diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index cf829ed..735b9dc 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -168,6 +168,15 @@ uv run python main.py config channel stable # or: beta uv run python main.py update ``` +### Running the tests + +Unit tests live in `tests/`, mirroring `core/`, `services/` and `engines/`. They call +the functions directly: no Docker, no network, no built binary. + +```bash +uv run pytest +uv run ruff check . && uv run ruff format --check . && uv run mypy +``` --- ## Reporting Issues @@ -237,4 +246,16 @@ If you encounter a bug or have a suggestion for improvement, follow these steps: Thank you for contributing! 🙌 ---- \ No newline at end of file +--- +## Releasing + +Releases are cut from GitHub Actions, never from a local machine. + +1. Open **Actions → Bump version → Run workflow**. +2. Pick the branch (`main` for stable, any branch for a release candidate). +3. Enter the version without a leading `v` (`26.09.0` for stable, `26.09.0rc1` for a candidate) and the matching channel. +4. The workflow commits `chore(release): `, creates the tag and pushes. The tag triggers the build, the GitHub release, the Discord notification and the template upload. + +Stable versions must match `X.Y.Z` and can only be cut from `main`. + +The workflow pushes with the `RELEASE_TOKEN` repository secret (a fine-grained PAT with *Contents: read and write*). A tag pushed with the default `GITHUB_TOKEN` would not trigger the release workflows. diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml new file mode 100644 index 0000000..2247c6b --- /dev/null +++ b/.github/actions/build/action.yml @@ -0,0 +1,45 @@ +name: Build the CLI binary +description: >- + Build the standalone binary from portabase.spec and check that it can read + its own bundled metadata. A binary that reports no version cannot resolve + its templates either, so it is rejected here rather than shipped. + +inputs: + name: + description: Binary name, without extension. + required: false + default: portabase + +outputs: + path: + description: Path to the built binary. + value: ${{ steps.build.outputs.path }} + +runs: + using: composite + steps: + - id: build + shell: bash + env: + PORTABASE_BINARY_NAME: ${{ inputs.name }} + run: | + set -euo pipefail + rm -rf build dist + uv run pyinstaller portabase.spec + + case "$(uname -s)" in + MINGW* | MSYS* | CYGWIN*) EXT=".exe" ;; + *) EXT="" ;; + esac + BINARY="dist/${PORTABASE_BINARY_NAME}${EXT}" + test -f "$BINARY" + echo "path=$BINARY" >> "$GITHUB_OUTPUT" + + - shell: bash + run: | + set -euo pipefail + VERSION=$("${{ steps.build.outputs.path }}" --version | head -1) + echo "$VERSION" + case "$VERSION" in + *unknown*) echo "::error::binary cannot read its bundled version"; exit 1 ;; + esac diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000..e51372c --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,13 @@ +name: Set up Python toolchain +description: Install uv and sync the locked environment, including dev dependencies. + +runs: + using: composite + steps: + - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 + with: + enable-cache: true + + - name: Install dependencies + shell: bash + run: uv sync --frozen --all-groups diff --git a/.github/assets/templates/agent.yml b/.github/assets/templates/agent.yml deleted file mode 100644 index 4a06bc1..0000000 --- a/.github/assets/templates/agent.yml +++ /dev/null @@ -1,22 +0,0 @@ -services: - agent: - restart: unless-stopped - image: portabase/agent:latest - volumes: - - ./databases.json:/config/config.json - environment: - TZ: "${TZ}" - EDGE_KEY: "${EDGE_KEY}" - LOG_LEVEL: "${LOG_LEVEL}" - POLLING: "${POLLING}" - networks: - - portabase - -{{EXTRA_SERVICES}} - -{{EXTRA_VOLUMES}} - -networks: - portabase: - name: portabase_network - external: true diff --git a/.github/assets/templates/dashboard.yml b/.github/assets/templates/dashboard.yml deleted file mode 100644 index 5d27087..0000000 --- a/.github/assets/templates/dashboard.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: ${PROJECT_NAME} -services: - portabase: - container_name: ${PROJECT_NAME}-app - image: portabase/portabase:latest - restart: unless-stopped - env_file: - - .env - ports: - - "${HOST_PORT}:80" - environment: - - TZ=${TZ} - - LOG_LEVEL=${LOG_LEVEL} - - PROJECT_SECRET=${PROJECT_SECRET} - - PROJECT_URL=${PROJECT_URL} - volumes: - - portabase-data:/data - depends_on: - db: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost/api/health"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 60s - db: - container_name: ${PROJECT_NAME}-pg - image: postgres:17-alpine - restart: unless-stopped - ports: - - "${PG_PORT}:5432" - volumes: - - postgres-data:/var/lib/postgresql/data - environment: - - POSTGRES_DB=${POSTGRES_DB} - - POSTGRES_USER=${POSTGRES_USER} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] - interval: 10s - timeout: 5s - retries: 5 -volumes: - postgres-data: - portabase-data: diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..acd57de --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: ["*"] + commit-message: + prefix: "ci" + + - package-ecosystem: uv + directory: / + schedule: + interval: weekly + groups: + python: + patterns: ["*"] + commit-message: + prefix: "build" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..7942dbf --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,62 @@ +name: Build binaries + +on: + workflow_call: + +permissions: {} + +jobs: + build: + name: ${{ matrix.os }}-${{ matrix.arch }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + permissions: + contents: read + id-token: write + attestations: write + strategy: + fail-fast: false + matrix: + include: + - os: linux + arch: amd64 + runner: ubuntu-latest + - os: linux + arch: arm64 + runner: ubuntu-24.04-arm + - os: macos + arch: arm64 + runner: macos-latest + - os: macos + arch: amd64 + runner: macos-15-intel + - os: windows + arch: amd64 + runner: windows-latest + ext: .exe + defaults: + run: + shell: bash + env: + NAME: portabase_${{ matrix.os }}_${{ matrix.arch }} + BINARY: portabase_${{ matrix.os }}_${{ matrix.arch }}${{ matrix.ext }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: ./.github/actions/setup + + - name: Build + id: build + uses: ./.github/actions/build + with: + name: ${{ env.NAME }} + + - name: Attest provenance + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2 + with: + subject-path: ${{ steps.build.outputs.path }} + + - name: Upload + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ${{ env.BINARY }} + path: ${{ steps.build.outputs.path }} diff --git a/.github/workflows/bump.yml b/.github/workflows/bump.yml new file mode 100644 index 0000000..f8fe0f8 --- /dev/null +++ b/.github/workflows/bump.yml @@ -0,0 +1,85 @@ +name: Bump version + +on: + workflow_dispatch: + inputs: + version: + description: "Version (e.g. 26.09.0 or 26.09.0rc1). No leading v." + required: true + type: string + channel: + description: "stable: only from main. rc: any branch." + required: true + type: choice + options: [stable, rc] + default: rc + +permissions: {} + +jobs: + bump: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_TOKEN }} + + - name: Validate version against channel + env: + VERSION: ${{ inputs.version }} + CHANNEL: ${{ inputs.channel }} + REF: ${{ github.ref_name }} + run: | + set -euo pipefail + if [[ "$VERSION" == v* ]]; then + echo "::error::Version must not start with 'v'"; exit 1 + fi + if [[ "$CHANNEL" == "stable" ]]; then + if [[ "$REF" != "main" ]]; then + echo "::error::stable releases are only allowed from main (got $REF)"; exit 1 + fi + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::stable version must match X.Y.Z"; exit 1 + fi + else + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.]?(rc|alpha|beta|a|b)[0-9]*(\.[0-9]+)?)$ ]]; then + echo "::error::rc version must match X.Y.Z(rc|a|b|alpha|beta)N"; exit 1 + fi + fi + if git rev-parse "$VERSION" >/dev/null 2>&1; then + echo "::error::Tag $VERSION already exists"; exit 1 + fi + + - name: Update version files + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + DATE=$(date -u +%F) + sed -i "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml + if [ -f CITATION.cff ]; then + sed -i "s/^version: .*/version: $VERSION/" CITATION.cff + sed -i "s/^date-released: .*/date-released: \"$DATE\"/" CITATION.cff + fi + git diff --stat + + - name: Commit, tag, push + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add pyproject.toml CITATION.cff + if git diff --cached --quiet; then + echo "No version change to commit" + else + git commit -m "chore(release): $VERSION" + fi + git tag -a "$VERSION" -m "Release $VERSION" + git push origin HEAD + git push origin "$VERSION" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9b0883d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: {} + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + name: ${{ matrix.name }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - name: lint + run: uv run ruff check . --output-format=github + - name: format + run: uv run ruff format --check . + - name: types + run: uv run mypy + - name: tests + run: uv run pytest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: ./.github/actions/setup + - run: ${{ matrix.run }} + + binary: + name: binary + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: ./.github/actions/setup + + - name: Build + id: build + uses: ./.github/actions/build diff --git a/.github/workflows/discord.yml b/.github/workflows/discord.yml new file mode 100644 index 0000000..9bc6de4 --- /dev/null +++ b/.github/workflows/discord.yml @@ -0,0 +1,81 @@ +name: Discord Notification + +on: + workflow_call: + inputs: + release_tag: + required: true + type: string + discord_title: + required: true + type: string + discord_color: + required: true + type: number + discord_footer: + required: false + type: string + default: Portabase + secrets: + DISCORD_WEBHOOK: + required: true + +permissions: {} + +jobs: + notify: + name: notify + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Send Discord notification + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + RELEASE_TAG: ${{ inputs.release_tag }} + DISCORD_TITLE: ${{ inputs.discord_title }} + DISCORD_COLOR: ${{ inputs.discord_color }} + DISCORD_FOOTER: ${{ inputs.discord_footer }} + run: | + set -euo pipefail + + RELEASE_INFO=$(gh release view "$RELEASE_TAG" -R "$REPOSITORY" --json name,url,body) + + RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r '.name // empty') + if [ -z "$RELEASE_TITLE" ]; then + RELEASE_TITLE="$RELEASE_TAG" + fi + RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r '.url // empty') + RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r '.body // ""') + + # Discord rejects the whole payload if any field is over its limit. + jq -n \ + --arg title "$RELEASE_TITLE" \ + --arg description "$RELEASE_BODY" \ + --arg url "$RELEASE_URL" \ + --arg icon "https://github.com/Portabase.png" \ + --arg discord_title "$DISCORD_TITLE" \ + --arg discord_footer "$DISCORD_FOOTER" \ + --argjson discord_color "$DISCORD_COLOR" \ + 'def clip($max): if (. | length) > $max then (.[0:$max - 3] + "...") else . end; + { + content: ($discord_title | clip(2000)), + embeds: [{ + title: ($title | clip(256)), + url: $url, + description: ($description | clip(4096)), + color: $discord_color, + author: { name: "Portabase", icon_url: $icon }, + footer: { text: ($discord_footer | clip(2048)) } + }] + }' > payload.json + + jq . payload.json + + curl --fail-with-body -sS \ + -H "Content-Type: application/json" \ + -d @payload.json \ + "$DISCORD_WEBHOOK?wait=true" diff --git a/.github/workflows/github.yml b/.github/workflows/github.yml deleted file mode 100644 index bc49fbf..0000000 --- a/.github/workflows/github.yml +++ /dev/null @@ -1,147 +0,0 @@ -name: GitHub Release - -on: - workflow_call: - inputs: - artifact_name: - required: false - type: string - default: "" - prerelease: - required: false - type: boolean - default: false - make_latest: - required: false - type: boolean - default: false - discord_title: - required: true - type: string - discord_color: - required: true - type: number - discord_footer: - required: true - type: string - secrets: - DISCORD_WEBHOOK: - required: true - GH_TOKEN: - required: true - -jobs: - create-release: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Check out the repo - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Download artifacts - if: inputs.artifact_name != '' - uses: actions/download-artifact@v4 - with: - pattern: ${{ inputs.artifact_name }} - path: dist - merge-multiple: true - - - name: Generate Checksums - if: inputs.artifact_name != '' - working-directory: dist - run: | - sha256sum * > checksums.txt - - - name: Build Changelog - id: build_changelog - uses: mikepenz/release-changelog-builder-action@v5 - with: - mode: "COMMIT" - configurationJson: | - { - "template": "#{{CHANGELOG}}", - "categories": [ - { - "title": "## Feature", - "labels": ["feat", "feature"] - }, - { - "title": "## Fix", - "labels": ["fix", "bug"] - }, - { - "title": "## Other", - "labels": [] - } - ], - "label_extractor": [ - { - "pattern": "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)", - "on_property": "title", - "target": "$1" - } - ] - } - env: - GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: dist/* - generate_release_notes: false - body: ${{ steps.build_changelog.outputs.changelog }} - prerelease: ${{ inputs.prerelease }} - make_latest: ${{ inputs.make_latest }} - env: - GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} - - - name: Send Discord Notification - env: - DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} - GH_TOKEN: ${{ secrets.GH_TOKEN }} - run: | - RELEASE_INFO=$(gh release view "${{ github.ref_name }}" -R ${{ github.repository }} --json name,url,body,author) - - RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name) - if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ github.ref_name }}"; fi - - RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url) - RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body) - - AUTHOR_NAME="Portabase" - AUTHOR_ICON="https://github.com/Portabase.png" - - PAYLOAD=$(jq -n \ - --arg title "$RELEASE_TITLE" \ - --arg description "$RELEASE_BODY" \ - --arg url "$RELEASE_URL" \ - --arg author "$AUTHOR_NAME" \ - --arg icon "$AUTHOR_ICON" \ - --arg discord_title "${{ inputs.discord_title }}" \ - --arg discord_footer "${{ inputs.discord_footer }}" \ - --argjson discord_color ${{ inputs.discord_color }} \ - '{ - content: $discord_title, - embeds: [{ - title: $title, - url: $url, - description: $description, - color: $discord_color, - author: { - name: $author, - icon_url: $icon - }, - footer: { - text: $discord_footer - } - }] - }' - ) - - curl -H "Content-Type: application/json" \ - -d "$PAYLOAD" \ - "$DISCORD_WEBHOOK" diff --git a/.github/workflows/plumber.yml b/.github/workflows/plumber.yml new file mode 100644 index 0000000..e29abf7 --- /dev/null +++ b/.github/workflows/plumber.yml @@ -0,0 +1,28 @@ +name: Plumber + +on: + pull_request: + push: + branches: [main] + +permissions: {} + +concurrency: + group: plumber-${{ github.ref }} + cancel-in-progress: true + +jobs: + audit: + name: audit + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: getplumber/plumber@7ad9d267ee5a00163cec9e5c749a088d5f565167 # v0.4.26 + with: + score-push: true + upload-sarif: false + soft-fail: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..07edf57 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,68 @@ +name: Publish release + +on: + workflow_call: + inputs: + prerelease: + required: true + type: boolean + +permissions: {} + +jobs: + publish: + name: publish + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + + - name: Download binaries + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: portabase_* + path: dist + merge-multiple: true + + - name: Generate checksums + working-directory: dist + run: sha256sum * > checksums.txt + + - name: Build changelog + id: changelog + uses: mikepenz/release-changelog-builder-action@c9dc8369bccbc41e0ac887f8fd674f5925d315f7 # v5 + with: + mode: COMMIT + configurationJson: | + { + "template": "#{{CHANGELOG}}", + "categories": [ + { "title": "## Feature", "labels": ["feat", "feature"] }, + { "title": "## Fix", "labels": ["fix", "bug"] }, + { "title": "## Other", "labels": [] } + ], + "label_extractor": [ + { + "pattern": "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\\([\\w\\-\\.]+\\))?(!)?: ([\\w ])+([\\s\\S]*)", + "on_property": "title", + "target": "$1" + } + ] + } + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: Create GitHub release + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + with: + files: dist/* + generate_release_notes: false + body: ${{ steps.changelog.outputs.changelog }} + prerelease: ${{ inputs.prerelease }} + make_latest: ${{ !inputs.prerelease }} + env: + GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml deleted file mode 100644 index 320173f..0000000 --- a/.github/workflows/python.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: Build Python Binaries - -on: - workflow_call: - -jobs: - build: - name: Build for ${{ matrix.os }} (${{ matrix.arch }}) - runs-on: ${{ matrix.runner }} - strategy: - matrix: - include: - - os: linux - arch: amd64 - runner: ubuntu-latest - - os: linux - arch: arm64 - runner: ubuntu-24.04-arm - - os: macos - arch: arm64 - runner: macos-latest - - os: macos - arch: amd64 - runner: macos-15-intel - - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v3 - - - name: Set up Python - run: uv python install - - - name: Build binary - run: | - rm -rf build dist *.spec - uv run pyinstaller \ - --onefile \ - --name portabase_${{ matrix.os }}_${{ matrix.arch }} \ - --paths=. \ - --collect-all rich \ - --collect-all requests \ - --collect-data certifi \ - --add-data "pyproject.toml:." \ - main.py - - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: portabase_${{ matrix.os }}_${{ matrix.arch }} - path: dist/portabase_${{ matrix.os }}_${{ matrix.arch }} - diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml deleted file mode 100644 index c64c173..0000000 --- a/.github/workflows/release-candidate.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Publish Python binaries for release candidate - -on: - push: - tags: - - '*.*.*a*' - - '*.*.*b*' - - '*.*.*rc*' - - '*.*.*alpha*' - - '*.*.*beta*' - -permissions: - contents: write - packages: write - -jobs: - python_build: - uses: ./.github/workflows/python.yml - - github_release: - needs: python_build - uses: ./.github/workflows/github.yml - with: - artifact_name: "portabase_*" - prerelease: true - make_latest: false - discord_title: "||@release-cli|| New release candidate published" - discord_color: 16776960 - discord_footer: "Portabase" - secrets: - DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - template_upload: - needs: python_build - uses: ./.github/workflows/templates-upload.yml - with: - version: ${{ github.ref_name }} - is_prerelease: true - secrets: - S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }} - S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} - S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} - S3_BUCKET: ${{ secrets.S3_BUCKET }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a9a422..c170c0c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,46 +1,59 @@ -name: Publish Python binaries for release +name: Release on: push: tags: - '*.*.*' - - '!*-*' - - '!*.*.*a*' - - '!*.*.*b*' - - '!*.*.*rc*' - - '!*.*.*alpha*' - - '!*.*.*beta*' permissions: contents: write - packages: write + id-token: write + attestations: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: - python_build: - uses: ./.github/workflows/python.yml + channel: + name: channel + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + prerelease: ${{ steps.detect.outputs.prerelease }} + steps: + - id: detect + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + if [[ "$TAG" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+([-.]?(rc|alpha|beta|a|b)[0-9]*(\.[0-9]+)?)$ ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + elif [[ "$TAG" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "prerelease=false" >> "$GITHUB_OUTPUT" + else + echo "::error::tag '$TAG' is neither X.Y.Z nor X.Y.ZN" + exit 1 + fi + + build: + needs: channel + uses: ./.github/workflows/build.yml - github_release: - needs: python_build - uses: ./.github/workflows/github.yml + publish: + needs: [channel, build] + uses: ./.github/workflows/publish.yml with: - artifact_name: "portabase_*" - prerelease: false - make_latest: true - discord_title: "||@release-cli|| New release published" - discord_color: 5814783 - discord_footer: "Portabase" - secrets: - DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + prerelease: ${{ needs.channel.outputs.prerelease == 'true' }} - template_upload: - needs: python_build - uses: ./.github/workflows/templates-upload.yml + announce: + needs: [channel, publish] + uses: ./.github/workflows/discord.yml with: - version: ${{ github.ref_name }} - is_prerelease: false - secrets: - S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }} - S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} - S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} - S3_BUCKET: ${{ secrets.S3_BUCKET }} + release_tag: ${{ github.ref_name }} + discord_title: >- + ${{ needs.channel.outputs.prerelease == 'true' + && '||@release-cli|| New release candidate published' + || '||@release-cli|| New release published' }} + discord_color: ${{ needs.channel.outputs.prerelease == 'true' && 16776960 || 5814783 }} + secrets: inherit diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..e4aac39 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,45 @@ +name: Security + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: security-${{ github.ref }} + cancel-in-progress: true + +jobs: + dependencies: + name: dependencies + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + scan-type: fs + format: table + severity: CRITICAL,HIGH + ignore-unfixed: true + exit-code: '1' + + secrets: + name: secrets + # Forks cannot read secrets, so the action would fail for reasons unrelated + # to the code under review. + if: github.event.pull_request.head.repo.fork == false + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + - uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} + GITLEAKS_CONFIG: .gitleaks.toml diff --git a/.github/workflows/templates-upload.yml b/.github/workflows/templates-upload.yml deleted file mode 100644 index 3963b47..0000000 --- a/.github/workflows/templates-upload.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Upload Templates to S3 - -on: - workflow_call: - inputs: - version: - required: true - type: string - is_prerelease: - required: true - type: boolean - secrets: - S3_ENDPOINT: - required: true - S3_ACCESS_KEY: - required: true - S3_SECRET_KEY: - required: true - S3_BUCKET: - required: true - -jobs: - upload: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Install S3cmd - run: sudo apt-get update && sudo apt-get install -y s3cmd - - - name: Configure S3cmd - run: | - cat < ~/.s3cfg - [default] - access_key = ${{ secrets.S3_ACCESS_KEY }} - secret_key = ${{ secrets.S3_SECRET_KEY }} - host_base = ${{ secrets.S3_ENDPOINT }} - host_bucket = %(bucket)s.${{ secrets.S3_ENDPOINT }} - use_https = True - EOF - - - name: Upload Versioned Templates - run: | - CLEAN_VERSION=$(echo "${{ inputs.version }}" | sed 's/^v//') - s3cmd sync .github/assets/templates/ s3://${{ secrets.S3_BUCKET }}/cli/public/templates/$CLEAN_VERSION/ --acl-public - - - name: Upload Latest Templates (Stable Only) - if: ${{ !inputs.is_prerelease }} - run: | - s3cmd sync .github/assets/templates/ s3://${{ secrets.S3_BUCKET }}/cli/public/templates/latest/ --acl-public diff --git a/.gitignore b/.gitignore index ecc0d2a..fcb772d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,12 @@ -dist/ -build/ +/dist/ +/build/ .venv/ __pycache__/ -uv.lock +# Keep portabase.spec: it is the build definition. *.spec +!portabase.spec .claude .codex/ -/docs \ No newline at end of file + +test/ diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..f0136f6 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,19 @@ +title = "portabase-cli" + +[extend] +useDefault = true + +[allowlist] +description = "Known false positives" +paths = [ + '''templates/.*''', + '''uv\.lock''', +] +regexes = [ + '''\$\{[A-Z0-9_]+\}''', + '''"masterKeyB64"''', +] +commits = [ + "074f436a291a49b8b9353bb5f7b536c41d9ecf52", + "c00f128f8e40964c4afe3acb77fcbd6670a9d5fa", +] diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..b311557 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,2 @@ +# Fake EDGE_KEY +394b25d811a4267e2e2c3c668030e6b9adc68e87:.github/workflows/ci.yml:generic-api-key:56 diff --git a/CITATION.cff b/CITATION.cff index f51f2dd..69b7e28 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -7,6 +7,8 @@ authors: given-names: Charles - family-names: Larcher given-names: Killian + - family-names: Lagache + given-names: Théo repository-code: https://github.com/Portabase/cli url: https://portabase.io abstract: "Portabase CLI is a command-line interface tool designed to streamline and enhance the management of Portabase services, providing developers with efficient access to core functionalities directly from the terminal." diff --git a/README.md b/README.md index a5b1f67..252a001 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ The official command line interface (CLI) for managing and deploying Portabase instances with ease.

- +[![Plumber Score](https://score.getplumber.io/github.com/Portabase/cli.svg)](https://score.getplumber.io/github.com/Portabase/cli) [![License: Apache](https://img.shields.io/badge/License-apache-yellow.svg)](LICENSE) [![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey)](https://github.com/Portabase/portabase) @@ -63,3 +63,28 @@ Distributed under the Apache License. See `LICENSE.txt` for more details. + +## Commands + +``` +portabase agent create NAME create an agent folder +portabase agent show|set|unset NAME +portabase agent db add|remove|list NAME +portabase dashboard create NAME create a dashboard folder +portabase dashboard show|set|unset NAME +portabase dashboard auth add|list|remove NAME +portabase start|stop|restart|logs|uninstall|build PATH +``` + +`portabase db` was removed: database commands only apply to an agent, use `portabase agent db`. + +## Upgrading from 26.08 or earlier + +From this release the CLI owns `docker-compose.yml`: it is re-rendered from your +`.env` and `databases.json` whenever you run `portabase agent db add`, `agent db remove` or +`build`. The first time that happens on an older install, the existing file is +copied to `docker-compose.legacy.yml` first. + +- Preview the change before applying it: `portabase build --diff` +- Keep your own customisations in `docker-compose.override.yml`; Docker Compose + merges it automatically and the CLI never touches it. diff --git a/commands/agent.py b/commands/agent.py index b03c402..176bb0d 100644 --- a/commands/agent.py +++ b/commands/agent.py @@ -1,949 +1,221 @@ -import os -import secrets -import uuid +from __future__ import annotations + from pathlib import Path -from typing import Optional +from typing import Annotated, Any -import questionary import typer -from rich.panel import Panel -from rich.prompt import Confirm, IntPrompt, Prompt -from rich.table import Table -from core.config import add_db_to_json, load_db_config, write_env_file, write_file -from core.docker import ensure_network, run_compose -from core.network import fetch_template -from core.utils import ( - check_system, - console, - generate_password, - get_free_port, - get_random_hint, - print_banner, - questionary_style, - validate_edge_key, -) -from templates.compose import ( - AGENT_FIREBIRD_SNIPPET, - AGENT_MARIADB_SNIPPET, - AGENT_MONGODB_AUTH_SNIPPET, - AGENT_MONGODB_SNIPPET, - AGENT_MSSQL_SNIPPET, - AGENT_POSTGRES_SNIPPET, - AGENT_REDIS_AUTH_SNIPPET, - AGENT_REDIS_SNIPPET, - AGENT_VALKEY_AUTH_SNIPPET, - AGENT_VALKEY_SNIPPET, +from commands.base import Command, CommandGroup +from commands.db import DbCommands, report_write +from commands.flows.add_database import AddDatabaseFlow +from commands.settings import ( + SetCommand, + UnsetCommand, + apply_settings, + display, + read_secret_flags, + show_settings, + with_settings_flags, ) +from engines import EngineRegistry +from services import settings as cfg +from services.docker import DockerRunner +from services.ports import PortAllocator +from services.project import AgentProject +from services.renderer import ComposeRenderer +from services.telemetry import Telemetry +from services.templates import TemplateRepository +from ui import UI + +NETWORK = "portabase_network" + + +class AgentCreateCommand(Command): + name, help, panel = "create", "Create a new Portabase Agent instance.", "Components" + no_args_is_help = True + + def __init__( + self, + ui: UI, + telemetry: Telemetry, + docker: DockerRunner, + templates: TemplateRepository, + renderer: ComposeRenderer, + engines: EngineRegistry, + ports: PortAllocator, + ) -> None: + super().__init__(ui, telemetry) + self.docker = docker + self.templates = templates + self.renderer = renderer + self.engines = engines + self.ports = ports + + def register(self, app: typer.Typer) -> None: + app.command( + self.name, help=self.help, rich_help_panel=self.panel, no_args_is_help=True + )(self._traced(with_settings_flags(self.run, cfg.AGENT))) + + def run( + self, + name: Annotated[str, typer.Argument(help="Agent name (creates a folder)")], + start: Annotated[ + bool, typer.Option("--start", "-s", help="Start immediately") + ] = False, + force: Annotated[ + bool, typer.Option("--force", "-f", help="Overwrite an existing folder") + ] = False, + yes: Annotated[ + bool, + typer.Option("--yes", "-y", help="Skip the configuration confirmation"), + ] = False, + **settings: Any, + ) -> None: + self.ui.banner() + self.require_docker(self.docker) + self.docker.ensure_network(NETWORK) + self.templates.resolve() + + path = Path(name).resolve() + if path.exists() and not force: + self.ui.warning(f"Directory '{name}' already exists.") + self.confirm_or_abort("Overwrite?", default=False) + + provided = read_secret_flags(cfg.AGENT, settings) + form = self.ui.form() + answers = { + setting.name: form.ask(setting.field, provided.get(setting.name)) + for setting in cfg.AGENT + if setting.core + } + env_vars = { + setting.env: setting.to_env(answers[setting.name]) + for setting in cfg.AGENT + if setting.core and setting.env + } + gateway = bool(answers["host_gateway"]) + + rows = [("Agent Name", name), ("Path", str(path))] + rows += [ + (setting.field.prompt, display(setting, answers[setting.name])) + for setting in cfg.AGENT + if setting.core + ] + rows.append(("Files to Create", "docker-compose.yml, .env, databases.json")) + self.ui.summary(rows, title="SUMMARY") + if not yes: + self.confirm_or_abort( + "Apply this configuration and generate files?", default=True + ) - -def agent( - name: str = typer.Argument(..., help="Name of the agent (creates a folder)"), - key: Optional[str] = typer.Option(None, "--key", "-k", help="Edge Key"), - tz: str = typer.Option("UTC", "--tz", help="Timezone"), - polling: int = typer.Option(5, "--polling", help="Polling frequency in seconds"), - start: bool = typer.Option(False, "--start", "-s", help="Start immediately"), -): - - print_banner() - check_system() - ensure_network("portabase_network") - - path = Path(name).resolve() - if path.exists(): - console.print(f"[warning]Directory '{name}' already exists.[/warning]") - if not Confirm.ask("Overwrite?"): - raise typer.Exit() - - path.mkdir(parents=True, exist_ok=True) - - if not key: - key = Prompt.ask("[key]Edge Key[/key]") - - if not validate_edge_key(key): - console.print( - "[danger]✖ Invalid Edge Key. Please check the format (Base64 or JSON).[/danger]" + project = AgentProject.create(path, env_vars, host_gateway=gateway) + apply_settings( + self.ui, + project, + { + key: value + for key, value in provided.items() + if not cfg.AGENT.get(key).core + }, ) - raise typer.Exit(1) - - if not tz or tz == "UTC": - tz = Prompt.ask("Timezone", default="UTC") - - if polling == 5: - polling = IntPrompt.ask("Polling frequency (seconds)", default=5) - - add_host_gateway = Confirm.ask( - "Add extra_hosts mapping (localhost -> host-gateway)?", default=False - ) + self._write(project) + self.ui.success(f"Agent '{name}' created in {path}") - raw_template = fetch_template("agent.yml") - - if "{{EXTRA_SERVICES}}" not in raw_template: - if "\nnetworks:" in raw_template: - raw_template = raw_template.replace( - "\nnetworks:", "\n\n{{EXTRA_SERVICES}}\n\nnetworks:" + if self.ui.non_interactive: + self.ui.hint( + f"Add databases with: portabase agent db add {name} " + "--engine postgresql --mode new" ) else: - raw_template += "\n\n{{EXTRA_SERVICES}}\n" + self.ui.section("Database Setup") + flow = AddDatabaseFlow(self.ui, self.engines, self.ports) + while self.ui.confirm("Add a database?", default=True): + spec, engine = flow.collect({}) + flow.apply(project, spec, engine) + self._write(project) + self.ui.success( + f"Added {engine.display} '{spec.name}' ({engine.describe(spec)})" + ) - if "{{EXTRA_VOLUMES}}" not in raw_template: - if "\nnetworks:" in raw_template: - raw_template = raw_template.replace( - "\nnetworks:", "\n\n{{EXTRA_VOLUMES}}\n\nnetworks:" - ) + if start or ( + not self.ui.non_interactive + and self.ui.confirm("Start agent now?", default=False) + ): + with self.ui.status("Starting agent..."): + self.docker.compose(path, ["up", "-d"]) + self.ui.success("Agent started.") else: - raw_template += "\n\n{{EXTRA_VOLUMES}}\n" - - env_vars = { - "EDGE_KEY": key, - "TZ": tz, - "POLLING": str(polling), - "LOG_LEVEL": "info", - } - - extra_services = "" - extra_volumes = "" - app_volumes = ["./databases.json:/config/config.json"] - volumes_list = [] - - json_path = path / "databases.json" - if not json_path.exists(): - write_file(json_path, '{"databases": []}') - try: - os.chmod(json_path, 0o666) - except: - pass - - console.print("") - console.print(Panel("[bold]Database Setup[/bold]", style="cyan")) - - while True: - storage_kind = questionary.select( - "What do you want to configure?", - choices=["done", "database", "docker-volume"], - default="database", - style=questionary_style, - ).ask() - - if storage_kind in (None, "done"): - break - - if storage_kind == "docker-volume": - console.print( - "[warning]⚠ Requires the Docker socket. It will be mounted " - "on the agent ([bold]/var/run/docker.sock[/bold]).[/warning]" - ) - friendly_name = Prompt.ask("Display Name", default="Docker Volume") - volume_name = Prompt.ask("Volume Name (e.g. databases_sqlite-data)").strip() - while not volume_name: - console.print("[danger]✖ Volume Name is required.[/danger]") - volume_name = Prompt.ask( - "Volume Name (e.g. databases_sqlite-data)" - ).strip() - container_name = Prompt.ask( - "Container Name (optional, enables auto-restart after restore)", - default="", - ) - dv_entry = { - "name": friendly_name, - "type": "docker-volume", - "volume_name": volume_name, - "generated_id": str(uuid.uuid4()), - } - if container_name: - dv_entry["container_name"] = container_name - - sock_mount = "/var/run/docker.sock:/var/run/docker.sock" - if sock_mount not in app_volumes: - app_volumes.append(sock_mount) - - add_db_to_json(path, dv_entry) - console.print("[success]✔ Added to config[/success]") - continue - - while True: - mode = Prompt.ask( - "Configuration Mode", choices=["new", "existing", "back"], default="new" - ) - - if mode == "back": - break - - if mode == "existing": - console.print("[info]External/Existing Database Configuration[/info]") - db_type = questionary.select( - "Select Database Type", - choices=[ - "back", - "postgresql", - "postgresql-cluster", - "mysql", - "mariadb", - "sqlite", - "firebird", - "mongodb", - "redis", - "valkey", - "mssql", - ], - style=questionary_style, - ).ask() - - if db_type == "back": - continue - - if not db_type: - raise typer.Exit() - - if db_type == "postgresql-cluster": - console.print( - "[warning]⚠ Postgres Cluster requires a superuser. " - "Cluster backup/restore uses pg_dumpall, which dumps all " - "databases and global objects (roles, tablespaces). " - "The provided user must be a Postgres superuser.[/warning]" - ) - - friendly_name = Prompt.ask("Display Name", default="External DB") - - if db_type == "sqlite": - db_name = Prompt.ask("Database Path (relative or absolute)") - if not db_name.startswith("/"): - app_volumes.append(f"./{db_name}:/config/{db_name}") - container_path = f"/config/{db_name}" - else: - container_path = db_name - - add_db_to_json( - path, - { - "name": friendly_name, - "database": container_path, - "type": db_type, - "generated_id": str(uuid.uuid4()), - }, - ) - else: - db_name = Prompt.ask("Database Name") - host = Prompt.ask("Host", default="localhost") - port = IntPrompt.ask( - "Port", - default=5432 - if db_type in ["postgresql", "postgresql-cluster"] - else ( - 3050 - if db_type == "firebird" - else ( - 1433 - if db_type == "mssql" - else ( - 3306 if db_type in ["mysql", "mariadb"] else 27017 - ) - ) - ), - ) - user = Prompt.ask("Username") - password = questionary.password( - "Password", style=questionary_style - ).ask() - if password is None: - raise typer.Exit() - - ext_entry = { - "name": friendly_name, - "database": db_name, - "type": db_type, - "username": user, - "password": password, - "port": port, - "host": host, - "generated_id": str(uuid.uuid4()), - } - if db_type == "postgresql": - console.print( - "[info]ℹ When enabled, omits [bold]--no-owner[/bold] and " - "[bold]--no-privileges[/bold] from the dump. Ownership and role " - "assignments are preserved in the output. By default, these flags " - "are applied to keep restores portable across different users and " - "environments, for example when migrating from one database " - "instance to another.[/info]" - ) - - keep_ownership = Confirm.ask("Keep ownership?", default=False) - - if keep_ownership is None: - raise typer.Exit() - - console.print( - "[info]ℹ Controls how the target database is cleaned before " - "a restore. [bold]pg_restore --clean[/bold] only drops " - "objects listed in the backup's own table of contents, so " - "anything already present in the target that the dump does " - "not know about survives and can make the restore " - "fail.[/info]" - ) - clean_mode = questionary.select( - "Clean mode", - choices=[ - questionary.Choice( - "clean - pg_restore --clean --if-exists (default)", - value="clean", - ), - questionary.Choice( - "none - no pre-clean, restore into an empty database", - value="none", - ), - questionary.Choice( - "drop_schemas - drop every non-system schema CASCADE " - "(recommended, works on managed Postgres)", - value="drop_schemas", - ), - questionary.Choice( - "drop_database - DROP DATABASE + CREATE DATABASE " - "(full reset)", - value="drop_database", - ), - ], - default="clean", - style=questionary_style, - ).ask() - if clean_mode is None: - raise typer.Exit() - if clean_mode == "drop_database": - console.print( - "[warning]⚠ drop_database drops the whole target " - "database before restoring. The user must have CREATEDB " - "and own the database, or be a superuser. Most managed " - "Postgres providers do not allow it.[/warning]" - ) - - pg_options = {} - if keep_ownership: - pg_options["keep_ownership"] = True - if clean_mode != "clean": - pg_options["clean_mode"] = clean_mode - if pg_options: - ext_entry["options"] = pg_options - - add_db_to_json(path, ext_entry) - console.print("[success]✔ Added to config[/success]") - break - - else: - console.print("[info]New Local Docker Container[/info]") - db_engine = questionary.select( - "Select Database Engine", - choices=[ - "back", - "postgresql", - "postgresql-cluster", - "mysql", - "mariadb", - "sqlite", - "firebird", - "mongodb", - "redis", - "valkey", - "mssql", - ], - style=questionary_style, - ).ask() - - if db_engine == "back": - continue - - if not db_engine: - raise typer.Exit() - - db_variant = "no-auth" - if db_engine in ["mongodb", "redis", "valkey"]: - engine_display = { - "mongodb": "MongoDB", - "redis": "Redis", - "valkey": "Valkey", - }[db_engine] - db_variant = questionary.select( - f"Select {engine_display} Variant", - choices=["back", "no-auth", "with-auth"], - style=questionary_style, - ).ask() - - if db_variant == "back": - continue - - if not db_variant: - raise typer.Exit() - - if db_engine == "sqlite": - db_name = Prompt.ask("Database Name", default="local") - if not db_name.endswith(".sqlite"): - db_name += ".sqlite" - - app_volumes.append(f"./{db_name}:/config/{db_name}") - - add_db_to_json( - path, - { - "name": db_name, - "database": f"/config/{db_name}", - "type": "sqlite", - "generated_id": str(uuid.uuid4()), - }, - ) - console.print( - f"[success]✔ Added SQLite database ({db_name})[/success]" - ) - - elif db_engine in ["postgresql", "postgresql-cluster"]: - pg_port = get_free_port() - db_user = "admin" - db_pass = generate_password(16) - db_name = f"pg_{secrets.token_hex(4)}" - service_name = f"db-pg-{secrets.token_hex(2)}" - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(pg_port) - env_vars[f"{var_prefix}_DB"] = db_name - env_vars[f"{var_prefix}_USER"] = db_user - env_vars[f"{var_prefix}_PASS"] = db_pass - - snippet = ( - AGENT_POSTGRES_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - .replace("${USER}", f"${{{var_prefix}_USER}}") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - pg_entry = { - "name": db_name, - "database": db_name, - "type": db_engine, - "username": db_user, - "password": db_pass, - "port": 5432, - "host": service_name, - "generated_id": str(uuid.uuid4()), - } - if db_engine == "postgresql": - console.print( - "[info]ℹ When enabled, omits [bold]--no-owner[/bold] and " - "[bold]--no-privileges[/bold] from the dump. Ownership and role " - "assignments are preserved in the output. By default, these flags " - "are applied to keep restores portable across different users and " - "environments, for example when migrating from one database " - "instance to another.[/info]" - ) - keep_ownership = Confirm.ask("Keep ownership?", default=False) - - if keep_ownership is None: - raise typer.Exit() - - console.print( - "[info]ℹ Controls how the target database is cleaned before " - "a restore. [bold]pg_restore --clean[/bold] only drops " - "objects listed in the backup's own table of contents, so " - "anything already present in the target that the dump does " - "not know about survives and can make the restore " - "fail.[/info]" - ) - clean_mode = questionary.select( - "Clean mode", - choices=[ - questionary.Choice( - "clean - pg_restore --clean --if-exists (default)", - value="clean", - ), - questionary.Choice( - "none - no pre-clean, restore into an empty database", - value="none", - ), - questionary.Choice( - "drop_schemas - drop every non-system schema CASCADE " - "(recommended, works on managed Postgres)", - value="drop_schemas", - ), - questionary.Choice( - "drop_database - DROP DATABASE + CREATE DATABASE " - "(full reset)", - value="drop_database", - ), - ], - default="clean", - style=questionary_style, - ).ask() - if clean_mode is None: - raise typer.Exit() - if clean_mode == "drop_database": - console.print( - "[warning]⚠ drop_database drops the whole target " - "database before restoring. The user must have CREATEDB " - "and own the database, or be a superuser. Most managed " - "Postgres providers do not allow it.[/warning]" - ) - - pg_options = {} - if keep_ownership: - pg_options["keep_ownership"] = True - if clean_mode != "clean": - pg_options["clean_mode"] = clean_mode - if pg_options: - pg_entry["options"] = pg_options - - add_db_to_json(path, pg_entry) - - label = ( - "Postgres Cluster" - if db_engine == "postgresql-cluster" - else "Postgres" - ) - console.print( - f"[success]✔ Added {label} container (Port {pg_port})[/success]" - ) - - elif db_engine == "mariadb" or db_engine == "mysql": - mysql_port = get_free_port() - db_user = "admin" - db_pass = generate_password(16) - db_name = f"mysql_{secrets.token_hex(4)}" - service_name = f"db-mariadb-{secrets.token_hex(2)}" - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(mysql_port) - env_vars[f"{var_prefix}_DB"] = db_name - env_vars[f"{var_prefix}_USER"] = db_user - env_vars[f"{var_prefix}_PASS"] = db_pass - - snippet = ( - AGENT_MARIADB_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - .replace("${USER}", f"${{{var_prefix}_USER}}") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - add_db_to_json( - path, - { - "name": db_name, - "database": db_name, - "type": db_engine, - "username": db_user, - "password": db_pass, - "port": 3306, - "host": service_name, - "generated_id": str(uuid.uuid4()), - }, - ) - console.print( - f"[success]✔ Added MariaDB container (Port {mysql_port})[/success]" - ) - - elif db_engine == "mongodb": - if db_variant == "with-auth": - mongo_port = get_free_port() - db_user = "admin" - db_pass = generate_password(16) - db_name = f"mongo_{secrets.token_hex(4)}" - service_name = f"db-mongo-auth-{secrets.token_hex(2)}" - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(mongo_port) - env_vars[f"{var_prefix}_DB"] = db_name - env_vars[f"{var_prefix}_USER"] = db_user - env_vars[f"{var_prefix}_PASS"] = db_pass - - snippet = ( - AGENT_MONGODB_AUTH_SNIPPET.replace( - "${SERVICE_NAME}", service_name - ) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - .replace("${USER}", f"${{{var_prefix}_USER}}") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - add_db_to_json( - path, - { - "name": db_name, - "database": db_name, - "type": "mongodb", - "username": db_user, - "password": db_pass, - "port": 27017, - "host": service_name, - "generated_id": str(uuid.uuid4()), - }, - ) - console.print( - f"[success]✔ Added MongoDB Auth container (Port {mongo_port})[/success]" - ) - else: - mongo_port = get_free_port() - db_name = f"mongo_{secrets.token_hex(4)}" - service_name = f"db-mongo-{secrets.token_hex(2)}" - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(mongo_port) - env_vars[f"{var_prefix}_DB"] = db_name - - snippet = ( - AGENT_MONGODB_SNIPPET.replace( - "${SERVICE_NAME}", service_name - ) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - ) - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - add_db_to_json( - path, - { - "name": db_name, - "database": db_name, - "type": "mongodb", - "username": "", - "password": "", - "port": 27017, - "host": service_name, - "generated_id": str(uuid.uuid4()), - }, - ) - - console.print( - f"[success]✔ Added MongoDB container (Port {mongo_port})[/success]" - ) - - elif db_engine == "redis": - if db_variant == "with-auth": - redis_port = get_free_port() - db_name = f"redis_{secrets.token_hex(4)}" - service_name = f"db-redis-auth-{secrets.token_hex(2)}" - db_pass = generate_password(16) - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(redis_port) - env_vars[f"{var_prefix}_PASS"] = db_pass - - snippet = ( - AGENT_REDIS_AUTH_SNIPPET.replace( - "${SERVICE_NAME}", service_name - ) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - add_db_to_json( - path, - { - "name": db_name, - "database": "0", - "type": "redis", - "username": "", - "password": db_pass, - "port": 6379, - "host": service_name, - "generated_id": str(uuid.uuid4()), - }, - ) - console.print( - f"[success]✔ Added Redis Auth container (Port {redis_port})[/success]" - ) - else: - redis_port = get_free_port() - db_name = f"redis_{secrets.token_hex(4)}" - service_name = f"db-redis-{secrets.token_hex(2)}" - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(redis_port) - - snippet = ( - AGENT_REDIS_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - ) - - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - add_db_to_json( - path, - { - "name": db_name, - "database": "0", - "type": "redis", - "username": "", - "password": "", - "port": 6379, - "host": service_name, - "generated_id": str(uuid.uuid4()), - }, - ) - console.print( - f"[success]✔ Added Redis container (Port {redis_port})[/success]" - ) - - elif db_engine == "valkey": - if db_variant == "with-auth": - valkey_port = get_free_port() - db_name = f"valkey_{secrets.token_hex(4)}" - service_name = f"db-valkey-auth-{secrets.token_hex(2)}" - db_pass = generate_password(16) - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(valkey_port) - env_vars[f"{var_prefix}_PASS"] = db_pass - - snippet = ( - AGENT_VALKEY_AUTH_SNIPPET.replace( - "${SERVICE_NAME}", service_name - ) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - add_db_to_json( - path, - { - "name": db_name, - "database": "0", - "type": "valkey", - "username": "", - "password": db_pass, - "port": 6379, - "host": service_name, - "generated_id": str(uuid.uuid4()), - }, - ) - console.print( - f"[success]✔ Added Valkey Auth container (Port {valkey_port})[/success]" - ) - else: - valkey_port = get_free_port() - db_name = f"valkey_{secrets.token_hex(4)}" - service_name = f"db-valkey-{secrets.token_hex(2)}" - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(valkey_port) - - snippet = ( - AGENT_VALKEY_SNIPPET.replace( - "${SERVICE_NAME}", service_name - ) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - ) - - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - add_db_to_json( - path, - { - "name": db_name, - "database": "0", - "type": "valkey", - "username": "", - "password": "", - "port": 6379, - "host": service_name, - "generated_id": str(uuid.uuid4()), - }, - ) - console.print( - f"[success]✔ Added Valkey container (Port {valkey_port})[/success]" - ) - - elif db_engine == "firebird": - fb_port = get_free_port() - db_user = "alice" - db_pass = generate_password(16) - db_root_pass = generate_password(16) - db_name = "mirror.fdb" - db_container_path = f"/var/lib/firebird/data/{db_name}" - service_name = f"db-firebird-{secrets.token_hex(2)}" - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(fb_port) - env_vars[f"{var_prefix}_DB"] = db_name - env_vars[f"{var_prefix}_USER"] = db_user - env_vars[f"{var_prefix}_PASS"] = db_pass - env_vars[f"{var_prefix}_ROOT_PASS"] = db_root_pass - - snippet = ( - AGENT_FIREBIRD_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - .replace("${USER}", f"${{{var_prefix}_USER}}") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - .replace("${ROOT_PASSWORD}", f"${{{var_prefix}_ROOT_PASS}}") - ) - - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - add_db_to_json( - path, - { - "name": db_name, - "database": db_container_path, - "type": "firebird", - "username": db_user, - "password": db_pass, - "port": 3050, - "host": service_name, - "generated_id": str(uuid.uuid4()), - }, - ) - console.print( - f"[success]✔ Added Firebird container (Port {fb_port})[/success]" - ) - - elif db_engine == "mssql": - mssql_port = get_free_port() - db_pass = generate_password(16) - db_name = "master" - service_name = f"db-mssql-{secrets.token_hex(2)}" - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(mssql_port) - env_vars[f"{var_prefix}_PASS"] = db_pass - - snippet = ( - AGENT_MSSQL_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - add_db_to_json( - path, - { - "name": "MSSQL", - "database": db_name, - "type": "mssql", - "username": "sa", - "password": db_pass, - "port": 1433, - "host": service_name, - "generated_id": str(uuid.uuid4()), - }, - ) - console.print( - f"[success]✔ Added MSSQL container (Port {mssql_port})[/success]" - ) - break - - if volumes_list: - extra_volumes = "volumes:\n" - for vol in volumes_list: - extra_volumes += f" {vol}:\n" - - final_compose = raw_template.replace("{{EXTRA_SERVICES}}", extra_services) - final_compose = final_compose.replace("{{EXTRA_VOLUMES}}", extra_volumes) - - vols_str = "\n".join([f" - {v}" for v in app_volumes]) - final_compose = final_compose.replace( - " - ./databases.json:/config/config.json", vols_str - ) - - if add_host_gateway: - final_compose = final_compose.replace( - " image: portabase/agent:latest\n", - " image: portabase/agent:latest\n" - " extra_hosts:\n" - ' - "localhost:host-gateway"\n', + self.ui.info(f"Run: portabase start {name}") + + def _write(self, project: AgentProject) -> None: + with self.ui.status("Rendering configuration..."): + result = self.renderer.render_agent(project) + project.save_state() + report = result.write(project.path) + report_write(self.ui, report) + + +class AgentShowCommand(Command): + name, help, panel = "show", "Show an agent's settings and databases.", "Components" + no_args_is_help = True + + def __init__(self, ui: UI, telemetry: Telemetry, engines: EngineRegistry) -> None: + super().__init__(ui, telemetry) + self.engines = engines + + def run(self, path: Annotated[Path, typer.Argument(help="Agent folder")]) -> None: + project = AgentProject.load(self.require_project_dir(path)) + show_settings(self.ui, project) + if project.databases: + rows = [ + [ + database.name, + database.engine, + self.engines.get(database.engine).describe(database), + ] + for database in project.databases + ] + self.ui.table(["Name", "Engine", "Where"], rows, title="DATABASES") + else: + self.ui.hint("No database yet: portabase agent db add") + + +class AgentCommands(CommandGroup): + name, help, panel = "agent", "Create and manage Portabase agents.", "Components" + + def __init__( + self, + ui: UI, + telemetry: Telemetry, + docker: DockerRunner, + templates: TemplateRepository, + renderer: ComposeRenderer, + engines: EngineRegistry, + ports: PortAllocator, + ) -> None: + super().__init__(ui, telemetry) + self._create = AgentCreateCommand( + ui, telemetry, docker, templates, renderer, engines, ports ) - - summary = Table(show_header=False, box=None, padding=(0, 2)) - summary.add_column("Property", style="bold cyan") - summary.add_column("Value", style="white") - - summary.add_row("Agent Name", name) - summary.add_row("Path", str(path)) - summary.add_row("Edge Key", f"{key[:10]}...{key[-10:]}" if len(key) > 20 else key) - summary.add_row("Timezone", tz) - summary.add_row("Polling", f"{polling}s") - summary.add_row("Host Gateway", "Yes" if add_host_gateway else "No") - - db_config = load_db_config(path) - dbs = db_config.get("databases", []) - if dbs: - db_details = [] - for db in dbs: - if db.get("type") == "sqlite": - db_details.append(f"• {db['name']} (sqlite: {db['database']})") - elif db.get("type") == "docker-volume": - db_details.append( - f"• {db['name']} (docker-volume: {db.get('volume_name', 'N/A')})" - ) - else: - db_details.append( - f"• {db['name']} ({db['type']} on port {db.get('port', 'N/A')})" - ) - - summary.add_row("Databases", "\n".join(db_details)) - else: - summary.add_row("Databases", "[dim]None configured[/dim]") - - summary.add_row("Files to Create", "• docker-compose.yml\n• .env\n• databases.json") - - console.print("") - console.print( - Panel( - summary, - title="[bold white]PROPOSED CONFIGURATION[/bold white]", - border_style="bold blue", - expand=False, + self._templates, self._renderer, self._engines = templates, renderer, engines + self.db = DbCommands(ui, telemetry, engines, ports, templates, renderer, docker) + + @property + def commands(self) -> list[Command]: + shared = ( + self.ui, + self.telemetry, + self._templates, + AgentProject.load, + self._renderer.render_agent, ) - ) - console.print( - "[dim]The agent will be configured in the directory above and ready for deployment.[/dim]\n" - ) - - if not Confirm.ask( - "[bold]Apply this configuration and generate files?[/bold]", default=True - ): - console.print("[warning]Configuration cancelled.[/warning]") - raise typer.Exit() - - write_file(path / "docker-compose.yml", final_compose) - write_env_file(path, env_vars) - - console.print( - Panel(f"[bold white]AGENT READY: {name}[/bold white]", style="bold #5f00d7") - ) - - if start or Confirm.ask("Start agent now?", default=False): - status_msg = f"[bold magenta]Starting...[/bold magenta]\n{get_random_hint()}" - with console.status(status_msg, spinner="earth"): - run_compose(path, ["up", "-d"]) - console.print(f"[bold green]✔ Agent {name} is running[/bold green]") - else: - console.print(f"[info]Run: portabase start {name}[/info]") + return [ + self._create, + AgentShowCommand(self.ui, self.telemetry, self._engines), + SetCommand(*shared), + UnsetCommand(*shared), + ] + + @property + def groups(self) -> list[CommandGroup]: + return [self.db] diff --git a/commands/auth.py b/commands/auth.py new file mode 100644 index 0000000..cdab244 --- /dev/null +++ b/commands/auth.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Annotated, Any + +import typer + +from commands.base import Command, CommandGroup +from commands.db import report_write +from core.errors import ValidationError +from services import auth_providers as ap +from services.docker import DockerRunner +from services.ports import PortAllocator +from services.project import AuthProvider, DashboardProject, ProviderKind +from services.renderer import ComposeRenderer +from services.telemetry import Telemetry +from services.templates import TemplateRepository +from ui import UI + +PathArg = Annotated[Path, typer.Argument(help="Dashboard folder")] +KINDS: tuple[ProviderKind, ...] = ("oidc", "oauth") + + +class _AuthCommand(Command): + panel = "Components" + no_args_is_help = True + + def __init__( + self, + ui: UI, + telemetry: Telemetry, + docker: DockerRunner, + templates: TemplateRepository, + renderer: ComposeRenderer, + ports: PortAllocator, + ) -> None: + super().__init__(ui, telemetry) + self.templates = templates + self.renderer = renderer + + def load(self, path: Path) -> DashboardProject: + project_path = self.require_project_dir(path) + self.templates.resolve() + return DashboardProject.load(project_path) + + def write(self, project: DashboardProject) -> None: + with self.ui.status("Rendering configuration..."): + result = self.renderer.render_dashboard(project) + project.save_state() + report = result.write(project.path) + report_write(self.ui, report) + self.ui.info(f"Apply with: portabase restart {project.path.name}") + + +class AuthAddCommand(_AuthCommand): + name, help = "add", "Add an OIDC or OAuth login provider." + + def run( + self, + path: PathArg, + kind: Annotated[ + str | None, typer.Argument(help="oidc | oauth (asked if omitted)") + ] = None, + provider_id: Annotated[ + str | None, + typer.Argument( + help="Provider id: any slug for oidc, a known name for oauth " + "(google, github, discord, apple, linkedin, x, reddit)" + ), + ] = None, + client: Annotated[ + str | None, typer.Option("--client", help="Client ID") + ] = None, + secret: Annotated[ + str | None, + typer.Option("--secret", help="Client secret (prefer --secret-stdin)"), + ] = None, + secret_stdin: Annotated[ + bool, + typer.Option("--secret-stdin", help="Read the client secret from stdin"), + ] = False, + issuer: Annotated[ + str | None, typer.Option("--issuer", help="OIDC issuer / discovery URL") + ] = None, + title: Annotated[ + str | None, typer.Option("--title", help="Display name") + ] = None, + scopes: Annotated[ + str | None, typer.Option("--scopes", help="OIDC scopes") + ] = None, + pkce: Annotated[ + bool | None, typer.Option("--pkce/--no-pkce", help="OIDC: use PKCE") + ] = None, + host: Annotated[ + str | None, typer.Option("--host", help="OIDC host override") + ] = None, + ) -> None: + form = self.ui.form() + picked = form.choice("Provider kind", list(KINDS), value=kind, name="kind") + provider_kind: ProviderKind = "oidc" if picked == "oidc" else "oauth" + if provider_kind == "oauth": + provider_id = form.choice( + "OAuth provider", list(ap.OAUTH_PROVIDERS), value=provider_id, name="id" + ) + else: + provider_id = form.text( + "Provider id (slug, e.g. keycloak)", value=provider_id, name="id" + ) + pid = ap.validate_provider_id(provider_kind, provider_id) + if secret_stdin: + secret = sys.stdin.readline().rstrip("\n") + elif secret is not None: + self.ui.warning( + "--secret is visible in shell history; prefer --secret-stdin." + ) + + values: dict[str, Any] = { + "client": client, + "secret": secret, + "issuer": issuer, + "title": title, + "scopes": scopes, + "pkce": pkce, + "host": host, + } + fields = ap.OIDC_FIELDS if provider_kind == "oidc" else ap.OAUTH_FIELDS + allowed = {field.name for field in fields} + stray = sorted( + key + for key, value in values.items() + if value is not None and key not in allowed + ) + if stray: + flags = ", ".join("--" + name for name in stray) + raise ValidationError(f"Not applicable to {provider_kind}: {flags}.") + + project = self.load(path) + answers = form.collect(list(fields), values) + provider = AuthProvider(kind=provider_kind, id=pid, values=answers) + project.add_provider(provider) + self.write(project) + self.ui.success(f"Added {provider_kind} provider '{pid}'.") + self.ui.info( + f"Callback URL to register at the provider: {project.callback_url(pid)}" + ) + + +class AuthListCommand(_AuthCommand): + name, help = "list", "List login providers." + + def run(self, path: PathArg) -> None: + project = self.load(path) + providers = project.providers + if not providers: + self.ui.warning("No login provider configured.") + return + self.ui.table( + ["Kind", "Id", "Title", "Issuer / provider", "Callback"], + [ + [ + provider.kind, + provider.id, + provider.values.get("title", ""), + provider.values.get("issuer", provider.id), + project.callback_url(provider.id), + ] + for provider in providers + ], + title=f"Login providers for {project.path.name}", + ) + + +class AuthRemoveCommand(_AuthCommand): + name, help = "remove", "Remove a login provider." + + def run( + self, + path: PathArg, + provider_id: Annotated[ + str | None, typer.Argument(help="Provider id (asked if omitted)") + ] = None, + yes: Annotated[ + bool, typer.Option("--yes", "-y", help="Skip confirmation") + ] = False, + ) -> None: + project = self.load(path) + if provider_id is None: + providers = project.providers + if not providers: + self.ui.warning("No login provider to remove.") + return + choices = [f"{provider.id} ({provider.kind})" for provider in providers] + picked = self.ui.form().choice( + "Which provider to remove?", choices, name="id" + ) + provider_id = providers[choices.index(picked)].id + if not yes: + self.confirm_or_abort( + f"Remove login provider '{provider_id}'?", default=False + ) + removed = project.remove_provider(provider_id) + self.write(project) + self.ui.success(f"Removed {removed.kind} provider '{removed.id}'.") + + +class DashboardAuthCommands(CommandGroup): + name, help, panel = "auth", "Manage a dashboard's login providers.", "Components" + + def __init__( + self, + ui: UI, + telemetry: Telemetry, + docker: DockerRunner, + templates: TemplateRepository, + renderer: ComposeRenderer, + ports: PortAllocator, + ) -> None: + super().__init__(ui, telemetry) + self._deps = (ui, telemetry, docker, templates, renderer, ports) + + @property + def commands(self) -> list[Command]: + return [ + AuthAddCommand(*self._deps), + AuthListCommand(*self._deps), + AuthRemoveCommand(*self._deps), + ] diff --git a/commands/base.py b/commands/base.py new file mode 100644 index 0000000..a36ed75 --- /dev/null +++ b/commands/base.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import functools +from abc import ABC, abstractmethod +from collections.abc import Callable +from pathlib import Path + +import typer + +from core.errors import ConfigError, DockerError, UserAbort +from services.docker import DockerRunner +from services.telemetry import Telemetry +from ui import UI + + +class Command(ABC): + name: str + help: str + panel: str = "General" + no_args_is_help: bool = False + + def __init__(self, ui: UI, telemetry: Telemetry) -> None: + self.ui = ui + self.telemetry = telemetry + + def register(self, app: typer.Typer) -> None: + app.command( + self.name, + help=self.help, + rich_help_panel=self.panel, + no_args_is_help=self.no_args_is_help, + )(self._traced(self.run)) + + def _traced(self, fn: Callable) -> Callable: + @functools.wraps(fn) + def wrapper(*args, **kwargs): + with self.telemetry.span(f"command.{self.name}"): + return fn(*args, **kwargs) + + return wrapper + + @abstractmethod + def run(self, *args, **kwargs) -> None: ... + + def require_docker(self, docker: DockerRunner) -> None: + if not docker.available(): + raise DockerError( + "Docker not found (binary missing).", + hint="Install Docker: https://docs.docker.com/get-docker/", + ) + if docker.daemon_running(): + return + self.ui.warning("Docker is installed but the daemon is not running.") + if self.ui.confirm("Do you want to try starting Docker?", default=False): + with self.ui.status("Waiting for Docker to start..."): + started = docker.start_daemon() + if started: + self.ui.success("Docker started successfully.") + return + raise DockerError( + "Docker is required to continue.", + hint="Start the Docker daemon and retry.", + ) + + @staticmethod + def require_project_dir(path: Path) -> Path: + path = path.resolve() + if not (path / "docker-compose.yml").exists(): + raise ConfigError( + f"No Portabase configuration found in: {path}", + hint=( + "Expected a docker-compose.yml created by " + "'portabase agent' or 'portabase dashboard'." + ), + ) + return path + + def confirm_or_abort( + self, question: str, *, default: bool = False, value: bool | None = None + ) -> None: + if not self.ui.confirm(question, default=default, value=value): + raise UserAbort() + + +class CommandGroup(ABC): + name: str + help: str + panel: str = "General" + + def __init__(self, ui: UI, telemetry: Telemetry) -> None: + self.ui = ui + self.telemetry = telemetry + + @property + @abstractmethod + def commands(self) -> list[Command]: ... + + @property + def groups(self) -> list[CommandGroup]: + return [] + + def build_typer(self) -> typer.Typer: + sub = typer.Typer(help=self.help, no_args_is_help=True) + for cmd in self.commands: + cmd.register(sub) + for group in self.groups: + group.register(sub) + return sub + + def register(self, app: typer.Typer) -> None: + app.add_typer(self.build_typer(), name=self.name, rich_help_panel=self.panel) diff --git a/commands/build.py b/commands/build.py new file mode 100644 index 0000000..85316df --- /dev/null +++ b/commands/build.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Annotated + +import typer + +from commands.base import Command +from commands.db import report_write +from core.errors import ValidationError +from services.project import ( + ENV_FILE, + AgentProject, + DashboardProject, + detect_kind, +) +from services.renderer import ComposeRenderer, RenderResult +from services.telemetry import Telemetry +from services.templates import TemplateRepository +from ui import UI + + +class BuildCommand(Command): + name = "build" + help = "Re-render docker-compose.yml from the component's configuration." + panel = "Configuration" + no_args_is_help = True + + def __init__( + self, + ui: UI, + telemetry: Telemetry, + templates: TemplateRepository, + renderer: ComposeRenderer, + ) -> None: + super().__init__(ui, telemetry) + self.templates = templates + self.renderer = renderer + + def run( + self, + path: Annotated[Path, typer.Argument(help="Component folder")], + diff: Annotated[ + bool, typer.Option("--diff", help="Show the diff, write nothing") + ] = False, + stdout: Annotated[ + bool, typer.Option("--stdout", help="Print the compose, write nothing") + ] = False, + inline_env: Annotated[ + bool, + typer.Option( + "--inline-env", help="Substitute values instead of ${VAR} references" + ), + ] = False, + output: Annotated[ + Path | None, + typer.Option("--output", "-o", help="Write files to another directory"), + ] = None, + ) -> None: + if sum([diff, stdout, output is not None]) > 1: + raise ValidationError("Use only one of --diff, --stdout, --output.") + path = self.require_project_dir(path) + self.templates.resolve() + kind = detect_kind(path) + + if kind == "agent": + agent = AgentProject.load(path) + result: RenderResult = self.renderer.render_agent(agent, inline=inline_env) + else: + dashboard = DashboardProject.load(path) + result = self.renderer.render_dashboard(dashboard, inline=inline_env) + result.validate() + + if inline_env and not stdout: + self.ui.warning( + "--inline-env writes secrets in clear text into the compose file." + ) + + if stdout: + self.ui.out(result.compose) + return + if diff: + self.ui.diff(result.diff_against(path)) + return + + target = (output or path).resolve() + if output is not None: + target.mkdir(parents=True, exist_ok=True) + (target / ENV_FILE).write_text( + (path / ENV_FILE).read_text(encoding="utf-8"), encoding="utf-8" + ) + report = result.write(target) + report_write(self.ui, report) + self.ui.success( + f"Rendered {', '.join(path.name for path in report.wrote)} in {target}" + ) + if kind == "agent" and output is None: + self.ui.info(f"Restart to apply: portabase restart {path.name}") diff --git a/commands/common.py b/commands/common.py deleted file mode 100644 index 3a8fefd..0000000 --- a/commands/common.py +++ /dev/null @@ -1,68 +0,0 @@ -import typer -import subprocess -import shutil -from pathlib import Path -from rich.prompt import Confirm -from core.utils import console, validate_work_dir, get_random_hint, slugify_project_name -from core.docker import run_compose - -def start(path: Path = typer.Argument(..., help="Path to component folder")): - path = path.resolve() - validate_work_dir(path) - status_msg = f"[bold magenta]Starting {path.name}...[/bold magenta]\n{get_random_hint()}" - with console.status(status_msg): - run_compose(path, ["up", "-d"]) - console.print("[success]✔ Started[/success]") - -def stop(path: Path = typer.Argument(..., help="Path to component folder")): - path = path.resolve() - validate_work_dir(path) - status_msg = f"[bold magenta]Stopping {path.name}...[/bold magenta]\n{get_random_hint()}" - with console.status(status_msg): - run_compose(path, ["stop"]) - console.print("[success]✔ Stopped[/success]") - -def restart(path: Path = typer.Argument(..., help="Path to component folder")): - path = path.resolve() - validate_work_dir(path) - status_msg = f"[bold magenta]Restarting {path.name}...[/bold magenta]\n{get_random_hint()}" - with console.status(status_msg): - run_compose(path, ["restart"]) - console.print("[success]✔ Restarted[/success]") - -def logs( - path: Path = typer.Argument(..., help="Path to component folder"), - follow: bool = typer.Option(True, "--follow/--no-follow", "-f") -): - path = path.resolve() - validate_work_dir(path) - args = ["logs"] - if follow: - args.append("-f") - try: - project_name = slugify_project_name(path.name) - subprocess.run(["docker", "compose", "-p", project_name] + args, cwd=path) - except KeyboardInterrupt: - pass - -def uninstall( - path: Path = typer.Argument(..., help="Path to component folder"), - force: bool = typer.Option(False, "--force", "-f") -): - path = path.resolve() - validate_work_dir(path) - - if not force: - console.print(f"[danger]⚠ WARNING: This will delete containers and data in {path}.[/danger]") - if not Confirm.ask("Are you sure?"): - raise typer.Exit() - - status_msg = f"[bold red]Uninstalling...[/bold red]\n{get_random_hint()}" - with console.status(status_msg): - run_compose(path, ["down", "-v"]) - try: - shutil.rmtree(path) - except Exception as e: - console.print(f"[warning]Could not remove directory: {e}[/warning]") - - console.print(f"[success]✔ Uninstalled[/success]") \ No newline at end of file diff --git a/commands/config.py b/commands/config.py index 7660f52..c781241 100644 --- a/commands/config.py +++ b/commands/config.py @@ -1,28 +1,97 @@ +from __future__ import annotations + +from typing import Annotated + import typer -from core.config import get_config_value, set_config_value -from core.utils import console +from commands.base import Command, CommandGroup +from core.config import GlobalConfig +from core.errors import ValidationError +from services.telemetry import Telemetry +from ui import UI + +CHANNELS = ("stable", "beta") + + +class _ConfigCommand(Command): + panel = "Configuration" + + def __init__(self, ui: UI, telemetry: Telemetry, config: GlobalConfig) -> None: + super().__init__(ui, telemetry) + self.config = config + + +class ConfigShow(_ConfigCommand): + name, help = "show", "Show the current configuration." -app = typer.Typer(help="Manage global CLI configuration.") + def run(self) -> None: + data = self.config.all() + self.ui.info(f"Configuration file: {self.config.path}") + for key in GlobalConfig.KNOWN_KEYS: + value = data.get(key, "[hint]unset[/hint]") + self.ui.print(f" [key]{key}[/key]: {value}") + for key in sorted(set(data) - set(GlobalConfig.KNOWN_KEYS)): + self.ui.print( + f" [key]{key}[/key]: {data[key]} [hint](unknown key)[/hint]" + ) -@app.command() -def channel( - name: str = typer.Argument(..., help="Update channel name (stable or beta)"), -): - name = name.lower() - if name not in ["stable", "beta"]: - console.print( - "[danger]✖ Invalid channel. Choose either 'stable' or 'beta'.[/danger]" +class ConfigGet(_ConfigCommand): + name, help = "get", "Print one configuration value." + no_args_is_help = True + + def run( + self, key: Annotated[str, typer.Argument(help="Configuration key")] + ) -> None: + value = self.config.get(key) + if value is None: + raise ValidationError( + f"'{key}' is not set.", + hint="Known keys: " + ", ".join(GlobalConfig.KNOWN_KEYS), + ) + self.ui.print(str(value)) + + +class ConfigSet(_ConfigCommand): + name, help = "set", "Set a configuration value." + no_args_is_help = True + + def run( + self, + key: Annotated[str, typer.Argument(help="Configuration key")], + value: Annotated[str, typer.Argument(help="Value")], + ) -> None: + if key == "update_channel" and value not in CHANNELS: + raise ValidationError( + f"Invalid channel '{value}'.", hint="Choose 'stable' or 'beta'." + ) + self.config.set(key, value) + self.ui.success(f"{key} = {value}") + + +class ConfigChannel(_ConfigCommand): + name, help = "channel", "Set the update channel (stable or beta)." + no_args_is_help = True + + def run(self, name: Annotated[str, typer.Argument(help="stable or beta")]) -> None: + ConfigSet(self.ui, self.telemetry, self.config).run( + "update_channel", name.lower() ) - raise typer.Exit(1) - set_config_value("update_channel", name) - console.print(f"[success]✔ Update channel set to: [bold]{name}[/bold][/success]") +class ConfigCommands(CommandGroup): + name, help, panel = "config", "Manage global CLI configuration.", "Configuration" + + def __init__(self, ui: UI, telemetry: Telemetry, config: GlobalConfig) -> None: + super().__init__(ui, telemetry) + self.config = config -@app.command() -def show(): - channel = get_config_value("update_channel", "auto (based on current version)") - console.print(f"[info]Current Configuration:[/info]") - console.print(f" [bold]Update Channel:[/bold] {channel}") + @property + def commands(self) -> list[Command]: + deps = (self.ui, self.telemetry, self.config) + return [ + ConfigShow(*deps), + ConfigGet(*deps), + ConfigSet(*deps), + ConfigChannel(*deps), + ] diff --git a/commands/dashboard.py b/commands/dashboard.py index 050f719..78d16b7 100644 --- a/commands/dashboard.py +++ b/commands/dashboard.py @@ -1,205 +1,327 @@ -import re +from __future__ import annotations + import secrets +import sys from pathlib import Path +from typing import Annotated, Any from urllib.parse import quote -import questionary import typer -from rich.panel import Panel -from rich.prompt import Confirm, IntPrompt, Prompt -from rich.table import Table - -from core.config import write_env_file, write_file -from core.docker import run_compose -from core.network import fetch_template -from core.utils import ( - check_system, - console, - generate_password, - get_free_port, - get_random_hint, - print_banner, - questionary_style, - slugify_project_name, + +from commands.auth import DashboardAuthCommands +from commands.base import Command, CommandGroup +from commands.db import report_write +from commands.settings import ( + SetCommand, + UnsetCommand, + apply_settings, + display, + read_secret_flags, + show_settings, + with_settings_flags, ) +from core.utils import generate_password, slugify_project_name +from services import settings as cfg +from services.docker import DockerRunner +from services.envfile import EnvFile +from services.ports import PortAllocator +from services.project import DashboardProject +from services.renderer import ComposeRenderer +from services.telemetry import Telemetry +from services.templates import TemplateRepository +from ui import UI +from ui.form import Form +DB_MODES = ("external", "internal", "custom") +MODE_LABELS = { + "external": "Dedicated Docker Container (Recommended)", + "internal": "Embedded Database (In-container)", + "custom": "Custom/Existing Database", +} +PathArg = Annotated[Path, typer.Argument(help="Dashboard folder")] -def dashboard( - name: str = typer.Argument(..., help="Name of the dashboard (creates a folder)"), - port: str = typer.Option("8887", help="Web Port"), - start: bool = typer.Option(False, "--start", "-s", help="Start immediately"), -): - print_banner() - check_system() - - path = Path(name).resolve() - if path.exists(): - console.print(f"[warning]Directory '{name}' already exists.[/warning]") - if not Confirm.ask("Overwrite?"): - raise typer.Exit() - - path.mkdir(parents=True, exist_ok=True) - project_name = slugify_project_name(path.name) - - raw_template = fetch_template("dashboard.yml") - - auth_secret = secrets.token_hex(32) - base_url = f"http://localhost:{port}" - - env_vars = { - "HOST_PORT": port, - "PROJECT_SECRET": auth_secret, - "PROJECT_URL": base_url, - "PROJECT_NAME": project_name, - "TZ": "Europe/Paris", - "LOG_LEVEL": "info", - } - - mode = questionary.select( - "Database Setup", - choices=[ - questionary.Choice( - "external: create a dedicated container in the same docker-compose.yml (recommended)", - value="external", - ), - questionary.Choice( - "internal: use the database embedded in the Portabase container", - value="internal", - ), - questionary.Choice( - "custom: provide credentials of an existing database", - value="custom", + +class _DashboardCommand(Command): + panel = "Components" + no_args_is_help = True + + def __init__( + self, + ui: UI, + telemetry: Telemetry, + docker: DockerRunner, + templates: TemplateRepository, + renderer: ComposeRenderer, + ports: PortAllocator, + ) -> None: + super().__init__(ui, telemetry) + self.docker = docker + self.templates = templates + self.renderer = renderer + self.ports = ports + + def write(self, project: DashboardProject) -> None: + with self.ui.status("Rendering configuration..."): + result = self.renderer.render_dashboard(project) + project.save_state() + report = result.write(project.path) + report_write(self.ui, report) + + +class DashboardCreateCommand(_DashboardCommand): + name, help = "create", "Create a new Portabase Dashboard instance." + + def register(self, app: typer.Typer) -> None: + app.command( + self.name, help=self.help, rich_help_panel=self.panel, no_args_is_help=True + )(self._traced(with_settings_flags(self.run, cfg.DASHBOARD))) + + def run( + self, + name: Annotated[str, typer.Argument(help="Dashboard name (creates a folder)")], + port: Annotated[int | None, typer.Option("--port", help="Web port")] = None, + db_mode: Annotated[ + str | None, typer.Option("--db-mode", help="external | internal | custom") + ] = None, + db_host: Annotated[ + str | None, typer.Option("--db-host", help="Host of the existing database") + ] = None, + db_port: Annotated[ + int | None, typer.Option("--db-port", help="Port of the existing database") + ] = None, + db_name: Annotated[ + str | None, typer.Option("--db-name", help="Database name") + ] = None, + db_user: Annotated[ + str | None, typer.Option("--db-user", help="Username") + ] = None, + db_password_stdin: Annotated[ + bool, + typer.Option( + "--db-password-stdin", help="Read the custom DB password from stdin" ), - ], - style=questionary_style, - ).ask() - - if not mode: - raise typer.Exit() - - if mode == "external": - pg_port = get_free_port() - pg_pass = generate_password(16) - env_vars.update( - { - "POSTGRES_DB": "portabase", - "POSTGRES_USER": "portabase", - "POSTGRES_PASSWORD": pg_pass, - "POSTGRES_HOST": "db", - "DATABASE_URL": f"postgresql://portabase:{quote(pg_pass, safe='')}@db:5432/portabase?schema=public", - "PG_PORT": str(pg_port), - } - ) - final_compose = raw_template.replace("${PROJECT_NAME}", project_name) - elif mode == "custom": - console.print("[info]External Database Configuration[/info]") - db_host = Prompt.ask("Host", default="localhost") - db_port = IntPrompt.ask("Port", default=5432) - db_name = Prompt.ask("Database Name", default="portabase") - db_user = Prompt.ask("Username") - db_pass = questionary.password("Password", style=questionary_style).ask() - if db_pass is None: - raise typer.Exit() - - env_vars.update( - { - "POSTGRES_DB": db_name, - "POSTGRES_USER": db_user, - "POSTGRES_PASSWORD": db_pass, - "POSTGRES_HOST": db_host, - "DATABASE_URL": f"postgresql://{quote(db_user, safe='')}:{quote(db_pass, safe='')}@{db_host}:{db_port}/{db_name}?schema=public", - "PG_PORT": str(db_port), - } - ) + ] = False, + tz: Annotated[str | None, typer.Option("--tz", help="Timezone")] = None, + start: Annotated[ + bool, typer.Option("--start", "-s", help="Start immediately") + ] = False, + force: Annotated[ + bool, typer.Option("--force", "-f", help="Overwrite an existing folder") + ] = False, + yes: Annotated[ + bool, + typer.Option("--yes", "-y", help="Skip the configuration confirmation"), + ] = False, + **settings: Any, + ) -> None: + self.ui.banner() + self.require_docker(self.docker) + self.templates.resolve() - final_compose = re.sub( - r"[ ]{4}depends_on:\n[ ]{6}db:\n[ ]{8}condition: service_healthy\n", - "", - raw_template, - ) - final_compose = re.sub( - r"[ ]{2}db:.*?retries: 5\n", "", final_compose, flags=re.DOTALL + path = Path(name).resolve() + if path.exists() and not force: + self.ui.warning(f"Directory '{name}' already exists.") + self.confirm_or_abort("Overwrite?", default=False) + + form = self.ui.form() + web_port = form.integer("Web Port", value=port, default=8887, name="port") + mode = form.choice( + "Database Setup", + list(DB_MODES), + value=db_mode, + default="external", + name="db_mode", ) - final_compose = re.sub(r"[ ]{2}postgres-data:\n", "", final_compose) - final_compose = final_compose.replace("${PROJECT_NAME}", project_name) - else: - final_compose = re.sub( - r"[ ]{4}depends_on:\n[ ]{6}db:\n[ ]{8}condition: service_healthy\n", - "", - raw_template, + project_name = slugify_project_name(path.name) + + env_vars = { + "HOST_PORT": str(web_port), + "PROJECT_SECRET": secrets.token_hex(32), + "PROJECT_URL": f"http://localhost:{web_port}", + "PROJECT_NAME": project_name, + "TZ": form.text("Timezone", value=tz, default="Europe/Paris", name="tz"), + "LOG_LEVEL": "info", + } + rows = [ + ("Dashboard Name", name), + ("Path", str(path)), + ("Database Setup", MODE_LABELS[mode]), + ] + + if mode == "external": + pg_pass, pg_port = generate_password(16), self.ports.free() + env_vars.update( + self._pg_env("portabase", "portabase", pg_pass, "db", 5432, pg_port) + ) + rows.append(("Internal Port", str(pg_port))) + elif mode == "custom": + self.ui.info("External Database Configuration") + host = form.text("Host", value=db_host, default="localhost", name="db_host") + dport = form.integer("Port", value=db_port, default=5432, name="db_port") + dbname = form.text( + "Database Name", value=db_name, default="portabase", name="db_name" + ) + user = form.text("Username", value=db_user, name="db_user") + if db_password_stdin: + password = sys.stdin.readline().rstrip("\n") + else: + password = form.secret("Password", name="db_password") + env_vars.update(self._pg_env(dbname, user, password, host, dport, dport)) + rows += [ + ("DB Host", host), + ("DB Name", dbname), + ("Connection URL", env_vars["DATABASE_URL"]), + ] + + env = EnvFile(path / ".env") + env.merge(env_vars) + project = DashboardProject(path, env) + + provided = read_secret_flags(cfg.DASHBOARD, settings) + apply_settings(self.ui, project, provided) + explicit = any(value is not None for value in provided.values()) + if ( + not self.ui.non_interactive + and not explicit + and self.ui.confirm( + "Configure API, MCP and authentication now?", default=False + ) + ): + self._wizard(form, project) + + rows.append(("Access URL", project.setting("url"))) + rows += [ + (setting.field.prompt, display(setting, project.setting(setting.name))) + for setting in cfg.DASHBOARD + if setting.name != "url" and project.env.get(setting.env or "") is not None + ] + rows.append(("Files to Create", "docker-compose.yml, .env")) + self.ui.summary(rows, title="SUMMARY") + if not yes: + self.confirm_or_abort( + "Apply this configuration and generate files?", default=True + ) + + path.mkdir(parents=True, exist_ok=True) + self.write(project) + self.ui.success(f"Dashboard '{name}' created in {path}") + + if start or ( + not self.ui.non_interactive + and self.ui.confirm("Start dashboard now?", default=False) + ): + with self.ui.status("Starting..."): + self.docker.compose(path, ["up", "-d"]) + self.ui.success(f"Live at: {project.setting('url')}") + else: + self.ui.info(f"Run: portabase start {name}") + + self.ui.print("") + self.ui.hint("Single sign-on (OIDC / OAuth) can be added at any time:") + self.ui.hint(f" portabase dashboard set {name} url https://your.domain") + self.ui.hint( + f" portabase dashboard auth add {name} oidc keycloak --issuer URL ..." ) - final_compose = re.sub( - r"[ ]{2}db:.*?retries: 5\n", "", final_compose, flags=re.DOTALL + self.ui.hint( + f" portabase dashboard auth add {name} oauth github --client ID ..." ) - final_compose = re.sub(r"[ ]{2}postgres-data:\n", "", final_compose) - final_compose = final_compose.replace("${PROJECT_NAME}", project_name) - - summary = Table(show_header=False, box=None, padding=(0, 2)) - summary.add_column("Property", style="bold cyan") - summary.add_column("Value", style="white") - - summary.add_row("Dashboard Name", name) - summary.add_row("Path", str(path)) - summary.add_row("Access URL", f"[bold green]http://localhost:{port}[/bold green]") - - db_setup_label = { - "external": "Dedicated Docker Container (Recommended)", - "internal": "Embedded Database (In-container)", - "custom": "Custom/Existing Database", - } - summary.add_row("Database Setup", db_setup_label.get(mode)) - - if mode == "external": - summary.add_row("Internal Port", env_vars["PG_PORT"]) - elif mode == "custom": - summary.add_row("DB Host", env_vars["POSTGRES_HOST"]) - summary.add_row("DB Name", env_vars["POSTGRES_DB"]) - masked_url = re.sub(r":.*?@", ":****@", env_vars["DATABASE_URL"]) - summary.add_row("Connection URL", f"[dim]{masked_url}[/dim]") - - summary.add_row("Files to Create", "• docker-compose.yml\n• .env") - - console.print("") - console.print( - Panel( - summary, - title="[bold white]PROPOSED CONFIGURATION[/bold white]", - border_style="bold blue", - expand=False, + + def _wizard(self, form: Form, project: DashboardProject) -> None: + for section, names in cfg.DASHBOARD_WIZARD: + self.ui.section(cfg.DASHBOARD.sections[section]) + for setting_name in names: + needs_account = ( + section == "onboarding" and setting_name != "skip_onboarding" + ) + if needs_account and not project.setting("skip_onboarding"): + continue + setting = cfg.DASHBOARD.get(setting_name) + value = form.ask(setting.field) + if value != setting.field.default or needs_account: + project.set(setting_name, value) + + @staticmethod + def _pg_env( + db: str, user: str, password: str, host: str, port: int, host_port: int + ) -> dict[str, str]: + url = ( + f"postgresql://{quote(user, safe='')}:{quote(password, safe='')}" + f"@{host}:{port}/{db}?schema=public" ) - ) - console.print( - "[dim]The dashboard will be set up with the parameters above.[/dim]\n" + return { + "POSTGRES_DB": db, + "POSTGRES_USER": user, + "POSTGRES_PASSWORD": password, + "POSTGRES_HOST": host, + "DATABASE_URL": url, + "PG_PORT": str(host_port), + } + + +class DashboardShowCommand(_DashboardCommand): + name, help = "show", "Show a dashboard's settings and login providers." + + def run(self, path: PathArg) -> None: + project = DashboardProject.load(self.require_project_dir(path)) + show_settings(self.ui, project) + providers = project.providers + if providers: + self.ui.table( + ["Kind", "Id", "Title", "Issuer / provider", "Callback"], + [ + [ + provider.kind, + provider.id, + provider.values.get("title", ""), + provider.values.get("issuer", provider.id), + project.callback_url(provider.id), + ] + for provider in providers + ], + title="LOGIN PROVIDERS", + ) + else: + state = "enabled." if project.setting("password_auth") else "disabled!" + self.ui.hint(f"No login provider. Password login is {state}") + + +class DashboardCommands(CommandGroup): + name, help, panel = ( + "dashboard", + "Create and manage Portabase dashboards.", + "Components", ) - if not Confirm.ask( - "[bold]Apply this configuration and generate files?[/bold]", default=True - ): - console.print("[warning]Configuration cancelled.[/warning]") - raise typer.Exit() - - write_file(path / "docker-compose.yml", final_compose) - write_env_file(path, env_vars) - - db_info = "" - if mode == "external": - db_info = f"\n[dim]DB Port: {env_vars.get('PG_PORT')}[/dim]" - elif mode == "custom": - db_info = f"\n[dim]External DB: {env_vars.get('POSTGRES_HOST')}[/dim]" - else: - db_info = "\n[dim]Embedded Database[/dim]" - - console.print( - Panel( - f"[bold white]DASHBOARD CREATED: {name}[/bold white]\n[dim]Path: {path}[/dim]{db_info}", - style="bold #5f00d7", + def __init__( + self, + ui: UI, + telemetry: Telemetry, + docker: DockerRunner, + templates: TemplateRepository, + renderer: ComposeRenderer, + ports: PortAllocator, + ) -> None: + super().__init__(ui, telemetry) + self._deps = (ui, telemetry, docker, templates, renderer, ports) + self.auth = DashboardAuthCommands(*self._deps) + + @property + def commands(self) -> list[Command]: + ui, telemetry, _docker, templates, renderer, _ports = self._deps + shared = ( + ui, + telemetry, + templates, + DashboardProject.load, + renderer.render_dashboard, ) - ) + return [ + DashboardCreateCommand(*self._deps), + DashboardShowCommand(*self._deps), + SetCommand(*shared), + UnsetCommand(*shared), + ] - if start or Confirm.ask("Start dashboard now?", default=False): - status_msg = f"[bold magenta]Starting...[/bold magenta]\n{get_random_hint()}" - with console.status(status_msg, spinner="earth"): - run_compose(path, ["up", "-d"]) - console.print(f"[bold green]✔ Live at: http://localhost:{port}[/bold green]") - else: - console.print(f"[info]Run: portabase start {name}[/info]") + @property + def groups(self) -> list[CommandGroup]: + return [self.auth] diff --git a/commands/db.py b/commands/db.py index e670442..bbee8ab 100644 --- a/commands/db.py +++ b/commands/db.py @@ -1,756 +1,288 @@ -import re -import secrets -import uuid +from __future__ import annotations + +import sys from pathlib import Path +from typing import Annotated -import questionary import typer -from rich.panel import Panel -from rich.prompt import IntPrompt, Prompt -from rich.table import Table - -from core.config import add_db_to_json, load_db_config, save_db_config, write_env_file -from core.docker import ensure_network -from core.utils import ( - console, - generate_password, - get_free_port, - questionary_style, - validate_work_dir, -) -from templates.compose import ( - AGENT_FIREBIRD_SNIPPET, - AGENT_MARIADB_SNIPPET, - AGENT_MONGODB_AUTH_SNIPPET, - AGENT_MONGODB_SNIPPET, - AGENT_MSSQL_SNIPPET, - AGENT_POSTGRES_SNIPPET, - AGENT_REDIS_AUTH_SNIPPET, - AGENT_REDIS_SNIPPET, - AGENT_VALKEY_AUTH_SNIPPET, - AGENT_VALKEY_SNIPPET, -) - -app = typer.Typer(help="Manage databases configuration.") - -DOCKER_SOCKET_MOUNT = "/var/run/docker.sock:/var/run/docker.sock" - -def ensure_docker_socket(path: Path): - """Mount the Docker socket on the agent's app service if not already present.""" - compose_path = path / "docker-compose.yml" - if not compose_path.exists(): - console.print( - "[warning]⚠ docker-compose.yml not found. Add " - f"[bold]{DOCKER_SOCKET_MOUNT}[/bold] to the agent volumes manually.[/warning]" +from commands.base import Command, CommandGroup +from commands.flows.add_database import AddDatabaseFlow +from engines import EngineRegistry +from services.docker import DockerRunner +from services.ports import PortAllocator +from services.project import AgentProject +from services.renderer import ComposeRenderer, WriteReport +from services.telemetry import Telemetry +from services.templates import TemplateRepository +from ui import UI + +NameArg = Annotated[Path, typer.Argument(help="Agent folder")] + + +def report_write(ui: UI, report: WriteReport) -> None: + if report.backed_up: + ui.warning( + f"Legacy compose backed up to {report.backed_up.name}. " + "Manual edits belong in docker-compose.override.yml." ) - return - content = compose_path.read_text() - if DOCKER_SOCKET_MOUNT in content: - return - anchor = "- ./databases.json:/config/config.json" - lines = content.splitlines(keepends=True) - new_lines = [] - inserted = False - for line in lines: - new_lines.append(line) - if not inserted and anchor in line: - indent = line[: len(line) - len(line.lstrip())] - new_lines.append(f"{indent}- {DOCKER_SOCKET_MOUNT}\n") - inserted = True +class _DbCommand(Command): + panel = "Configuration" + no_args_is_help = True + + def __init__( + self, + ui: UI, + telemetry: Telemetry, + engines: EngineRegistry, + ports: PortAllocator, + templates: TemplateRepository, + renderer: ComposeRenderer, + docker: DockerRunner, + ) -> None: + super().__init__(ui, telemetry) + self.engines = engines + self.ports = ports + self.templates = templates + self.renderer = renderer + self.docker = docker + + def render_and_write(self, project: AgentProject) -> None: + with self.ui.status("Rendering configuration..."): + result = self.renderer.render_agent(project) + project.save_state() + report = result.write(project.path) + report_write(self.ui, report) + + +class DbAddCommand(_DbCommand): + name, help = "add", "Add a database to an agent." + + def run( + self, + name: NameArg, + engine: Annotated[ + str | None, typer.Option("--engine", "-e", help="Database engine") + ] = None, + mode: Annotated[ + str | None, typer.Option("--mode", help="new (container) or existing") + ] = None, + auth: Annotated[ + bool | None, + typer.Option( + "--auth/--no-auth", help="Auth variant for mongodb/redis/valkey" + ), + ] = None, + label: Annotated[ + str | None, typer.Option("--label", help="Display name") + ] = None, + host: Annotated[ + str | None, typer.Option("--host", help="Host of an existing database") + ] = None, + port: Annotated[ + int | None, typer.Option("--port", help="Port of an existing database") + ] = None, + database: Annotated[ + str | None, typer.Option("--database", help="Database name") + ] = None, + user: Annotated[str | None, typer.Option("--user", help="Username")] = None, + password: Annotated[ + str | None, typer.Option("--password", help="Prefer --password-stdin") + ] = None, + password_stdin: Annotated[ + bool, typer.Option("--password-stdin", help="Read password from stdin") + ] = False, + path: Annotated[ + str | None, typer.Option("--path", help="SQLite file path (existing)") + ] = None, + db_name: Annotated[ + str | None, typer.Option("--name", help="SQLite file name (new)") + ] = None, + volume: Annotated[ + str | None, typer.Option("--volume", help="Docker volume name") + ] = None, + container: Annotated[ + str | None, + typer.Option("--container", help="Container to restart after restore"), + ] = None, + option: Annotated[ + list[str] | None, + typer.Option("--option", "-o", help="Engine option KEY=VALUE (repeatable)"), + ] = None, + ) -> None: + if password_stdin: + password = sys.stdin.readline().rstrip("\n") + elif password is not None: + self.ui.warning( + "--password is visible in shell history; prefer --password-stdin." + ) - if inserted: - compose_path.write_text("".join(new_lines)) - console.print( - f"[info]ℹ Mounted Docker socket ([bold]{DOCKER_SOCKET_MOUNT}[/bold]) " - "on the agent.[/info]" + project_path = self.require_project_dir(name) + self.templates.resolve() + project = AgentProject.load(project_path) + + flow = AddDatabaseFlow(self.ui, self.engines, self.ports) + values = { + "engine": engine, + "mode": mode, + "auth": auth, + "label": label, + "host": host, + "port": port, + "database": database, + "username": user, + "password": password, + "path": path, + "name": db_name, + "volume": volume, + "container": container, + "options": flow.parse_options(option), + } + spec, eng = flow.collect(values) + flow.apply(project, spec, eng) + self.render_and_write(project) + + self.ui.success( + f"Added {eng.display} database '{spec.name}' ({eng.describe(spec)})." ) - else: - console.print( - "[warning]⚠ Could not locate the agent volumes block. Add " - f"[bold]{DOCKER_SOCKET_MOUNT}[/bold] to docker-compose.yml manually.[/warning]" + self.ui.info( + f"Restart the agent to apply changes: portabase restart {project_path.name}" ) -@app.command("list") -def list_dbs(name: str = typer.Argument(..., help="Name of the agent")): - path = Path(name).resolve() - validate_work_dir(path) - - config = load_db_config(path) - dbs = config.get("databases", []) - - if not dbs: - console.print("[warning]No databases configured.[/warning]") - return - - table = Table(title=f"Databases for {name}") - table.add_column("Display Name", style="cyan") - table.add_column("Database", style="blue") - table.add_column("Type", style="magenta") - table.add_column("Host:Port", style="green") - table.add_column("User", style="white") - table.add_column("ID", style="dim") - - for db in dbs: - db_type = db.get("type", "N/A") - if db_type == "sqlite": - host_port = "Local File" - elif db_type == "docker-volume": - host_port = f"volume: {db.get('volume_name', 'N/A')}" +class DbRemoveCommand(_DbCommand): + name, help = "remove", "Remove a database from an agent." + + def run( + self, + name: NameArg, + target: Annotated[ + str | None, + typer.Option( + "--id", "--name", "-i", help="Database id (or prefix) or display name" + ), + ] = None, + purge_volume: Annotated[ + bool, + typer.Option( + "--purge-volume", + help="Also delete the Docker volume of a managed database", + ), + ] = False, + yes: Annotated[ + bool, typer.Option("--yes", "-y", help="Skip confirmation") + ] = False, + ) -> None: + project_path = self.require_project_dir(name) + self.templates.resolve() + project = AgentProject.load(project_path) + if not project.databases: + self.ui.warning("No databases to remove.") + return + + if target is None: + choices = [ + f"{database.name} ({database.engine}) [{database.id[:8]}]" + for database in project.databases + ] + picked = self.ui.form().choice( + "Which database to remove?", choices, name="id" + ) + spec = project.databases[choices.index(picked)] else: - host_port = f"{db.get('host', 'N/A')}:{db.get('port', 'N/A')}" - username = ( - "N/A" - if db_type in ("sqlite", "docker-volume") - else db.get("username", "N/A") - ) - - table.add_row( - db.get("name", "N/A"), - db.get("database", db.get("name", "N/A")), - db_type, - host_port, - username, - db.get("generated_id", "")[:8] + "...", - ) - console.print(table) - + spec = project.find(target) + engine = self.engines.get(spec.engine) -@app.command("add") -def add_db(name: str = typer.Argument(..., help="Name of the agent")): - path = Path(name).resolve() - validate_work_dir(path) - ensure_network("portabase_network") - - console.print(Panel("Add Database to Agent", style="bold blue")) - - while True: - storage_kind = questionary.select( - "What do you want to configure?", - choices=["done", "database", "docker-volume"], - default="database", - style=questionary_style, - ).ask() - - if storage_kind in (None, "done"): - break - - if storage_kind == "docker-volume": - console.print( - "[warning]⚠ Requires the Docker socket mounted on the agent " - "([bold]/var/run/docker.sock[/bold]). It will be added to " - "docker-compose.yml automatically.[/warning]" - ) - friendly_name = Prompt.ask("Display Name", default="Docker Volume") - volume_name = Prompt.ask("Volume Name (e.g. databases_sqlite-data)").strip() - while not volume_name: - console.print("[danger]✖ Volume Name is required.[/danger]") - volume_name = Prompt.ask( - "Volume Name (e.g. databases_sqlite-data)" - ).strip() - container_name = Prompt.ask( - "Container Name (optional, enables auto-restart after restore)", - default="", + if not yes: + extra = " and its Docker volume" if (purge_volume and spec.managed) else "" + self.confirm_or_abort( + f"Remove '{spec.name}' ({engine.describe(spec)}){extra}?", default=False ) - entry = { - "name": friendly_name, - "type": "docker-volume", - "volume_name": volume_name, - "generated_id": str(uuid.uuid4()), - } - if container_name: - entry["container_name"] = container_name - ensure_docker_socket(path) - add_db_to_json(path, entry) - console.print("[success]✔ Added to config[/success]") - continue - mode = Prompt.ask( - "Configuration Mode", - choices=["new", "existing", "back"], - default="existing", - ) - - if mode == "back": - break - - if mode == "existing": - db_type = questionary.select( - "Select Database Type", - choices=[ - "back", - "postgresql", - "postgresql-cluster", - "mysql", - "mariadb", - "sqlite", - "firebird", - "mongodb", - "mssql", - ], - style=questionary_style, - ).ask() - - if db_type == "back": - continue - - if not db_type: - raise typer.Exit() - - if db_type == "postgresql-cluster": - console.print( - "[warning]⚠ Postgres Cluster requires a superuser. " - "Cluster backup/restore uses pg_dumpall, which dumps all " - "databases and global objects (roles, tablespaces). " - "The provided user must be a Postgres superuser.[/warning]" + project.remove(spec, engine) + self.render_and_write(project) + self.ui.success(f"Removed {spec.name}") + + if spec.managed: + volume_name = f"{self.docker.project_name(project_path)}_{spec.host}-data" + if purge_volume: + self.require_docker(self.docker) + removed = self.docker.remove_volume(volume_name) + self.ui.success( + f"Deleted volume {volume_name}" + if removed + else f"Volume {volume_name} did not exist" ) - - friendly_name = Prompt.ask("Display Name", default="External DB") - - if db_type == "sqlite": - db_name = Prompt.ask("Database Path (e.g. /data/db.sqlite)") - entry = { - "name": friendly_name, - "database": db_name, - "type": db_type, - "generated_id": str(uuid.uuid4()), - } else: - db_name = Prompt.ask("Database Name") - host = Prompt.ask("Host", default="localhost") - port = IntPrompt.ask( - "Port", - default=5432 - if db_type in ["postgresql", "postgresql-cluster"] - else ( - 3050 - if db_type == "firebird" - else ( - 1433 - if db_type == "mssql" - else (3306 if db_type in ["mysql", "mariadb"] else 27017) - ) - ), - ) - user = Prompt.ask("Username") - password = questionary.password( - "Password", style=questionary_style - ).ask() - if password is None: - raise typer.Exit() - - entry = { - "name": friendly_name, - "database": db_name, - "type": db_type, - "username": user, - "password": password, - "port": port, - "host": host, - "generated_id": str(uuid.uuid4()), - } - - if db_type == "postgresql": - console.print( - "[info]ℹ When enabled, omits [bold]--no-owner[/bold] and " - "[bold]--no-privileges[/bold] from the dump. Ownership and role " - "assignments are preserved in the output. By default, these flags " - "are applied to keep restores portable across different users and " - "environments, for example when migrating from one database " - "instance to another.[/info]" - ) - keep_ownership = questionary.confirm( - "Keep ownership?", - default=False, - style=questionary_style, - ).ask() - if keep_ownership is None: - raise typer.Exit() - - console.print( - "[info]ℹ Controls how the target database is cleaned before a " - "restore. [bold]pg_restore --clean[/bold] only drops objects " - "listed in the backup's own table of contents, so anything " - "already present in the target that the dump does not know " - "about survives and can make the restore fail.[/info]" - ) - clean_mode = questionary.select( - "Clean mode", - choices=[ - questionary.Choice( - "clean - pg_restore --clean --if-exists (default)", - value="clean", - ), - questionary.Choice( - "none - no pre-clean, restore into an empty database", - value="none", - ), - questionary.Choice( - "drop_schemas - drop every non-system schema CASCADE " - "(recommended, works on managed Postgres)", - value="drop_schemas", - ), - questionary.Choice( - "drop_database - DROP DATABASE + CREATE DATABASE " - "(full reset)", - value="drop_database", - ), - ], - default="clean", - style=questionary_style, - ).ask() - if clean_mode is None: - raise typer.Exit() - if clean_mode == "drop_database": - console.print( - "[warning]⚠ drop_database drops the whole target database " - "before restoring. The user must have CREATEDB and own the " - "database, or be a superuser. Most managed Postgres " - "providers do not allow it.[/warning]" - ) - - pg_options = {} - if keep_ownership: - pg_options["keep_ownership"] = True - if clean_mode != "clean": - pg_options["clean_mode"] = clean_mode - if pg_options: - entry["options"] = pg_options - - add_db_to_json(path, entry) - break - else: - db_engine = questionary.select( - "Select Database Engine", - choices=[ - "back", - "postgresql", - "postgresql-cluster", - "mysql", - "mariadb", - "sqlite", - "firebird", - "mongodb", - "redis", - "valkey", - "mssql", - ], - style=questionary_style, - ).ask() - - if db_engine == "back": - continue - - if not db_engine: - raise typer.Exit() - - db_variant = "no-auth" - if db_engine in ["mongodb", "redis", "valkey"]: - engine_display = { - "mongodb": "MongoDB", - "redis": "Redis", - "valkey": "Valkey", - }[db_engine] - db_variant = questionary.select( - f"Select {engine_display} Variant", - choices=["back", "no-auth", "with-auth"], - default="no-auth", - style=questionary_style, - ).ask() - - if db_variant == "back": - continue - - if not db_variant: - raise typer.Exit() - - env_vars = {} - snippet = "" - service_name = "" - db_name = "" - db_container_path = "" - db_user = "" - db_pass = "" - db_port = 0 - pg_options = None - - if db_engine == "sqlite": - db_name = Prompt.ask("Database Name", default="local") - if not db_name.endswith(".sqlite"): - db_name += ".sqlite" - - compose_path = path / "docker-compose.yml" - if compose_path.exists(): - content = compose_path.read_text() - lines = content.splitlines(keepends=True) - new_lines = [] - in_app_service = False - inserted = False - - for line in lines: - new_lines.append(line) - if not inserted: - if re.search(r"^ app:", line): - in_app_service = True - elif in_app_service and re.search(r"^ volumes:", line): - new_lines.append( - f" - ./{db_name}:/config/{db_name}\n" - ) - in_app_service = False - inserted = True - elif in_app_service and re.search(r"^ [a-zA-Z]", line): - in_app_service = False - - with open(compose_path, "w") as f: - f.writelines(new_lines) - - add_db_to_json( - path, - { - "name": db_name, - "database": f"/config/{db_name}", - "type": "sqlite", - "generated_id": str(uuid.uuid4()), - }, - ) - console.print(f"[success]✔ Added SQLite database ({db_name})[/success]") - - elif db_engine in ["postgresql", "postgresql-cluster"]: - db_port = get_free_port() - db_user = "admin" - db_pass = generate_password(16) - db_name = f"pg_{secrets.token_hex(4)}" - service_name = f"db-pg-{secrets.token_hex(2)}" - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(db_port) - env_vars[f"{var_prefix}_DB"] = db_name - env_vars[f"{var_prefix}_USER"] = db_user - env_vars[f"{var_prefix}_PASS"] = db_pass - snippet = ( - AGENT_POSTGRES_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - .replace("${USER}", f"${{{var_prefix}_USER}}") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - - if db_engine == "postgresql": - console.print( - "[info]ℹ When enabled, omits [bold]--no-owner[/bold] and " - "[bold]--no-privileges[/bold] from the dump. Ownership and role " - "assignments are preserved in the output. By default, these flags " - "are applied to keep restores portable across different users and " - "environments, for example when migrating from one database " - "instance to another.[/info]" - ) - keep_ownership = questionary.confirm( - "Keep ownership?", - default=False, - style=questionary_style, - ).ask() - if keep_ownership is None: - raise typer.Exit() - - console.print( - "[info]ℹ Controls how the target database is cleaned before a " - "restore. [bold]pg_restore --clean[/bold] only drops objects " - "listed in the backup's own table of contents, so anything " - "already present in the target that the dump does not know " - "about survives and can make the restore fail.[/info]" - ) - clean_mode = questionary.select( - "Clean mode", - choices=[ - questionary.Choice( - "clean - pg_restore --clean --if-exists (default)", - value="clean", - ), - questionary.Choice( - "none - no pre-clean, restore into an empty database", - value="none", - ), - questionary.Choice( - "drop_schemas - drop every non-system schema CASCADE " - "(recommended, works on managed Postgres)", - value="drop_schemas", - ), - questionary.Choice( - "drop_database - DROP DATABASE + CREATE DATABASE " - "(full reset)", - value="drop_database", - ), - ], - default="clean", - style=questionary_style, - ).ask() - if clean_mode is None: - raise typer.Exit() - if clean_mode == "drop_database": - console.print( - "[warning]⚠ drop_database drops the whole target database " - "before restoring. The user must have CREATEDB and own the " - "database, or be a superuser. Most managed Postgres " - "providers do not allow it.[/warning]" - ) - - options = {} - if keep_ownership: - options["keep_ownership"] = True - if clean_mode != "clean": - options["clean_mode"] = clean_mode - if options: - pg_options = options - - elif db_engine in ["mysql", "mariadb"]: - db_port = get_free_port() - db_user = "admin" - db_pass = generate_password(16) - db_name = f"mysql_{secrets.token_hex(4)}" - service_name = f"db-mariadb-{secrets.token_hex(2)}" - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(db_port) - env_vars[f"{var_prefix}_DB"] = db_name - env_vars[f"{var_prefix}_USER"] = db_user - env_vars[f"{var_prefix}_PASS"] = db_pass - snippet = ( - AGENT_MARIADB_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - .replace("${USER}", f"${{{var_prefix}_USER}}") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - - elif db_engine == "mongodb": - db_port = get_free_port() - db_name = f"mongo_{secrets.token_hex(4)}" - if db_variant == "with-auth": - db_user = "admin" - db_pass = generate_password(16) - service_name = f"db-mongo-auth-{secrets.token_hex(2)}" - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(db_port) - env_vars[f"{var_prefix}_DB"] = db_name - env_vars[f"{var_prefix}_USER"] = db_user - env_vars[f"{var_prefix}_PASS"] = db_pass - snippet = ( - AGENT_MONGODB_AUTH_SNIPPET.replace( - "${SERVICE_NAME}", service_name - ) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - .replace("${USER}", f"${{{var_prefix}_USER}}") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - else: - service_name = f"db-mongo-{secrets.token_hex(2)}" - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(db_port) - env_vars[f"{var_prefix}_DB"] = db_name - snippet = ( - AGENT_MONGODB_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - ) - - elif db_engine == "firebird": - db_port = get_free_port() - db_user = "alice" - db_pass = generate_password(16) - db_root_pass = generate_password(16) - db_name = "mirror.fdb" - db_container_path = f"/var/lib/firebird/data/{db_name}" - service_name = f"db-firebird-{secrets.token_hex(2)}" - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(db_port) - env_vars[f"{var_prefix}_DB"] = db_name - env_vars[f"{var_prefix}_USER"] = db_user - env_vars[f"{var_prefix}_PASS"] = db_pass - env_vars[f"{var_prefix}_ROOT_PASS"] = db_root_pass - snippet = ( - AGENT_FIREBIRD_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - .replace("${USER}", f"${{{var_prefix}_USER}}") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - .replace("${ROOT_PASSWORD}", f"${{{var_prefix}_ROOT_PASS}}") + self.ui.info( + f"Data volume kept: {volume_name}. " + f"Delete it with: docker volume rm {volume_name}" ) + self.ui.info( + f"Restart the agent to apply changes: portabase restart {project_path.name}" + ) - elif db_engine == "redis": - db_port = get_free_port() - db_name = f"redis_{secrets.token_hex(4)}" - if db_variant == "with-auth": - db_pass = generate_password(16) - service_name = f"db-redis-auth-{secrets.token_hex(2)}" - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(db_port) - env_vars[f"{var_prefix}_PASS"] = db_pass - snippet = ( - AGENT_REDIS_AUTH_SNIPPET.replace( - "${SERVICE_NAME}", service_name - ) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - else: - service_name = f"db-redis-{secrets.token_hex(2)}" - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(db_port) - snippet = ( - AGENT_REDIS_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - ) - - elif db_engine == "valkey": - db_port = get_free_port() - db_name = f"valkey_{secrets.token_hex(4)}" - if db_variant == "with-auth": - db_pass = generate_password(16) - service_name = f"db-valkey-auth-{secrets.token_hex(2)}" - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(db_port) - env_vars[f"{var_prefix}_PASS"] = db_pass - snippet = ( - AGENT_VALKEY_AUTH_SNIPPET.replace( - "${SERVICE_NAME}", service_name - ) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - else: - service_name = f"db-valkey-{secrets.token_hex(2)}" - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(db_port) - snippet = ( - AGENT_VALKEY_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - ) - - elif db_engine == "mssql": - db_port = get_free_port() - db_pass = generate_password(16) - db_name = "master" - service_name = f"db-mssql-{secrets.token_hex(2)}" - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(db_port) - env_vars[f"{var_prefix}_PASS"] = db_pass - snippet = ( - AGENT_MSSQL_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - - compose_path = path / "docker-compose.yml" - if compose_path.exists(): - content = compose_path.read_text() - - vol_match = re.search(r"^volumes:", content, re.MULTILINE) - net_match = re.search(r"^networks:", content, re.MULTILINE) - - if vol_match: - insert_pos = vol_match.start() - elif net_match: - insert_pos = net_match.start() - else: - insert_pos = len(content) - - content = content[:insert_pos] + snippet + "\n" + content[insert_pos:] - - vol_match = re.search(r"^volumes:", content, re.MULTILINE) - net_match = re.search(r"^networks:", content, re.MULTILINE) - vol_entry = f" {service_name}-data:\n" - - if vol_match: - if net_match and net_match.start() > vol_match.start(): - content = ( - content[: net_match.start()] - + vol_entry - + content[net_match.start() :] - ) - else: - if not content.endswith("\n"): - content += "\n" - content += vol_entry - else: - if not content.endswith("\n"): - content += "\n" - content += "\nvolumes:\n" + vol_entry - - with open(compose_path, "w") as f: - f.write(content) - - if db_engine != "sqlite": - write_env_file(path, env_vars) - new_entry = { - "name": "mirror.fdb" if db_engine == "firebird" else db_name, - "database": db_container_path - if db_engine == "firebird" - else ("0" if db_engine in ["redis", "valkey"] else db_name), - "type": db_engine, - "username": "sa" if db_engine == "mssql" else db_user, - "password": db_pass, - "port": 5432 - if db_engine in ["postgresql", "postgresql-cluster"] - else ( - 3050 - if db_engine == "firebird" - else ( - 3306 - if db_engine in ["mysql", "mariadb"] - else ( - 1433 - if db_engine == "mssql" - else (6379 if db_engine in ["redis", "valkey"] else 27017) - ) - ) - ), - "host": service_name, - "generated_id": str(uuid.uuid4()), - } - if pg_options is not None: - new_entry["options"] = pg_options - add_db_to_json(path, new_entry) - break - - console.print("[success]✔ Database added to configuration.[/success]") - console.print( - "[info]Restart the agent to apply changes: [/info]" - + f"portabase restart {name}" - ) - - -@app.command("remove") -def remove_db(name: str = typer.Argument(..., help="Name of the agent")): - path = Path(name).resolve() - validate_work_dir(path) - - config = load_db_config(path) - dbs = config.get("databases", []) - - if not dbs: - console.print("[warning]No databases to remove.[/warning]") - return - - options = [f"{db['name']} ({db['type']})" for db in dbs] - choice = Prompt.ask("Which database to remove?", choices=options) - index = options.index(choice) - removed = dbs.pop(index) +class DbListCommand(_DbCommand): + name, help = "list", "List an agent's databases." + + def run(self, name: NameArg) -> None: + project = AgentProject.load(self.require_project_dir(name)) + if not project.databases: + self.ui.warning("No databases configured.") + return + rows = [] + for database in project.databases: + engine = self.engines.get(database.engine) + opts = ", ".join( + f"{key}={value}" + for key, value in engine.non_default_options(database).items() + ) + user = ( + "N/A" + if database.engine in ("sqlite", "docker-volume") + else (database.username or "") + ) + rows.append( + [ + database.name, + database.database or "", + database.engine, + engine.describe(database), + user, + opts, + database.id[:8] + "...", + ] + ) + self.ui.table( + ["Display Name", "Database", "Type", "Host:Port", "User", "Options", "ID"], + rows, + title=f"Databases for {project.path.name}", + ) - config["databases"] = dbs - save_db_config(path, config) - console.print(f"[success]✔ Removed {removed['name']}[/success]") - console.print("[info]Restart the agent to apply changes.[/info]") +class DbCommands(CommandGroup): + name, help, panel = "db", "Manage an agent's databases.", "Components" + + def __init__( + self, + ui: UI, + telemetry: Telemetry, + engines: EngineRegistry, + ports: PortAllocator, + templates: TemplateRepository, + renderer: ComposeRenderer, + docker: DockerRunner, + ) -> None: + super().__init__(ui, telemetry) + self._deps = (ui, telemetry, engines, ports, templates, renderer, docker) + + @property + def commands(self) -> list[Command]: + return [ + DbAddCommand(*self._deps), + DbRemoveCommand(*self._deps), + DbListCommand(*self._deps), + ] diff --git a/commands/decrypt.py b/commands/decrypt.py index d14ddfc..f1c08f5 100644 --- a/commands/decrypt.py +++ b/commands/decrypt.py @@ -1,8 +1,11 @@ +from __future__ import annotations + from pathlib import Path -from typing import Optional +from typing import Annotated import typer +from commands.base import Command from core.crypto import ( ENC_SUFFIX, DecryptionError, @@ -10,124 +13,106 @@ default_output_for, load_master_key, ) -from core.utils import console +from core.errors import ConfigError, ValidationError def _looks_like_dir(path: Path) -> bool: - """True when ``path`` is an existing directory or clearly names one.""" if path.exists(): return path.is_dir() - # A trailing separator or no suffix is treated as a directory hint. return str(path).endswith(("/", "\\")) or path.suffix == "" -def decrypt( - input_path: Path = typer.Argument( - ..., - help="A .enc file, or a folder containing .enc files.", - ), - output_path: Optional[Path] = typer.Argument( - None, - help="Output file or folder (must match the input type). " - "Defaults to the same directory as the input.", - ), - key: Optional[Path] = typer.Option( - None, - "--key", - "-k", - help="Path to the master key file. Defaults to 'master_key.bin' in the " - "current directory.", - ), -): - """Decrypt Portabase AES-256-GCM ``.enc`` backup files.""" - input_path = input_path.resolve() - - if not input_path.exists(): - console.print(f"[danger]✖ Input path not found: {input_path}[/danger]") - raise typer.Exit(1) - - try: +class DecryptCommand(Command): + name = "decrypt" + help = "Decrypt Portabase .enc backup files (single file or folder)." + panel = "Configuration" + no_args_is_help = True + + def run( + self, + input_path: Annotated[ + Path, + typer.Argument(help="A .enc file, or a folder containing .enc files."), + ], + output_path: Annotated[ + Path | None, + typer.Argument( + help="Output file or folder (must match the input type). " + "Defaults to the input directory." + ), + ] = None, + key: Annotated[ + Path | None, + typer.Option( + "--key", "-k", help="Master key file. Defaults to ./master_key.bin" + ), + ] = None, + ) -> None: + input_path = input_path.resolve() + if not input_path.exists(): + raise ConfigError(f"Input path not found: {input_path}") master_key = load_master_key(key.resolve() if key else None) - except DecryptionError as exc: - console.print(f"[danger]✖ {exc}[/danger]") - raise typer.Exit(1) - - if input_path.is_dir(): - _decrypt_folder(input_path, output_path, master_key) - else: - _decrypt_single(input_path, output_path, master_key) - - -def _decrypt_single( - enc_path: Path, output_path: Optional[Path], master_key: bytes -) -> None: - if enc_path.suffix != ENC_SUFFIX: - console.print( - f"[warning]⚠ {enc_path.name} does not end with {ENC_SUFFIX}; " - "decrypting anyway.[/warning]" + if input_path.is_dir(): + self._folder(input_path, output_path, master_key) + else: + self._single(input_path, output_path, master_key) + + def _single( + self, enc_path: Path, output_path: Path | None, master_key: bytes + ) -> None: + if enc_path.suffix != ENC_SUFFIX: + self.ui.warning( + f"{enc_path.name} does not end with {ENC_SUFFIX}; decrypting anyway." + ) + if output_path is None: + out = enc_path.parent / default_output_for(enc_path) + elif _looks_like_dir(output_path): + out = output_path.resolve() / default_output_for(enc_path) + else: + out = output_path.resolve() + try: + decrypt_enc_file(enc_path, out, master_key) + except OSError as error: + raise DecryptionError( + f"I/O error on {enc_path.name}: {error}", cause=error + ) from error + self.ui.success(f"Decrypted {enc_path.name} → {out}") + + def _folder( + self, in_dir: Path, output_path: Path | None, master_key: bytes + ) -> None: + enc_files = sorted( + path + for path in in_dir.iterdir() + if path.is_file() and path.suffix == ENC_SUFFIX ) - - if output_path is None: - out_path = enc_path.parent / default_output_for(enc_path) - elif _looks_like_dir(output_path): - out_path = output_path.resolve() / default_output_for(enc_path) - else: - out_path = output_path.resolve() - - try: - decrypt_enc_file(enc_path, out_path, master_key) - except DecryptionError as exc: - console.print(f"[danger]✖ Failed to decrypt {enc_path.name}: {exc}[/danger]") - raise typer.Exit(1) - except OSError as exc: - console.print(f"[danger]✖ I/O error on {enc_path.name}: {exc}[/danger]") - raise typer.Exit(1) - - console.print(f"[success]✔ Decrypted[/success] {enc_path.name} → {out_path}") - - -def _decrypt_folder( - in_dir: Path, output_path: Optional[Path], master_key: bytes -) -> None: - enc_files = sorted(p for p in in_dir.iterdir() if p.is_file() and p.suffix == ENC_SUFFIX) - - if not enc_files: - console.print(f"[warning]No {ENC_SUFFIX} files found in {in_dir}.[/warning]") - raise typer.Exit() - - if output_path is None: - out_dir = in_dir - elif _looks_like_dir(output_path): - out_dir = output_path.resolve() - else: - console.print( - "[danger]✖ Input is a folder, so the output must be a folder too.[/danger]" + if not enc_files: + self.ui.warning(f"No {ENC_SUFFIX} files found in {in_dir}.") + return + if output_path is None: + out_dir = in_dir + elif _looks_like_dir(output_path): + out_dir = output_path.resolve() + else: + raise ValidationError( + "Input is a folder, so the output must be a folder too." + ) + out_dir.mkdir(parents=True, exist_ok=True) + + failures: list[tuple[str, str]] = [] + with self.ui.status(f"Decrypting {len(enc_files)} file(s)..."): + for enc_path in enc_files: + out = out_dir / default_output_for(enc_path) + try: + decrypt_enc_file(enc_path, out, master_key) + except (DecryptionError, OSError) as error: + failures.append((enc_path.name, str(error))) + succeeded = len(enc_files) - len(failures) + self.ui.info( + f"Done: {succeeded} succeeded, {len(failures)} failed " + f"of {len(enc_files)} file(s)." ) - raise typer.Exit(1) - - out_dir.mkdir(parents=True, exist_ok=True) - - succeeded = 0 - failures: list[tuple[str, str]] = [] - - with console.status(f"[bold magenta]Decrypting {len(enc_files)} file(s)...[/bold magenta]"): - for enc_path in enc_files: - out_path = out_dir / default_output_for(enc_path) - try: - decrypt_enc_file(enc_path, out_path, master_key) - except (DecryptionError, OSError) as exc: - failures.append((enc_path.name, str(exc))) - console.print(f"[danger]✖ {enc_path.name}: {exc}[/danger]") - continue - succeeded += 1 - console.print(f"[success]✔[/success] {enc_path.name} → {out_path.name}") - - console.print( - f"\n[info]Done: {succeeded} succeeded, {len(failures)} failed " - f"of {len(enc_files)} file(s).[/info]" - ) - if failures: - console.print("[warning]Failed files:[/warning]") - for name, reason in failures: - console.print(f" [danger]•[/danger] {name}: {reason}") - raise typer.Exit(1) + if failures: + for name, reason in failures: + self.ui.print(f" [danger]•[/danger] {name}: {reason}") + raise DecryptionError(f"{len(failures)} file(s) failed to decrypt.") diff --git a/templates/__init__.py b/commands/flows/__init__.py similarity index 100% rename from templates/__init__.py rename to commands/flows/__init__.py diff --git a/commands/flows/add_database.py b/commands/flows/add_database.py new file mode 100644 index 0000000..3098795 --- /dev/null +++ b/commands/flows/add_database.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from typing import Any + +from core.errors import ValidationError +from core.fields import Field +from core.specs import DatabaseSpec +from engines import EngineRegistry +from engines.base import DbEngine +from services.ports import PortAllocator +from services.project import AgentProject +from ui import UI +from ui.form import Form + +FLOW_KEYS = {"engine", "mode", "auth", "label", "options"} + + +class AddDatabaseFlow: + def __init__(self, ui: UI, engines: EngineRegistry, ports: PortAllocator) -> None: + self.ui = ui + self.engines = engines + self.ports = ports + + @staticmethod + def parse_options(items: list[str] | None) -> dict[str, str]: + out: dict[str, str] = {} + for item in items or []: + if "=" not in item: + raise ValidationError( + f"Invalid option '{item}'.", hint="Use -o KEY=VALUE" + ) + key, value = item.split("=", 1) + out[key.strip()] = value.strip() + return out + + def collect(self, values: dict[str, Any]) -> tuple[DatabaseSpec, DbEngine]: + form = self.ui.form() + engine_key = form.choice( + "Select Database Engine", + self.engines.choices(), + value=values.get("engine"), + name="engine", + ) + engine = self.engines.get(engine_key) + if engine.warning: + self.ui.warning(engine.warning) + + mode = "new" + if engine.has_modes: + mode = form.choice( + "Configuration Mode", + ["new", "existing"], + value=values.get("mode"), + default="new", + name="mode", + ) + + fields = list( + engine.fields_new() if mode == "new" else engine.fields_existing() + ) + if mode == "existing" or not engine.has_modes: + fields.insert( + 0, Field("label", "Display Name", "text", default=engine.label_default) + ) + + self._reject_irrelevant(values, fields, engine, mode) + + auth = True + if mode == "new" and engine.auth_variants: + raw = values.get("auth") + if raw is None: + picked = form.choice("Variant", ["with-auth", "no-auth"], name="auth") + auth = picked == "with-auth" + else: + auth = bool(raw) + + if mode == "existing": + self.ui.info(f"{engine.display} — existing database") + answers = form.collect(fields, values) + answers["options"] = self._collect_options( + form, engine, values.get("options") or {} + ) + + if mode == "new": + spec = engine.generate(auth=auth, ports=self.ports, answers=answers) + else: + spec = engine.from_existing(answers) + return spec.with_options(answers["options"]), engine + + def apply( + self, project: AgentProject, spec: DatabaseSpec, engine: DbEngine + ) -> None: + project.add(spec, engine) + + def _collect_options( + self, form: Form, engine: DbEngine, provided: dict[str, str] + ) -> dict[str, Any]: + option_fields = engine.option_fields() + known = {field.name for field in option_fields} + unknown = set(provided) - known + if unknown: + raise ValidationError( + f"Unknown option(s) for {engine.key}: {', '.join(sorted(unknown))}.", + hint=( + ("Valid options: " + ", ".join(sorted(known))) + if known + else f"{engine.key} has no options." + ), + ) + if not option_fields: + return {} + return form.collect(option_fields, provided) + + @staticmethod + def _reject_irrelevant( + values: dict[str, Any], fields: list[Field], engine: DbEngine, mode: str + ) -> None: + relevant = {field.name for field in fields} | FLOW_KEYS + extra = sorted( + key + for key, value in values.items() + if value is not None and key not in relevant + ) + if not extra: + return + flags = ", ".join("--" + key.replace("_", "-") for key in extra) + applicable = ", ".join("--" + field.name.replace("_", "-") for field in fields) + raise ValidationError( + f"Option(s) not applicable to {engine.key} in '{mode}' mode: {flags}.", + hint=f"Applicable: {applicable}" + if applicable + else "No extra input needed.", + ) diff --git a/commands/lifecycle.py b/commands/lifecycle.py new file mode 100644 index 0000000..b277dd9 --- /dev/null +++ b/commands/lifecycle.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import contextlib +import shutil +from pathlib import Path +from typing import Annotated + +import typer + +from commands.base import Command +from services.docker import DockerRunner +from services.telemetry import Telemetry +from ui import UI + +PathArg = Annotated[Path, typer.Argument(help="Path to the component folder")] + + +class _ComposeCommand(Command): + panel = "Lifecycle" + no_args_is_help = True + verb: str + compose_args: list[str] + done: str + + def __init__(self, ui: UI, telemetry: Telemetry, docker: DockerRunner) -> None: + super().__init__(ui, telemetry) + self.docker = docker + + def run(self, path: PathArg) -> None: + path = self.require_project_dir(path) + self.require_docker(self.docker) + with self.ui.status(f"{self.verb} {path.name}..."): + self.docker.compose(path, self.compose_args) + self.ui.success(self.done) + + +class StartCommand(_ComposeCommand): + name, help = "start", "Start a Portabase component." + verb, compose_args, done = "Starting", ["up", "-d"], "Started" + + +class StopCommand(_ComposeCommand): + name, help = "stop", "Stop a Portabase component." + verb, compose_args, done = "Stopping", ["stop"], "Stopped" + + +class RestartCommand(_ComposeCommand): + name, help = "restart", "Restart a Portabase component, applying config changes." + verb, compose_args, done = "Restarting", ["up", "-d"], "Restarted" + + def run(self, path: PathArg) -> None: + path = self.require_project_dir(path) + self.require_docker(self.docker) + with self.ui.status(f"{self.verb} {path.name}..."): + # `compose restart` neither creates services added since the last + # start nor rereads env_file; `up -d` converges first. + self.docker.compose(path, ["up", "-d"]) + self.docker.compose(path, ["restart"]) + self.ui.success(self.done) + + +class LogsCommand(Command): + name, help, panel = "logs", "Show the logs of a Portabase component.", "Lifecycle" + no_args_is_help = True + + def __init__(self, ui: UI, telemetry: Telemetry, docker: DockerRunner) -> None: + super().__init__(ui, telemetry) + self.docker = docker + + def run( + self, + path: PathArg, + follow: Annotated[ + bool, typer.Option("--follow/--no-follow", "-f", help="Follow log output") + ] = True, + ) -> None: + path = self.require_project_dir(path) + self.require_docker(self.docker) + args = ["logs", "-f"] if follow else ["logs"] + with contextlib.suppress(KeyboardInterrupt): + self.docker.compose(path, args, check=False) + + +class UninstallCommand(Command): + name = "uninstall" + help = "Uninstall and delete a Portabase component." + panel = "Lifecycle" + no_args_is_help = True + + def __init__(self, ui: UI, telemetry: Telemetry, docker: DockerRunner) -> None: + super().__init__(ui, telemetry) + self.docker = docker + + def run( + self, + path: PathArg, + force: Annotated[ + bool, typer.Option("--force", "-f", help="Skip confirmation") + ] = False, + ) -> None: + path = self.require_project_dir(path) + self.require_docker(self.docker) + if not force: + self.ui.warning( + f"This will delete containers, volumes and all data in {path}." + ) + self.confirm_or_abort("Are you sure?", default=False) + with self.ui.status("Uninstalling..."): + self.docker.compose(path, ["down", "-v"]) + try: + shutil.rmtree(path) + except OSError as error: + self.ui.warning(f"Could not remove directory: {error}") + self.ui.success("Uninstalled") diff --git a/commands/settings.py b/commands/settings.py new file mode 100644 index 0000000..07ded24 --- /dev/null +++ b/commands/settings.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import inspect +import sys +from collections.abc import Callable +from pathlib import Path +from typing import Annotated, Any, Protocol + +import typer + +from commands.base import Command +from commands.db import report_write +from core.errors import ValidationError +from services import settings as cfg +from services.renderer import RenderResult +from services.telemetry import Telemetry +from services.templates import TemplateRepository +from ui import UI + + +class SettingsProject(Protocol): + path: Path + registry: cfg.Registry + + def setting(self, name: str) -> Any: ... + def settings(self) -> dict[str, Any]: ... + def set(self, name: str, value: Any) -> None: ... + def unset(self, name: str) -> None: ... + def save_state(self) -> None: ... + + +def flag_of(name: str) -> str: + return "--" + name.replace("_", "-") + + +def settings_parameters(registry: cfg.Registry) -> list[inspect.Parameter]: + params: list[inspect.Parameter] = [] + for setting in registry: + field = setting.field + flag = flag_of(setting.name) + if field.kind == "bool": + ann: Any = Annotated[ + bool | None, typer.Option(f"{flag}/--no-{flag[2:]}", help=field.prompt) + ] + elif field.kind == "int": + ann = Annotated[int | None, typer.Option(flag, help=field.prompt)] + else: + note = " (prefer the -stdin variant)" if setting.secret else "" + ann = Annotated[str | None, typer.Option(flag, help=field.prompt + note)] + params.append( + inspect.Parameter( + setting.name, + inspect.Parameter.KEYWORD_ONLY, + default=None, + annotation=ann, + ) + ) + if setting.secret: + params.append( + inspect.Parameter( + f"{setting.name}_stdin", + inspect.Parameter.KEYWORD_ONLY, + default=False, + annotation=Annotated[ + bool, + typer.Option( + f"{flag}-stdin", + help=f"Read {field.prompt.lower()} from stdin", + ), + ], + ) + ) + return params + + +def with_settings_flags( + run: Callable[..., None], registry: cfg.Registry +) -> Callable[..., None]: + def entry(*args: Any, **kwargs: Any) -> None: + run(*args, **kwargs) + + static = [ + parameter + for parameter in inspect.signature(run, eval_str=True).parameters.values() + if parameter.kind is not inspect.Parameter.VAR_KEYWORD + ] + signature = inspect.Signature(static + settings_parameters(registry)) + entry.__signature__ = signature # type: ignore[attr-defined] + entry.__annotations__ = { + name: param.annotation for name, param in signature.parameters.items() + } + return entry + + +def read_secret_flags(registry: cfg.Registry, values: dict[str, Any]) -> dict[str, Any]: + out = dict(values) + for setting in registry: + if setting.secret and out.pop(f"{setting.name}_stdin", False): + out[setting.name] = sys.stdin.readline().rstrip("\n") + return out + + +def display(setting: cfg.Setting, value: Any) -> str: + if setting.secret: + return "••••••••" + if isinstance(value, bool): + return "Yes" if value else "No" + return str(value) + + +def apply_settings(ui: UI, project: SettingsProject, values: dict[str, Any]) -> None: + form = ui.form() + for name, raw in values.items(): + if raw is not None: + project.set(name, form.ask(project.registry.get(name).field, raw)) + + +def show_settings(ui: UI, project: SettingsProject) -> None: + values = project.settings() + for section, title in project.registry.sections.items(): + rows = [ + (setting.field.prompt, display(setting, values[setting.name])) + for setting in project.registry.in_section(section) + if values[setting.name] not in (None, "") + ] + if rows: + ui.summary(rows, title=title.upper()) + + +class _SettingsCommand(Command): + panel = "Components" + no_args_is_help = True + + def __init__( + self, + ui: UI, + telemetry: Telemetry, + templates: TemplateRepository, + load: Callable[[Path], SettingsProject], + render: Callable[[Any], RenderResult], + ) -> None: + super().__init__(ui, telemetry) + self.templates = templates + self._load = load + self._render = render + + def load(self, path: Path) -> SettingsProject: + project_path = self.require_project_dir(path) + self.templates.resolve() + return self._load(project_path) + + def write(self, project: SettingsProject) -> None: + with self.ui.status("Rendering configuration..."): + result = self._render(project) + project.save_state() + report = result.write(project.path) + report_write(self.ui, report) + self.ui.info(f"Apply with: portabase restart {project.path.name}") + + +class SetCommand(_SettingsCommand): + name, help = "set", "Change settings: KEY VALUE [KEY VALUE ...]." + + def run( + self, + path: Annotated[Path, typer.Argument(help="Component folder")], + pairs: Annotated[ + list[str], typer.Argument(help="KEY VALUE pairs; keys as in 'show'") + ], + ) -> None: + project = self.load(path) + if len(pairs) % 2: + raise ValidationError( + "Expected KEY VALUE pairs.", + hint="Known keys: " + ", ".join(project.registry.names()), + ) + apply_settings( + self.ui, project, dict(zip(pairs[::2], pairs[1::2], strict=True)) + ) + self.write(project) + for key in pairs[::2]: + self.ui.success( + f"{key} = {display(project.registry.get(key), project.setting(key))}" + ) + + +class UnsetCommand(_SettingsCommand): + name, help = "unset", "Reset settings to their default: KEY [KEY ...]." + + def run( + self, + path: Annotated[Path, typer.Argument(help="Component folder")], + keys: Annotated[list[str], typer.Argument(help="Setting keys")], + ) -> None: + project = self.load(path) + for key in keys: + project.unset(key) + self.write(project) + self.ui.success("Reset: " + ", ".join(keys)) diff --git a/commands/update.py b/commands/update.py new file mode 100644 index 0000000..04a7eba --- /dev/null +++ b/commands/update.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from commands.base import Command +from core.errors import NetworkError, UpdateError +from core.version import UNKNOWN, parse_version +from services.telemetry import Telemetry +from services.updater import Release, UpdateChecker, Updater, is_frozen +from ui import UI + + +class UpdateCommand(Command): + name, help, panel = "update", "Update the CLI to the latest version.", "System" + + def __init__( + self, ui: UI, telemetry: Telemetry, checker: UpdateChecker, updater: Updater + ) -> None: + super().__init__(ui, telemetry) + self.checker = checker + self.updater = updater + + def run(self) -> None: + if not is_frozen(): + self.ui.warning( + "The update command is only available for the binary version " + "of Portabase CLI." + ) + self.ui.info( + "If you installed from source, use [bold]git pull[/bold] to update." + ) + return + + current = self.checker.current + release = self._latest() + if release.tag == current: + self.ui.success(f"Portabase CLI is already up to date ({current}).") + return + if current != UNKNOWN and parse_version(release.tag) < parse_version(current): + self.ui.warning( + f"Current version ({current}) is newer than the latest remote " + f"version ({release.tag})." + ) + self.confirm_or_abort("Continue with the downgrade?", default=False) + + target = self.updater.target_path() + self.ui.info(f"Updating Portabase CLI from {current} to {release.tag}") + self.ui.info(f"Target installation path: {target}") + + total = self.updater.expected_size(release) or 0 + with self.ui.progress().download( + f"Downloading {release.tag}...", total + ) as advance: + tmp = self.updater.download(release, advance) + self.updater.install(tmp, target) + self.ui.success(f"Successfully updated to {release.tag}!") + + def _latest(self) -> Release: + try: + release = self.checker.fetch_latest() + except NetworkError as error: + raise UpdateError( + "Could not fetch latest release data from GitHub.", cause=error + ) from error + if release is None: + raise UpdateError("No release found for this channel.") + return release diff --git a/core/config.py b/core/config.py index 6f47004..da07a34 100644 --- a/core/config.py +++ b/core/config.py @@ -1,82 +1,40 @@ import json import os -import uuid from pathlib import Path -TEMPLATE_BASE_URL = "https://s3.eu-central-3.ionoscloud.com/portabase-software/cli/public/templates" GLOBAL_CONFIG_DIR = Path.home() / ".portabase" GLOBAL_CONFIG_FILE = GLOBAL_CONFIG_DIR / "config.json" -def write_file(path: Path, content: str): - path.parent.mkdir(parents=True, exist_ok=True) - with open(path, "w") as f: - f.write(content) -def write_env_file(work_dir: Path, env_vars: dict): - existing = {} - env_path = work_dir / ".env" - if env_path.exists(): - with open(env_path, "r") as f: - for line in f: - if "=" in line: - k, v = line.strip().split("=", 1) - existing[k] = v.strip('"') - - existing.update(env_vars) - content = "" - for k, v in existing.items(): - content += f'{k}="{v}"\n' - write_file(env_path, content) - -def load_global_config() -> dict: - if not GLOBAL_CONFIG_FILE.exists(): - return {} - try: - with open(GLOBAL_CONFIG_FILE, "r") as f: - return json.load(f) - except: - return {} - -def save_global_config(config: dict): - GLOBAL_CONFIG_DIR.mkdir(parents=True, exist_ok=True) - with open(GLOBAL_CONFIG_FILE, "w") as f: - json.dump(config, f, indent=2) - -def get_config_value(key: str, default=None): - config = load_global_config() - return config.get(key, default) - -def set_config_value(key: str, value): - config = load_global_config() - config[key] = value - save_global_config(config) - -def load_db_config(path: Path) -> dict: - json_path = path / "databases.json" - if not json_path.exists(): - return {"databases": []} - try: - with open(json_path, "r") as f: - return json.load(f) - except: - return {"databases": []} - -def save_db_config(path: Path, config: dict): - json_path = path / "databases.json" - with open(json_path, "w") as f: - json.dump(config, f, indent=2) - try: - os.chmod(json_path, 0o666) - except: - pass - -def add_db_to_json(path: Path, db_entry: dict): - config = load_db_config(path) - if "databases" not in config: - config["databases"] = [] - - if "generated_id" not in db_entry: - db_entry["generated_id"] = str(uuid.uuid4()) - - config["databases"].append(db_entry) - save_db_config(path, config) \ No newline at end of file +class GlobalConfig: + KNOWN_KEYS = ("update_channel",) + + def __init__(self, path: Path = GLOBAL_CONFIG_FILE) -> None: + self.path = path + self.cache_dir = path.parent / "cache" + + def all(self) -> dict: + if not self.path.exists(): + return {} + try: + with open(self.path, encoding="utf-8") as file: + data = json.load(file) + except (OSError, json.JSONDecodeError): + return {} + return data if isinstance(data, dict) else {} + + def get(self, key: str, default=None): + return self.all().get(key, default) + + def set(self, key: str, value) -> None: + data = self.all() + data[key] = value + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_suffix(".json.tmp") + with open(tmp, "w", encoding="utf-8") as file: + json.dump(data, file, indent=2) + os.replace(tmp, self.path) + + @property + def update_channel(self) -> str | None: + return self.get("update_channel") diff --git a/core/crypto.py b/core/crypto.py index 016eee2..dfb4b2d 100644 --- a/core/crypto.py +++ b/core/crypto.py @@ -1,22 +1,5 @@ -"""AES-256-GCM decryption for Portabase ``.enc`` backup files. - -The ``.enc`` format is produced by the Rust encryption pipeline. Layout:: - - {"version":1,"cipher":"AES-256-GCM","chunk_size":16777216,"base_nonce":[...8 bytes...]}\n - [u32 big-endian ciphertext length][ciphertext + 16-byte GCM tag] # chunk 0 - [u32 big-endian ciphertext length][ciphertext + 16-byte GCM tag] # chunk 1 - ... - -* First line is a compact JSON header terminated by ``\n``. -* Each subsequent chunk is a 4-byte big-endian length prefix followed by the - AES-256-GCM ciphertext with the authentication tag appended (as emitted by - the Rust ``aes-gcm`` crate). -* The 12-byte nonce for chunk ``i`` is ``base_nonce (8 bytes) || i (u32 BE)``. -* The master key is the raw 32-byte AES-256 key. No key derivation is applied; - the Rust side base64-decodes ``masterKeyB64`` to obtain these same 32 bytes. -""" - import base64 +import contextlib import json import os import struct @@ -25,6 +8,8 @@ from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from core.errors import PortabaseError + ENC_SUFFIX = ".enc" DEFAULT_KEY_FILENAME = "master_key.bin" CIPHER_NAME = "AES-256-GCM" @@ -32,26 +17,16 @@ _LEN_PREFIX_LEN = 4 _AES_256_KEY_LEN = 32 _TAG_LEN = 16 -# Hard ceiling on a single chunk's plaintext, used when the header's declared -# chunk_size is missing or implausible. Bounds per-chunk allocation so a corrupt -# length prefix can never force an arbitrarily large read on a multi-GB file. _MAX_CHUNK_PLAINTEXT = 256 * 1024 * 1024 -# Copy plaintext to disk in bounded slices so a large chunk is never handed to -# the OS write path as one giant buffer. _WRITE_SLICE = 4 * 1024 * 1024 -class DecryptionError(Exception): - """Raised when a ``.enc`` file cannot be decrypted.""" +class DecryptionError(PortabaseError): + code = "E_CRYPTO" + exit_code = 8 def load_master_key(key_path: Path | None) -> bytes: - """Load the raw 32-byte AES-256 key from ``key_path``. - - When ``key_path`` is ``None`` the key file is looked up as - ``master_key.bin`` in the current working directory. The file may hold - either the raw 32 key bytes or the STANDARD base64 encoding of them. - """ if key_path is None: key_path = Path.cwd() / DEFAULT_KEY_FILENAME @@ -62,14 +37,12 @@ def load_master_key(key_path: Path | None) -> bytes: raw = key_path.read_bytes() - # Raw 32-byte key (as produced by the test/backup tooling). if len(raw) == _AES_256_KEY_LEN: return raw - # Otherwise try to interpret the file as base64 text (masterKeyB64). try: decoded = base64.standard_b64decode(raw.strip()) - except Exception: # binascii.Error / ValueError on malformed base64 + except ValueError: decoded = b"" if len(decoded) == _AES_256_KEY_LEN: return decoded @@ -81,12 +54,6 @@ def load_master_key(key_path: Path | None) -> bytes: def _read_header(handle) -> tuple[bytes, int]: - """Read and validate the JSON header line. - - Returns ``(base_nonce, chunk_size)`` where ``chunk_size`` is the declared - plaintext chunk size, clamped to a safe ceiling. It bounds how many bytes a - single chunk read may allocate regardless of total file size. - """ header_line = handle.readline() if not header_line: raise DecryptionError("File is empty: missing header.") @@ -97,7 +64,9 @@ def _read_header(handle) -> tuple[bytes, int]: cipher = header.get("cipher") if cipher != CIPHER_NAME: - raise DecryptionError(f"Unsupported cipher: {cipher!r} (expected {CIPHER_NAME}).") + raise DecryptionError( + f"Unsupported cipher: {cipher!r} (expected {CIPHER_NAME})." + ) base_nonce = bytes(header.get("base_nonce", [])) if len(base_nonce) != _BASE_NONCE_LEN: @@ -107,19 +76,11 @@ def _read_header(handle) -> tuple[bytes, int]: chunk_size = header.get("chunk_size") if not isinstance(chunk_size, int) or not 0 < chunk_size <= _MAX_CHUNK_PLAINTEXT: - # Unknown or implausible declared size: fall back to the hard ceiling as - # the per-chunk allocation limit rather than trusting the file. chunk_size = _MAX_CHUNK_PLAINTEXT return base_nonce, chunk_size def decrypt_enc_file(enc_path: Path, out_path: Path, key: bytes) -> None: - """Decrypt a single ``.enc`` file to ``out_path``. - - The plaintext is written to a temporary sibling file first and atomically - renamed on success, so a failure never leaves a partial output behind. - Raises :class:`DecryptionError` on any format or authentication failure. - """ aesgcm = AESGCM(key) tmp_path = out_path.with_name(out_path.name + ".part") @@ -134,13 +95,11 @@ def decrypt_enc_file(enc_path: Path, out_path: Path, key: bytes) -> None: while True: len_buf = src.read(_LEN_PREFIX_LEN) if not len_buf: - break # clean end of stream + break if len(len_buf) != _LEN_PREFIX_LEN: raise DecryptionError("Truncated chunk length prefix.") chunk_len = struct.unpack(">I", len_buf)[0] - # Bound the allocation before reading: a corrupt prefix must - # not be able to trigger a multi-GB read on a large file. if chunk_len < _TAG_LEN: raise DecryptionError( f"Chunk {chunk_index} length {chunk_len} is smaller than " @@ -168,8 +127,6 @@ def decrypt_enc_file(enc_path: Path, out_path: Path, key: bytes) -> None: "(wrong key or corrupt data)." ) from exc - # Release the ciphertext buffer before writing the plaintext, - # then write in bounded slices to keep the footprint flat. del ciphertext for start in range(0, len(plaintext), _WRITE_SLICE): dst.write(plaintext[start : start + _WRITE_SLICE]) @@ -177,15 +134,12 @@ def decrypt_enc_file(enc_path: Path, out_path: Path, key: bytes) -> None: os.replace(tmp_path, out_path) except BaseException: - try: + with contextlib.suppress(OSError): tmp_path.unlink(missing_ok=True) - except OSError: - pass raise def default_output_for(enc_path: Path) -> str: - """Return the plaintext filename for ``enc_path`` (strips a trailing ``.enc``).""" name = enc_path.name if name.endswith(ENC_SUFFIX): return name[: -len(ENC_SUFFIX)] diff --git a/core/docker.py b/core/docker.py deleted file mode 100644 index b66c1e0..0000000 --- a/core/docker.py +++ /dev/null @@ -1,19 +0,0 @@ -import subprocess -import typer -from core.utils import console, slugify_project_name -from pathlib import Path - -def ensure_network(name: str): - try: - subprocess.run(["docker", "network", "inspect", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) - except subprocess.CalledProcessError: - subprocess.run(["docker", "network", "create", name], stdout=subprocess.DEVNULL, check=True) - -def run_compose(cwd: Path, args: list): - try: - project_name = slugify_project_name(cwd.resolve().name) - cmd = ["docker", "compose", "-p", project_name] + args - subprocess.run(cmd, cwd=cwd, check=True) - except subprocess.CalledProcessError: - console.print("[danger]Command failed.[/danger]") - raise typer.Exit(1) \ No newline at end of file diff --git a/core/errors.py b/core/errors.py new file mode 100644 index 0000000..8d0612d --- /dev/null +++ b/core/errors.py @@ -0,0 +1,61 @@ +from __future__ import annotations + + +class PortabaseError(Exception): + code: str = "E_GENERIC" + exit_code: int = 1 + + def __init__( + self, + message: str, + *, + hint: str | None = None, + cause: BaseException | None = None, + ) -> None: + super().__init__(message) + self.message = message + self.hint = hint + self.cause = cause + if cause is not None: + self.__cause__ = cause + + def __str__(self) -> str: + return self.message + + +class UserAbort(PortabaseError): + code = "E_ABORT" + exit_code = 130 + + def __init__(self, message: str = "Canceled.", **kwargs) -> None: + super().__init__(message, **kwargs) + + +class ValidationError(PortabaseError): + code = "E_VALIDATION" + exit_code = 2 + + +class ConfigError(PortabaseError): + code = "E_CONFIG" + exit_code = 3 + + +class DockerError(PortabaseError): + code = "E_DOCKER" + exit_code = 4 + + +class TemplateError(PortabaseError): + code = "E_TEMPLATE" + exit_code = 5 + + +class NetworkError(PortabaseError): + code = "E_NETWORK" + exit_code = 6 + + +class UpdateError(PortabaseError): + code = "E_UPDATE" + exit_code = 7 diff --git a/core/fields.py b/core/fields.py new file mode 100644 index 0000000..cd01959 --- /dev/null +++ b/core/fields.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Literal + +FieldKind = Literal["text", "int", "secret", "bool", "choice", "path"] + + +@dataclass(frozen=True) +class Field: + name: str + prompt: str + kind: FieldKind = "text" + default: Any = None + choices: tuple[str, ...] = () + help: str | None = None + validator: Callable[[Any], Any] | None = None + + @property + def flag(self) -> str: + return "--" + self.name.replace("_", "-") diff --git a/core/network.py b/core/network.py deleted file mode 100644 index 9eeeb84..0000000 --- a/core/network.py +++ /dev/null @@ -1,26 +0,0 @@ -import requests -import typer -from rich.console import Console -from core.config import TEMPLATE_BASE_URL -from core.utils import current_version, get_random_hint - -console = Console() - -def fetch_template(filename: str) -> str: - version = current_version() - url = f"{TEMPLATE_BASE_URL}/{version if version != 'unknown' else 'latest'}/{filename}" - - try: - status_msg = f"[dim]Fetching template...[/dim]\n{get_random_hint()}" - with console.status(status_msg): - response = requests.get(url, timeout=10) - if response.status_code in [403, 404] and version != "unknown": - url = f"{TEMPLATE_BASE_URL}/latest/{filename}" - response = requests.get(url, timeout=10) - - response.raise_for_status() - return response.text - except requests.RequestException as e: - console.print(f"[bold red] Error fetching template:[/bold red] {e}") - console.print("[dim]Check your internet connection or the template URL.[/dim]") - raise typer.Exit(1) \ No newline at end of file diff --git a/core/specs.py b/core/specs.py new file mode 100644 index 0000000..82ec84c --- /dev/null +++ b/core/specs.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from typing import Any + + +@dataclass(frozen=True) +class DatabaseSpec: + id: str + engine: str + name: str + managed: bool = False + host: str | None = None + port: int | None = None + host_port: int | None = None + database: str | None = None + username: str | None = None + password: str | None = None + root_password: str | None = None + path: str | None = None + volume: str | None = None + container: str | None = None + options: dict[str, Any] = field(default_factory=dict) + + @property + def env_prefix(self) -> str: + if not self.host: + raise ValueError("env_prefix requires a host/service name") + return self.host.upper().replace("-", "_") + + @property + def auth(self) -> bool: + return bool(self.password) + + def with_options(self, options: dict[str, Any]) -> DatabaseSpec: + return replace(self, options=dict(options)) diff --git a/core/updater.py b/core/updater.py deleted file mode 100644 index be187fe..0000000 --- a/core/updater.py +++ /dev/null @@ -1,291 +0,0 @@ -import json -import os -import platform -import shutil -import subprocess -import sys -import tempfile -import time -from pathlib import Path - -import requests -import typer - -from core.config import get_config_value -from core.utils import console, current_version, get_random_hint - -GITHUB_REPO = "Portabase/cli" -GITHUB_API_BASE_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases" -CACHE_FILE = Path.home() / ".portabase" / "update_cache.json" - - -def is_prerelease(version: str) -> bool: - v = version.lower() - return any(x in v for x in ["a", "b", "rc", "alpha", "beta"]) - - -def get_platform_info(): - system = platform.system().lower() - if system == "darwin": - system = "macos" - machine = platform.machine().lower() - - arch = "amd64" - if machine in ["arm64", "aarch64"]: - arch = "arm64" - elif machine in ["x86_64", "amd64"]: - arch = "amd64" - - return system, arch - - -def get_latest_release_data(pre=False): - try: - if not pre: - response = requests.get(f"{GITHUB_API_BASE_URL}/latest", timeout=5) - response.raise_for_status() - return response.json() - else: - response = requests.get(GITHUB_API_BASE_URL, timeout=5) - response.raise_for_status() - releases = response.json() - return releases[0] if releases else None - except Exception: - return None - - -def check_for_updates(force=False): - if ( - not force - and not getattr(sys, "frozen", False) - and platform.system().lower() != "windows" - ): - return None - - current = current_version() - if current == "unknown": - return None - - channel = get_config_value("update_channel") - if channel: - include_pre = channel == "beta" - else: - include_pre = is_prerelease(current) - - latest_tag = None - - try: - CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) - if not force and CACHE_FILE.exists(): - with open(CACHE_FILE, "r") as f: - cache = json.load(f) - if time.time() - cache.get("last_check", 0) < 86400: - latest_tag = cache.get("latest_version") - except Exception: - pass - - if latest_tag is None: - data = get_latest_release_data(pre=include_pre) - if data: - latest_tag = data.get("tag_name", "").lstrip("v") - try: - with open(CACHE_FILE, "w") as f: - json.dump( - {"last_check": time.time(), "latest_version": latest_tag}, f - ) - except Exception: - pass - - if not latest_tag: - return None - - if latest_tag != current: - console.print( - f"\n[warning]⚠ A new version of Portabase CLI is available: [bold]{latest_tag}[/bold] (current: {current})[/warning]" - ) - console.print("[info]Run [bold]portabase update[/bold] to update.[/info]\n") - return latest_tag - return None - - -def update_cli(): - if not getattr(sys, "frozen", False) and platform.system().lower() != "windows": - console.print( - "[warning]⚠ The update command is only available for the binary version of Portabase CLI.[/warning]" - ) - console.print( - "[info]If you installed via source, please use [bold]git pull[/bold] to update.[/info]" - ) - return - - current = current_version() - - channel = get_config_value("update_channel") - if channel: - pre = channel == "beta" - else: - pre = is_prerelease(current) if current != "unknown" else False - - data = get_latest_release_data(pre=pre) - if not data: - console.print( - "[danger]✖ Could not fetch latest release data from GitHub.[/danger]" - ) - return - - latest_tag = data.get("tag_name", "").lstrip("v") - - if latest_tag == current: - console.print( - f"[success]✔ Portabase CLI is already up to date ({current}).[/success]" - ) - return - - try: - if latest_tag < current and not ( - is_prerelease(current) and not is_prerelease(latest_tag) - ): - console.print( - f"[warning]⚠ Current version ({current}) appears to be older than the latest remote version ({latest_tag}).[/warning]" - ) - if not typer.confirm("Do you want to continue with the update ?"): - return - except Exception: - pass - - system, arch = get_platform_info() - asset_name = f"portabase_{system}_{arch}" - if system == "windows": - asset_name += ".exe" - - asset = next((a for a in data.get("assets", []) if a["name"] == asset_name), None) - - if not asset: - console.print( - f"[danger]✖ Could not find binary for your platform ({system}/{arch}) in the latest release.[/danger]" - ) - available_assets = [a["name"] for a in data.get("assets", [])] - console.print(f"[info]Target asset name: {asset_name}[/info]") - console.print(f"[info]Available assets: {', '.join(available_assets)}[/info]") - return - - console.print( - f"[info]Updating Portabase CLI from {current} to {latest_tag}...[/info]" - ) - - try: - if system == "windows": - default_bin_path = ( - Path(os.environ.get("APPDATA", "")) / "Portabase" / "portabase.exe" - ) - else: - default_bin_path = Path("/usr/local/bin/portabase") - - if getattr(sys, "frozen", False): - current_exe = Path(sys.executable) - else: - if default_bin_path.exists(): - current_exe = default_bin_path - else: - current_exe = ( - Path.home() - / ".local" - / "bin" - / ("portabase" if system != "windows" else "portabase.exe") - ) - - console.print(f"[info]Target installation path: {current_exe}[/info]") - - download_url = asset["browser_download_url"] - fd, temp_path = tempfile.mkstemp(prefix="portabase_update_") - temp_file = Path(temp_path) - os.close(fd) - - try: - response = requests.get(download_url, stream=True, timeout=15) - response.raise_for_status() - total_size = int(response.headers.get("content-length", 0)) - - from rich.progress import ( - BarColumn, - DownloadColumn, - Progress, - SpinnerColumn, - TextColumn, - TransferSpeedColumn, - ) - - with Progress( - SpinnerColumn(), - TextColumn( - "[progress.description]{task.description}\n[hint]" - + get_random_hint() - + "[/hint]" - ), - BarColumn(), - DownloadColumn(), - TransferSpeedColumn(), - console=console, - ) as progress: - task = progress.add_task( - f"Downloading {asset_name}...", total=total_size - ) - with open(temp_file, "wb") as f: - for chunk in response.iter_content(chunk_size=8192): - if chunk: - f.write(chunk) - progress.update(task, advance=len(chunk)) - except Exception as e: - if temp_file.exists(): - temp_file.unlink() - raise e - - if system != "windows": - temp_file.chmod(0o755) - - if system == "windows": - if current_exe.exists(): - old_exe = Path(f"{current_exe}.old") - if old_exe.exists(): - old_exe.unlink() - current_exe.rename(old_exe) - temp_file.rename(current_exe) - else: - need_sudo = not os.access(current_exe.parent, os.W_OK) or ( - current_exe.exists() and not os.access(current_exe, os.W_OK) - ) - - if need_sudo: - console.print( - "[info]Permissions required to install to /usr/local/bin. Using sudo...[/info]" - ) - if current_exe.exists(): - subprocess.run( - ["sudo", "mv", str(current_exe), f"{current_exe}.old"], - check=False, - ) - subprocess.run( - ["sudo", "mv", str(temp_file), str(current_exe)], check=True - ) - subprocess.run(["sudo", "chmod", "+x", str(current_exe)], check=True) - else: - if current_exe.exists(): - old_exe = Path(f"{current_exe}.old") - if old_exe.exists(): - old_exe.unlink() - current_exe.rename(old_exe) - current_exe.parent.mkdir(parents=True, exist_ok=True) - shutil.move(str(temp_file), str(current_exe)) - - try: - console.print(f"[success]✔ Successfully updated to {latest_tag}![/success]") - except Exception: - print(f"Successfully updated to {latest_tag}!") - - except Exception as e: - try: - console.print(f"[danger]✖ An error occurred during update: {e}[/danger]") - except Exception: - print(f"An error occurred during update: {e}") - if "temp_file" in locals() and temp_file.exists(): - temp_file.unlink() diff --git a/core/utils.py b/core/utils.py index a6ec75e..c7eaaad 100644 --- a/core/utils.py +++ b/core/utils.py @@ -1,69 +1,9 @@ import base64 import binascii import json -import platform -import random import re import secrets -import shutil -import socket import string -import subprocess -import time -from pathlib import Path - -import typer -from questionary import Style -from rich.align import Align -from rich.console import Console, Theme -from rich.prompt import Confirm - -questionary_style = Style( - [ - ("pointer", "fg:#ff8800 bold"), - ("highlighted", "fg:black bg:#ff8800 bold"), - ("selected", "fg:#ff8800 bold"), - ] -) - -custom_theme = Theme( - { - "info": "dim cyan", - "warning": "magenta", - "danger": "bold red", - "success": "bold green", - "title": "bold white on #5f00d7", - "key": "bold #ff6600", - "value": "white", - "hint": "italic dim white", - } -) - -HINTS = [ - "The Edge Key contains the connection details for dashboard and agent communication.", - "Portabase uses Docker Compose to isolate your databases.", - "You can list all configured databases using 'portabase db list '.", - "Running 'portabase stop' will gracefully shut down your containers.", - "The agent polls the github for configuration updates.", - "Logs can be viewed in real-time with 'portabase logs '.", - "Custom environment variables can be added to the generated .env file.", - "Need to update? Use 'portabase update' to get the latest version.", - "You can add multiple databases to a single agent during setup.", - "Portabase Dashboard provides a web interface to manage your infrastructure.", - "Is Docker not running? The CLI will offer to start it for you!", - "All configurations are stored locally in the component's folder.", - "The 'portabase restart' command is useful after manual .env modifications.", - "Portabase is open-source! Check our GitHub to contribute.", - "Using the --start flag with 'agent' or 'dashboard' skips the final prompt.", - "Internal databases are automatically backed up when using volumes.", - "The dashboard requires a PostgreSQL database to store its own data.", - "You can change the update channel to 'beta' in the config for early features.", - "Portabase network ensures secure communication between your containers.", - "Lost your Edge Key? You can find it in the dashboard.", - "The 'portabase uninstall' command safely removes containers and their data.", - "Use 'portabase --version' to check your current installation details.", - "The 'databases.json' file keeps track of all managed database instances.", -] def generate_password(length: int = 16) -> str: @@ -74,7 +14,7 @@ def generate_password(length: int = 16) -> str: lower = string.ascii_lowercase upper = string.ascii_uppercase digits = string.digits - symbols = "!@#$%^&*()-_=+[]{}|;:,.<>?" + symbols = "!@#%^&*()-_=+[]{}|;:,.<>?" password = [ secrets.choice(lower), @@ -91,6 +31,10 @@ def generate_password(length: int = 16) -> str: return "".join(password) +def escape_yaml_double_quoted(value: str) -> str: + return value.replace("\\", "\\\\").replace('"', '\\"') + + def slugify_project_name(value: str, fallback: str = "portabase") -> str: slug = re.sub(r"[^a-z0-9_-]+", "-", value.lower()) slug = slug.strip("-_") @@ -99,99 +43,7 @@ def slugify_project_name(value: str, fallback: str = "portabase") -> str: return slug or fallback -def get_random_hint(): - return f"[hint]{random.choice(HINTS)}[/hint]" - - -console = Console(theme=custom_theme) - -BANNER = """ -[bold #ff6600]█▀█ █▀█ █▀█ ▀█▀ ▄▀█ █▄▄ ▄▀█ █▀ █▀▀[/bold #ff6600] -[bold #ff6600]█▀▀ █▄█ █▀▄  █  █▀█ █▄█ █▀█ ▄█ ██▄[/bold #ff6600] -[dim]Deploy your infrastructure anywhere.[/dim] -""" - - -def print_banner(): - console.print(Align.center(BANNER)) - console.print(Align.center(get_random_hint() + "\n")) - - -def get_free_port(): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("", 0)) - return s.getsockname()[1] - - -def start_docker(): - """Attempts to start the Docker daemon based on the OS.""" - os_type = platform.system() - - try: - if os_type == "Linux": - subprocess.run(["sudo", "systemctl", "start", "docker"], check=True) - elif os_type == "Darwin": - subprocess.run(["open", "--background", "-a", "Docker"], check=True) - elif os_type == "Windows": - subprocess.run(["start", "docker"], shell=True, check=True) - - console.print("[info]Waiting for Docker to start...[/info]") - for _ in range(10): - try: - subprocess.run( - ["docker", "info"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=True, - ) - console.print("[success]✔ Docker started successfully.[/success]") - return True - except: - time.sleep(2) - except Exception as e: - console.print(f"[danger]✖ Failed to start Docker:[/danger] {e}") - - return False - - -def check_system(): - docker_path = shutil.which("docker") - - if docker_path is None: - console.print("[danger]✖ Docker not found (binary missing).[/danger]") - raise typer.Exit(1) - - try: - subprocess.run( - [docker_path, "info"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=True, - ) - except subprocess.CalledProcessError: - console.print( - "[warning]⚠ Docker is installed but the Daemon is not running.[/warning]" - ) - if Confirm.ask("Do you want to try starting Docker?"): - if start_docker(): - return - - console.print("[danger]✖ Docker is required to continue.[/danger]") - raise typer.Exit(1) - except Exception as e: - console.print(f"[danger]✖ Critical Error executing Docker:[/danger] {e}") - raise typer.Exit(1) - - -def validate_work_dir(path: Path): - if not (path / "docker-compose.yml").exists(): - console.print(f"[danger]No Portabase configuration found in: {path}[/danger]") - raise typer.Exit(1) - return path - - def validate_edge_key(key: str) -> bool: - """Validates the integrity of the EDGE_KEY (Base64 or JSON).""" try: try: decoded_bytes = base64.b64decode(key, validate=True) @@ -204,24 +56,8 @@ def validate_edge_key(key: str) -> bool: return False required_fields = ["serverUrl", "agentId", "masterKeyB64"] - return all(field in data for field in required_fields) - except Exception: + return isinstance(data, dict) and all( + field in data for field in required_fields + ) + except TypeError: return False - - -def current_version() -> str: - - try: - import sys - import tomllib - from pathlib import Path - - if getattr(sys, "frozen", False): - base_path = Path(sys._MEIPASS) - else: - base_path = Path(__file__).parent.parent - with open(base_path / "pyproject.toml", "rb") as f: - __version__ = tomllib.load(f)["project"]["version"] - except (FileNotFoundError, KeyError, ImportError, AttributeError): - __version__ = "unknown" - return __version__ diff --git a/core/version.py b/core/version.py new file mode 100644 index 0000000..eea5cfa --- /dev/null +++ b/core/version.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import re +import sys +import tomllib +from functools import lru_cache +from pathlib import Path + +UNKNOWN = "unknown" +_PRE = re.compile( + r"^(\d+)\.(\d+)\.(\d+)(?:[-.]?(rc|alpha|beta|a|b)(\d*)(?:\.(\d+))?)?$", re.I +) + + +@lru_cache(maxsize=1) +def current_version() -> str: + try: + bundled = getattr(sys, "_MEIPASS", None) + base = Path(bundled) if bundled else Path(__file__).parent.parent + with open(base / "pyproject.toml", "rb") as file: + return tomllib.load(file)["project"]["version"] + except (FileNotFoundError, KeyError, tomllib.TOMLDecodeError, AttributeError): + return UNKNOWN + + +def is_prerelease(version: str) -> bool: + match = _PRE.match(version.strip().lstrip("v")) + return bool(match and match.group(4)) + + +def parse_version(version: str) -> tuple[int, int, int, int, int, int]: + match = _PRE.match(version.strip().lstrip("v")) + if not match: + return (0, 0, 0, 0, 0, 0) + major, minor, patch = (int(match.group(group)) for group in (1, 2, 3)) + tag = (match.group(4) or "").lower() + rank = {"alpha": 0, "a": 0, "beta": 1, "b": 1, "rc": 2, "": 3}[tag] + num, sub = match.group(5), match.group(6) + if not num: + num, sub = sub, None # "beta.2" is beta 2, like "beta2" + return (major, minor, patch, rank, int(num or 0), int(sub or 0)) diff --git a/docs/superpowers/plans/2026-09-11-plan-1-ci-hygiene.md b/docs/superpowers/plans/2026-09-11-plan-1-ci-hygiene.md new file mode 100644 index 0000000..9f0648a --- /dev/null +++ b/docs/superpowers/plans/2026-09-11-plan-1-ci-hygiene.md @@ -0,0 +1,954 @@ +# Plan 1 — CI, hygiène et release (chantier A) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** CI de PR bloquante (lint, secrets, sécurité pipeline, build smoke), workflows durcis et pinnés, `./release` remplacé par `bump.yml` — sans changer une ligne de comportement du CLI. + +**Architecture:** Un workflow `ci.yml` sur PR/push main avec des jobs indépendants. Les workflows de release existants restent structurellement identiques, seulement pinnés par SHA et restreints en permissions. La configuration ruff vit dans `pyproject.toml` avec des exclusions explicites pour le code legacy qui sera supprimé aux plans 2–4. + +**Tech Stack:** GitHub Actions, uv 0.9, ruff 0.16, pytest, PyInstaller 6.17, gitleaks-action v2, getplumber/plumber, Dependabot. + +**Spec:** `docs/superpowers/specs/2026-09-11-cli-refactor-design.md` — sections 9 (CI, sécurité, release) et 10 (étape A). + +## Global Constraints + +- Python `>=3.12` (pyproject actuel). Ne pas changer. +- Aucune modification de comportement du CLI dans ce plan. Seuls `pyproject.toml`, `.gitignore`, `.github/**`, `.gitleaks.toml` et le formatage (`ruff format`) bougent. +- Toutes les `uses:` pinnées par SHA complet + commentaire `# vX.Y.Z`. +- `permissions: {}` au top de chaque workflow ; permissions explicites par job. +- Le job `test` existe mais ne collecte aucun test (réservé à la spec tests). +- Aucun test unitaire dans ce plan (consigne utilisateur). Chaque tâche a des étapes de vérification exécutables. +- Commits en Conventional Commits (`chore`, `ci`, `build`, `style`). +- `gh` n'est pas authentifié sur ce poste : les appels `gh api` sur dépôts publics fonctionnent, `gh` sur `Portabase/cli` (protection de branche, secrets) ne fonctionne pas. Vérifier ces points dans l'interface GitHub. + +SHAs résolus le 2026-09-11 (à réutiliser tels quels) : + +| Action | Tag | SHA | +|---|---|---| +| actions/checkout | v4 | `11d5960a326750d5838078e36cf38b85af677262` | +| actions/upload-artifact | v4 | `ea165f8d65b6e75b540449e92b4886f43607fa02` | +| actions/download-artifact | v4 | `d3f86a106a0bac45b974a628896c90dbdf5c8093` | +| actions/attest-build-provenance | v2 | `e8998f949152b193b063cb0ec769d69d929409be` | +| astral-sh/setup-uv | v3 | `caf0cab7a618c569241d31dcd442f54681755d39` | +| astral-sh/ruff-action | v3 | `4919ec5cf1f49eff0871dbcea0da843445b837e6` | +| softprops/action-gh-release | v2 | `3bb12739c298aeb8a4eeaf626c5b8d85266b0e65` | +| mikepenz/release-changelog-builder-action | v5 | `c9dc8369bccbc41e0ac887f8fd674f5925d315f7` | +| gitleaks/gitleaks-action | v2 | `ff98106e4c7b2bc287b24eaf42907196329070c7` | +| getplumber/plumber | (doc officielle) | `3feac69e925e9771f8a495f4177af754d568c1ad` | + +Pour re-résoudre un SHA : `gh api repos///git/ref/tags/ --jq .object.sha` (si `.object.type == "tag"`, résoudre encore via `repos///git/tags/ --jq .object.sha`). + +--- + +## File Structure + +| Fichier | Action | Responsabilité | +|---|---|---| +| `pyproject.toml` | modifier | deps runtime/dev, config ruff, config pytest | +| `.gitignore` | modifier | retirer `uv.lock` (tracké, requis par `--frozen`) | +| `commands/*.py`, `core/*.py`, `main.py` | reformater seulement | `ruff format` mécanique, aucun changement sémantique | +| `.gitleaks.toml` | créer | allowlist des faux positifs | +| `.github/workflows/ci.yml` | créer | lint, test, gitleaks, plumber, build-smoke | +| `.github/workflows/python.yml` | modifier | pin SHA, permissions, `--frozen`, attestation | +| `.github/workflows/github.yml` | modifier | pin SHA, permissions par job | +| `.github/workflows/templates-upload.yml` | modifier | pin SHA, permissions, s3cmd sans `~/.s3cfg` | +| `.github/workflows/release.yml`, `release-candidate.yml` | modifier | `permissions: {}` top-level, retirer `packages: write` | +| `.github/dependabot.yml` | créer | github-actions + uv hebdo | +| `.github/workflows/bump.yml` | créer | remplace `./release` | +| `release` | supprimer | — | +| `.github/CONTRIBUTING.md` | modifier | procédure de release | + +--- + +### Task 1 : `pyproject.toml` — dépendances, ruff, pytest + +**Files:** +- Modify: `pyproject.toml` +- Modify: `.gitignore` + +**Interfaces:** +- Produces: commandes `uv run ruff check .`, `uv run ruff format --check .`, `uv run pytest` utilisables localement et en CI ; groupe `dev` avec `pyinstaller`, `ruff`, `pytest`. + +- [ ] **Step 1: Réécrire `pyproject.toml`** + +Remplacer le contenu intégral par : + +```toml +[project] +name = "portabase-cli" +version = "26.07.6" +description = "The official command line interface (CLI) for managing and deploying Portabase instances with ease." +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "typer>=0.20.0", + "rich>=14.2.0", + "questionary>=2.1.0", + "requests>=2.32.5", + "pyyaml>=6.0.3", +] + +[dependency-groups] +dev = [ + "pyinstaller>=6.17.0", + "ruff>=0.16.0", + "pytest>=8.3", +] + +[tool.ruff] +target-version = "py312" +line-length = 88 +extend-exclude = [".venv", "build", "dist"] + +[tool.ruff.lint] +select = [ + "E", "F", "W", # pycodestyle / pyflakes + "I", # isort + "UP", # pyupgrade + "B", # bugbear + "BLE", # blind except + "S110", # try-except-pass + "E722", # bare except + "TID251", # banned imports (activé au plan 2 : rich.prompt, typer.prompt) + "SIM", + "TRY201", + "PLW1510", # subprocess.run sans check= +] +ignore = [ + "B008", # typer.Argument(...) / typer.Option(...) en défaut : idiome Typer + "E501", # line length géré par ruff format +] + +# Code legacy supprimé aux plans 2-4. Ne pas étendre cette liste : tout nouveau +# fichier doit passer sans exception. +[tool.ruff.lint.per-file-ignores] +"commands/agent.py" = ["BLE001", "E722", "S110", "SIM102"] +"commands/db.py" = ["BLE001", "E722", "S110"] +"commands/dashboard.py" = ["BLE001"] +"commands/common.py" = ["BLE001", "PLW1510"] +"core/config.py" = ["BLE001", "E722", "S110"] +"core/utils.py" = ["BLE001", "E722", "S110", "PLR1730"] +"core/updater.py" = ["BLE001", "TRY201"] +"core/network.py" = ["BLE001"] + +[tool.ruff.lint.isort] +known-first-party = ["commands", "core", "templates"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" +``` + +- [ ] **Step 2: Retirer `uv.lock` de `.gitignore`** + +`.gitignore` devient : + +``` +dist/ +build/ +.venv/ +__pycache__/ +*.spec +``` + +- [ ] **Step 3: Régénérer le lock et synchroniser** + +Run: `uv lock && uv sync --all-groups` +Expected: `uv.lock` mis à jour (pyinstaller passe en groupe dev, ruff et pytest ajoutés), `.venv` contient `ruff` et `pytest`. + +- [ ] **Step 4: Vérifier que le CLI démarre toujours** + +Run: `uv run python main.py --version` +Expected: `Portabase CLI version: 26.07.6` (plus éventuel message de mise à jour). + +- [ ] **Step 5: Vérifier le lint** + +Run: `uv run ruff check .` +Expected: `All checks passed!`. Si des erreurs subsistent, ajuster **uniquement** `per-file-ignores` pour les fichiers legacy listés ; ne pas modifier le code Python. + +- [ ] **Step 6: Commit** + +```bash +git add pyproject.toml uv.lock .gitignore +git commit -m "build: move pyinstaller to dev group, add ruff and pytest config + +Legacy files get per-file-ignores for bare/blind excepts; those files are +rewritten in later plans and the ignores are removed with them." +``` + +--- + +### Task 2 : Formatage mécanique + +**Files:** +- Modify: tous les fichiers signalés par `ruff format --check` (5 fichiers au 2026-09-11) + +**Interfaces:** +- Produces: `uv run ruff format --check .` passe. + +- [ ] **Step 1: Lister les fichiers à reformater** + +Run: `uv run ruff format --check .` +Expected: `5 files would be reformatted, 17 files already formatted` (nombres indicatifs). + +- [ ] **Step 2: Appliquer** + +Run: `uv run ruff format .` + +- [ ] **Step 3: Vérifier que rien de sémantique n'a changé** + +Run: `git diff --stat && uv run python main.py --help` +Expected: diff uniquement sur espaces/quotes/retours à la ligne ; `--help` affiche les commandes `agent`, `dashboard`, `start`, `stop`, `restart`, `logs`, `uninstall`, `db`, `config`, `update`. + +- [ ] **Step 4: Vérifier lint + format ensemble** + +Run: `uv run ruff check . && uv run ruff format --check .` +Expected: les deux passent. + +- [ ] **Step 5: Commit** + +```bash +git add -A commands core main.py +git commit -m "style: apply ruff format" +``` + +--- + +### Task 3 : `.gitleaks.toml` + +**Files:** +- Create: `.gitleaks.toml` + +**Interfaces:** +- Produces: config lue par `gitleaks/gitleaks-action` (Task 4) et par `gitleaks detect` en local. + +- [ ] **Step 1: Créer le fichier** + +```toml +# Gitleaks configuration for Portabase CLI. +# Extends the default ruleset; only adds allowlists for known false positives. + +title = "portabase-cli" + +[extend] +useDefault = true + +[allowlist] +description = "Known false positives" +paths = [ + # Compose templates contain PASSWORD=${...} placeholders, never real secrets. + '''templates/.*''', + '''\.github/assets/templates/.*''', + # Lock file: hashes only. + '''uv\.lock''', +] +regexes = [ + # Compose interpolation placeholders. + '''\$\{[A-Z0-9_]+\}''', + # Test/fixture edge keys are base64 JSON with these field names, not credentials. + '''"masterKeyB64"''', +] +``` + +- [ ] **Step 2: Scanner l'historique en local** + +Run: `uvx --from gitleaks gitleaks detect --source . --config .gitleaks.toml --redact --no-banner || docker run --rm -v "$PWD:/repo" -w /repo ghcr.io/gitleaks/gitleaks:v8 detect --source . --config .gitleaks.toml --redact --no-banner` + +(Le binaire gitleaks n'est pas distribué via PyPI ; la première commande échouera, la seconde via Docker fonctionne. Si aucun des deux n'est disponible, passer : la CI fera le scan à la Task 4.) + +Expected: `no leaks found`. Si des fuites réelles sont trouvées dans l'historique : **s'arrêter et le signaler** — ne pas allowlister, ne pas réécrire l'historique sans décision explicite. + +- [ ] **Step 3: Commit** + +```bash +git add .gitleaks.toml +git commit -m "ci: add gitleaks config with template placeholder allowlist" +``` + +--- + +### Task 4 : `ci.yml` — lint, test, gitleaks, plumber, build-smoke + +**Files:** +- Create: `.github/workflows/ci.yml` + +**Interfaces:** +- Consumes: config ruff/pytest de Task 1, `.gitleaks.toml` de Task 3. +- Produces: check requis `CI / lint`, `CI / test`, `CI / gitleaks`, `CI / plumber`, `CI / build-smoke` sur chaque PR. Le job `build-smoke` sera enrichi au Plan 4 (invocation `agent --non-interactive`). + +- [ ] **Step 1: Créer le workflow** + +```yaml +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: {} + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: lint + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 + - name: Install + run: uv sync --frozen --all-groups + - name: Ruff check + run: uv run ruff check . --output-format=github + - name: Ruff format + run: uv run ruff format --check . + + test: + name: test + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 + - name: Install + run: uv sync --frozen --all-groups + - name: Pytest + # Exit code 5 = no tests collected. Accepted until the test suite exists. + run: | + set +e + uv run pytest + code=$? + set -e + if [ "$code" -ne 0 ] && [ "$code" -ne 5 ]; then exit "$code"; fi + + gitleaks: + name: gitleaks + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + - uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_CONFIG: .gitleaks.toml + + plumber: + name: plumber + runs-on: ubuntu-24.04 + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: getplumber/plumber@3feac69e925e9771f8a495f4177af754d568c1ad + with: + score-push: false + upload-sarif: true + # First run: observe only. Tighten to min-score once the baseline is known. + soft-fail: true + + build-smoke: + name: build-smoke + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 + - name: Install + run: uv sync --frozen --all-groups + - name: Build binary + run: | + rm -rf build dist *.spec + uv run pyinstaller \ + --onefile \ + --name portabase_smoke \ + --paths=. \ + --collect-all rich \ + --collect-all requests \ + --collect-data certifi \ + --add-data "pyproject.toml:." \ + main.py + - name: Smoke + run: | + ./dist/portabase_smoke --version + ./dist/portabase_smoke --help +``` + +- [ ] **Step 2: Valider la syntaxe YAML localement** + +Run: `uv run python -c "import yaml,sys; yaml.safe_load(open('.github/workflows/ci.yml')); print('ok')"` +Expected: `ok`. + +- [ ] **Step 3: Vérifier localement ce que fera le job lint** + +Run: `uv sync --frozen --all-groups && uv run ruff check . --output-format=github && uv run ruff format --check .` +Expected: aucune sortie d'erreur. + +- [ ] **Step 4: Vérifier localement ce que fera le job test** + +Run: `uv run pytest; echo "exit=$?"` +Expected: `exit=5` (aucun test collecté). + +- [ ] **Step 5: Vérifier localement ce que fera build-smoke** + +Run: `rm -rf build dist *.spec && uv run pyinstaller --onefile --name portabase_smoke --paths=. --collect-all rich --collect-all requests --collect-data certifi --add-data "pyproject.toml:." main.py && ./dist/portabase_smoke --version` +Expected: `Portabase CLI version: 26.07.6`. Puis `rm -rf build dist *.spec`. + +- [ ] **Step 6: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "ci: add PR workflow (lint, test, gitleaks, plumber, build smoke)" +``` + +--- + +### Task 5 : Durcir `python.yml` (build binaires) + +**Files:** +- Modify: `.github/workflows/python.yml` + +**Interfaces:** +- Consumes: appelé par `release.yml` / `release-candidate.yml` via `workflow_call`. +- Produces: artefacts `portabase__` inchangés + attestation de provenance. + +- [ ] **Step 1: Réécrire le workflow** + +```yaml +name: Build Python Binaries + +on: + workflow_call: + +permissions: {} + +jobs: + build: + name: Build for ${{ matrix.os }} (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + permissions: + contents: read + id-token: write + attestations: write + strategy: + matrix: + include: + - os: linux + arch: amd64 + runner: ubuntu-latest + - os: linux + arch: arm64 + runner: ubuntu-24.04-arm + - os: macos + arch: arm64 + runner: macos-latest + - os: macos + arch: amd64 + runner: macos-15-intel + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 + + - name: Install + run: uv sync --frozen --all-groups + + - name: Build binary + run: | + rm -rf build dist *.spec + uv run pyinstaller \ + --onefile \ + --name portabase_${{ matrix.os }}_${{ matrix.arch }} \ + --paths=. \ + --collect-all rich \ + --collect-all requests \ + --collect-data certifi \ + --add-data "pyproject.toml:." \ + main.py + + - name: Smoke + run: ./dist/portabase_${{ matrix.os }}_${{ matrix.arch }} --version + + - name: Attest provenance + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2 + with: + subject-path: dist/portabase_${{ matrix.os }}_${{ matrix.arch }} + + - name: Upload artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: portabase_${{ matrix.os }}_${{ matrix.arch }} + path: dist/portabase_${{ matrix.os }}_${{ matrix.arch }} +``` + +Changements par rapport à l'actuel : `uv python install` remplacé par `uv sync --frozen` (respecte `.python-version` et le lock) ; étape `Smoke` ; attestation ; permissions explicites. + +- [ ] **Step 2: Valider YAML** + +Run: `uv run python -c "import yaml; yaml.safe_load(open('.github/workflows/python.yml')); print('ok')"` +Expected: `ok`. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/python.yml +git commit -m "ci: pin actions, scope permissions, attest binaries in build workflow" +``` + +--- + +### Task 6 : Durcir `github.yml` (release GitHub + Discord) + +**Files:** +- Modify: `.github/workflows/github.yml` + +**Interfaces:** +- Consumes: artefacts de Task 5. +- Produces: release GitHub identique à aujourd'hui. + +- [ ] **Step 1: Modifier uniquement l'en-tête et les `uses:`** + +Remplacer le bloc `jobs:` d'en-tête et les trois `uses:` ; le reste (changelog config, script Discord) reste identique. + +En-tête (après le bloc `on:` existant, avant `jobs:`) — ajouter : + +```yaml +permissions: {} +``` + +Job : + +```yaml +jobs: + create-release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Check out the repo + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + + - name: Download artifacts + if: inputs.artifact_name != '' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: ${{ inputs.artifact_name }} + path: dist + merge-multiple: true +``` + +Et plus bas : + +```yaml + - name: Build Changelog + id: build_changelog + uses: mikepenz/release-changelog-builder-action@c9dc8369bccbc41e0ac887f8fd674f5925d315f7 # v5 +``` + +```yaml + - name: Create GitHub Release + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 +``` + +- [ ] **Step 2: Vérifier qu'aucun `@vN` non pinné ne reste** + +Run: `grep -nE 'uses: .*@v[0-9]' .github/workflows/github.yml` +Expected: aucune sortie. + +- [ ] **Step 3: Valider YAML** + +Run: `uv run python -c "import yaml; yaml.safe_load(open('.github/workflows/github.yml')); print('ok')"` +Expected: `ok`. + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/github.yml +git commit -m "ci: pin actions and scope permissions in release workflow" +``` + +--- + +### Task 7 : Durcir `templates-upload.yml` (S3 sans fichier de credentials) + +**Files:** +- Modify: `.github/workflows/templates-upload.yml` + +**Interfaces:** +- Produces: même arborescence S3 qu'aujourd'hui (`cli/public/templates//` et `latest/`). La source reste `.github/assets/templates/` jusqu'au Plan 3 qui la déplace vers `templates/` et ajoute le manifest. + +- [ ] **Step 1: Réécrire le workflow** + +```yaml +name: Upload Templates to S3 + +on: + workflow_call: + inputs: + version: + required: true + type: string + is_prerelease: + required: true + type: boolean + secrets: + S3_ENDPOINT: + required: true + S3_ACCESS_KEY: + required: true + S3_SECRET_KEY: + required: true + S3_BUCKET: + required: true + +permissions: {} + +jobs: + upload: + runs-on: ubuntu-latest + permissions: + contents: read + env: + # s3cmd reads these flags; no config file is written to disk. + S3CMD_ARGS: >- + --access_key=${{ secrets.S3_ACCESS_KEY }} + --secret_key=${{ secrets.S3_SECRET_KEY }} + --host=${{ secrets.S3_ENDPOINT }} + --host-bucket=%(bucket)s.${{ secrets.S3_ENDPOINT }} + --ssl + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Install s3cmd + run: sudo apt-get update && sudo apt-get install -y s3cmd + + - name: Upload versioned templates + run: | + CLEAN_VERSION="${{ inputs.version }}" + CLEAN_VERSION="${CLEAN_VERSION#v}" + s3cmd $S3CMD_ARGS sync .github/assets/templates/ \ + "s3://${{ secrets.S3_BUCKET }}/cli/public/templates/${CLEAN_VERSION}/" --acl-public + + - name: Upload latest templates (stable only) + if: ${{ !inputs.is_prerelease }} + run: | + s3cmd $S3CMD_ARGS sync .github/assets/templates/ \ + "s3://${{ secrets.S3_BUCKET }}/cli/public/templates/latest/" --acl-public +``` + +Note : les secrets passés en arguments de ligne de commande sont masqués dans les logs par GitHub (`***`). C'est le compromis retenu ; l'alternative (`~/.s3cfg`) laisse les secrets en clair sur le disque du runner. + +- [ ] **Step 2: Valider YAML** + +Run: `uv run python -c "import yaml; yaml.safe_load(open('.github/workflows/templates-upload.yml')); print('ok')"` +Expected: `ok`. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/templates-upload.yml +git commit -m "ci: pass S3 credentials to s3cmd as flags instead of writing ~/.s3cfg" +``` + +--- + +### Task 8 : Permissions top-level sur `release.yml` et `release-candidate.yml` + +**Files:** +- Modify: `.github/workflows/release.yml:10-13` +- Modify: `.github/workflows/release-candidate.yml:12-15` + +**Interfaces:** +- Produces: workflows appelants avec permissions minimales ; les jobs `uses:` héritent des permissions déclarées dans les workflows appelés (Tasks 5–7). + +- [ ] **Step 1: Dans les deux fichiers, remplacer** + +```yaml +permissions: + contents: write + packages: write +``` + +par + +```yaml +permissions: + contents: write + id-token: write + attestations: write + security-events: write +``` + +Un workflow appelant doit déclarer au moins les permissions que les workflows appelés demandent (`contents: write` pour la release, `id-token`/`attestations` pour l'attestation). `packages: write` n'était utilisé par aucun job. + +- [ ] **Step 2: Vérifier** + +Run: `grep -n "packages" .github/workflows/*.yml` +Expected: aucune sortie. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/release.yml .github/workflows/release-candidate.yml +git commit -m "ci: drop unused packages permission, declare attestation permissions" +``` + +--- + +### Task 9 : Dependabot + +**Files:** +- Create: `.github/dependabot.yml` + +**Interfaces:** +- Produces: PRs hebdomadaires pour les SHAs d'actions et les dépendances uv. + +- [ ] **Step 1: Créer le fichier** + +```yaml +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: ["*"] + commit-message: + prefix: "ci" + + - package-ecosystem: uv + directory: / + schedule: + interval: weekly + groups: + python: + patterns: ["*"] + commit-message: + prefix: "build" +``` + +- [ ] **Step 2: Valider YAML** + +Run: `uv run python -c "import yaml; yaml.safe_load(open('.github/dependabot.yml')); print('ok')"` +Expected: `ok`. + +- [ ] **Step 3: Commit** + +```bash +git add .github/dependabot.yml +git commit -m "ci: enable dependabot for actions and uv" +``` + +--- + +### Task 10 : `bump.yml` remplace `./release` + +**Files:** +- Create: `.github/workflows/bump.yml` +- Delete: `release` +- Modify: `.github/CONTRIBUTING.md` + +**Interfaces:** +- Produces: déclenchement manuel qui commit `chore(release): X`, tague `X` et pousse. Le push du tag déclenche `release.yml` ou `release-candidate.yml` selon le motif, exactement comme le script. + +- [ ] **Step 1: Créer le workflow** + +```yaml +name: Bump version + +on: + workflow_dispatch: + inputs: + version: + description: "Version (e.g. 26.09.0 or 26.09.0rc1). No leading v." + required: true + type: string + channel: + description: "stable: only from main. rc: any branch." + required: true + type: choice + options: [stable, rc] + default: rc + +permissions: {} + +jobs: + bump: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + # Use a PAT if branch protection blocks GITHUB_TOKEN pushes to main. + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Validate version against channel + env: + VERSION: ${{ inputs.version }} + CHANNEL: ${{ inputs.channel }} + REF: ${{ github.ref_name }} + run: | + set -euo pipefail + if [[ "$VERSION" == v* ]]; then + echo "::error::Version must not start with 'v'"; exit 1 + fi + if [[ "$CHANNEL" == "stable" ]]; then + if [[ "$REF" != "main" ]]; then + echo "::error::stable releases are only allowed from main (got $REF)"; exit 1 + fi + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::stable version must match X.Y.Z"; exit 1 + fi + else + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.]?(rc|alpha|beta|a|b)[0-9]*(\.[0-9]+)?)$ ]]; then + echo "::error::rc version must match X.Y.Z(rc|a|b|alpha|beta)N"; exit 1 + fi + fi + if git rev-parse "$VERSION" >/dev/null 2>&1; then + echo "::error::Tag $VERSION already exists"; exit 1 + fi + + - name: Update version files + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + DATE=$(date -u +%F) + sed -i "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml + if [ -f CITATION.cff ]; then + sed -i "s/^version: .*/version: $VERSION/" CITATION.cff + sed -i "s/^date-released: .*/date-released: \"$DATE\"/" CITATION.cff + fi + git diff --stat + + - name: Commit, tag, push + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add pyproject.toml CITATION.cff + if git diff --cached --quiet; then + echo "No version change to commit" + else + git commit -m "chore(release): $VERSION" + fi + git tag -a "$VERSION" -m "Release $VERSION" + git push origin HEAD + git push origin "$VERSION" +``` + +Différences avec le script : pas de `package.json` / `Cargo.toml` (absents du dépôt) ; `git add .` remplacé par un `add` ciblé ; identité bot. + +Point à vérifier dans l'interface GitHub (Settings → Branches) : si `main` exige une PR, `git push origin HEAD` sera refusé pour `GITHUB_TOKEN`. Deux solutions : (a) autoriser `github-actions[bot]` à contourner la règle ; (b) créer un PAT fine-grained (Contents: write) stocké en secret `RELEASE_TOKEN` et remplacer `token: ${{ secrets.GITHUB_TOKEN }}` par `token: ${{ secrets.RELEASE_TOKEN }}`. Note : un push effectué avec `GITHUB_TOKEN` ne déclenche **pas** d'autres workflows par design GitHub — **le push du tag ne déclenchera donc pas `release.yml`**. Avec un PAT (`RELEASE_TOKEN`), il le déclenche. → **Utiliser un PAT est obligatoire** pour que le tag lance la release. Créer le secret avant le premier usage. + +- [ ] **Step 2: Remplacer le token par le PAT** + +Dans le workflow ci-dessus, `token: ${{ secrets.GITHUB_TOKEN }}` → `token: ${{ secrets.RELEASE_TOKEN }}` et supprimer le commentaire au-dessus. Le secret `RELEASE_TOKEN` (fine-grained PAT, dépôt `Portabase/cli`, permissions Contents: Read and write, Metadata: Read) doit être créé par un mainteneur dans Settings → Secrets → Actions. + +- [ ] **Step 3: Supprimer le script** + +Run: `git rm release` + +- [ ] **Step 4: Documenter dans CONTRIBUTING.md** + +Ajouter une section à la fin de `.github/CONTRIBUTING.md` : + +```markdown +## Releasing + +Releases are cut from GitHub Actions, never from a local machine. + +1. Open **Actions → Bump version → Run workflow**. +2. Pick the branch (`main` for stable, any branch for a release candidate). +3. Enter the version without a leading `v` (`26.09.0` for stable, `26.09.0rc1` for a candidate) and the matching channel. +4. The workflow commits `chore(release): `, creates the tag and pushes. The tag triggers the build, the GitHub release, the Discord notification and the template upload. + +Stable versions must match `X.Y.Z` and can only be cut from `main`. +``` + +- [ ] **Step 5: Valider YAML** + +Run: `uv run python -c "import yaml; yaml.safe_load(open('.github/workflows/bump.yml')); print('ok')"` +Expected: `ok`. + +- [ ] **Step 6: Commit** + +```bash +git add .github/workflows/bump.yml .github/CONTRIBUTING.md +git commit -m "ci: replace ./release script with bump workflow" +``` + +--- + +### Task 11 : Vérification de bout en bout sur GitHub + +**Files:** aucun. + +**Interfaces:** +- Consumes: tout ce qui précède. + +- [ ] **Step 1: Pousser une branche et ouvrir une PR** + +```bash +git checkout -b ci/hygiene +git push -u origin ci/hygiene +gh pr create --fill --title "ci: PR workflow, pinned actions, bump workflow" --body "Implements plan 1 (chantier A) of docs/superpowers/specs/2026-09-11-cli-refactor-design.md. No CLI behaviour change." +``` + +(`gh` non authentifié ici : créer la PR depuis l'interface si la commande échoue.) + +- [ ] **Step 2: Vérifier les checks** + +Expected dans l'onglet Checks : `lint`, `test`, `gitleaks`, `plumber`, `build-smoke` tous verts. `plumber` publie un rapport SARIF dans Security → Code scanning ; noter le score obtenu. + +- [ ] **Step 3: Si `plumber` remonte des findings sur les workflows** + +Les traiter dans la même PR si triviaux (permission manquante, action non pinnée oubliée). Sinon ouvrir une issue avec la liste et laisser `soft-fail: true`. + +- [ ] **Step 4: Créer le secret `RELEASE_TOKEN`** + +Settings → Secrets and variables → Actions → New repository secret. PAT fine-grained, dépôt `Portabase/cli`, Contents: Read and write, Metadata: Read. + +- [ ] **Step 5: Merger, puis tester `bump.yml` avec un rc jetable** + +Actions → Bump version → branche `main`, version `26.07.7rc1`, channel `rc`. Expected : commit `chore(release): 26.07.7rc1` sur `main`, tag créé, `release-candidate.yml` déclenché, binaires attestés publiés en pre-release, templates uploadés sous `templates/26.07.7rc1/`. + +- [ ] **Step 6: Rendre les checks requis** + +Settings → Branches → `main` → Require status checks : `lint`, `test`, `gitleaks`, `build-smoke`. Laisser `plumber` non requis tant que `soft-fail: true`. + +--- + +## Self-review + +**Spec coverage (§9, §10 A) :** +- 9.1 `ci.yml` : lint ✔ (T4), test vide ✔ (T4), gitleaks ✔ (T3, T4), plumber ✔ (T4), build-smoke `--version` ✔ (T4 ; l'invocation `agent --non-interactive` arrive au Plan 4), `render-check` et `engines-check` → Plan 3 (dépendent des templates `.j2` et du registre). +- 9.2 pin SHA ✔ (T4–T8), Dependabot ✔ (T9), `permissions: {}` ✔, `packages: write` retiré ✔ (T8), `~/.s3cfg` supprimé ✔ (T7), attestation ✔ (T5). +- 9.3 `bump.yml` ✔ (T10), `./release` supprimé ✔, pas de release-please ✔, question branch protection → T10/T11. `templates-hotfix.yml` et manifest → Plan 3. +- 9.4 `pyproject.toml` ✔ (T1) ; `jinja2` ajouté au Plan 3 quand il est utilisé. +- 10 A : shippable stable ✔ (T11 step 5 le prouve avec un rc). + +**Placeholder scan :** aucun TBD/TODO. Toutes les étapes ont leur contenu ou leur commande. + +**Type consistency :** noms de jobs identiques entre T4 et T11 (`lint`, `test`, `gitleaks`, `plumber`, `build-smoke`) ; secret `RELEASE_TOKEN` cohérent T10/T11 ; SHAs identiques entre tâches. + +**Écart connu :** T1 `per-file-ignores` liste des fichiers/règles déduits du run ruff du 2026-09-11 ; si ruff remonte une règle non listée sur un fichier legacy, l'ajouter à la liste de ce fichier (pas de correction de code). diff --git a/docs/superpowers/plans/2026-09-11-plan-2-foundations-lifecycle.md b/docs/superpowers/plans/2026-09-11-plan-2-foundations-lifecycle.md new file mode 100644 index 0000000..7ec1a50 --- /dev/null +++ b/docs/superpowers/plans/2026-09-11-plan-2-foundations-lifecycle.md @@ -0,0 +1,2381 @@ +# Plan 2 — Fondations et lifecycle (chantiers B + C) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Poser les fondations POO (erreurs, `ui/`, services d'infrastructure, `Command`, catcher, télémétrie no-op, updater sans auto-update) et réécrire les commandes de lifecycle/config/update dessus, tout en gardant `agent`, `dashboard` et `db` sur l'ancien code via un adaptateur — le CLI reste shippable en stable à la fin. + +**Architecture:** `main.py` construit les dépendances (`UI`, `Telemetry`, `GlobalConfig`, `HttpClient`, `DockerRunner`) et les injecte dans des classes `Command` enregistrées sur Typer. Un seul `try` dans `main()` traduit `PortabaseError` en message + code de sortie. Les commandes legacy sont enregistrées telles quelles par `LegacyCommand` ; elles continuent d'importer `core.utils.console` jusqu'au Plan 4. + +**Tech Stack:** Python 3.12, Typer 0.25 / Click 8.4, Rich 15, questionary 2.1, requests. + +**Spec:** `docs/superpowers/specs/2026-09-11-cli-refactor-design.md` — sections 3, 4.1, 7, 8, 10 (B, C). + +## Global Constraints + +- Prérequis : Plan 1 exécuté (ruff configuré, CI en place). +- Règle de dépendance descendante : `commands → services, engines, ui, core` ; `services → engines, core` (jamais `ui`) ; `ui → core` ; `core → rien`. Vérifiée par ruff `TID251` (Task 12). +- `rich.prompt`, `typer.prompt`, `typer.confirm`, `print` interdits hors `ui/` (ruff `TID251`, activé Task 12 avec exceptions legacy). +- `typer.Exit` n'est levé nulle part hors des fichiers legacy ; le nouveau code lève `PortabaseError`. +- Pas de tests unitaires (consigne). Chaque tâche a des vérifications exécutables ; les commandes Docker sont vérifiées avec un dossier agent réel si Docker est disponible, sinon sur leurs chemins d'erreur. +- Ne pas toucher `commands/agent.py`, `commands/db.py`, `commands/dashboard.py`, `core/network.py`, `core/docker.py`, `templates/compose.py` (supprimés au Plan 4). `core/utils.py` : seulement retirer `current_version` (Task 2). +- Déviation spec assumée : `Field` vit dans `core/fields.py` (partagé par `ui.Form` et `engines`), pas dans `engines/base.py`. +- Nom de la clé de config existante conservé : `update_channel` (valeurs `stable` / `beta`). +- Commits Conventional Commits, un par tâche minimum. + +--- + +## File Structure + +| Fichier | Action | Responsabilité | +|---|---|---| +| `core/errors.py` | créer | hiérarchie `PortabaseError` | +| `core/version.py` | créer | `current_version()`, `parse_version()`, `is_prerelease()` | +| `core/utils.py` | modifier | retirer `current_version` (re-export pour legacy) | +| `core/config.py` | modifier | ajouter classe `GlobalConfig` ; fonctions legacy conservées | +| `core/fields.py` | créer | `Field` | +| `ui/theme.py` | créer | `PALETTE`, `RICH_THEME`, `QUESTIONARY_STYLE` | +| `ui/components/base.py` | créer | `Component` | +| `ui/components/hints.py` | créer | `HINTS`, `Hint` | +| `ui/components/message.py` | créer | `Message` | +| `ui/components/banner.py` | créer | `Banner` | +| `ui/components/section.py` | créer | `Section` | +| `ui/components/status.py` | créer | `Status` | +| `ui/components/progress.py` | créer | `Progress` (téléchargement) | +| `ui/components/prompt.py` | créer | `Prompt` (questionary) | +| `ui/form.py` | créer | `Form` | +| `ui/__init__.py` | créer | façade `UI` | +| `services/http.py` | créer | `HttpClient` | +| `services/docker.py` | créer | `DockerRunner` | +| `services/telemetry.py` | créer | `Telemetry`, `NoopTelemetry`, `ConsoleTelemetry`, `TelemetryHub`, `TelemetryFactory` | +| `services/updater.py` | créer | `Release`, `UpdateChecker`, `Updater` | +| `commands/base.py` | créer | `Command`, `CommandGroup`, `LegacyCommand` | +| `commands/lifecycle.py` | créer | `Start/Stop/Restart/Logs/Uninstall` | +| `commands/config.py` | réécrire | `ConfigCommands` | +| `commands/update.py` | créer | `UpdateCommand` | +| `main.py` | réécrire | `Settings`, `build_app`, `main` | +| `commands/common.py`, `core/updater.py` | supprimer | — | +| `pyproject.toml` | modifier | `TID251`, per-file-ignores mis à jour | + +--- + +### Task 1 : `core/errors.py` + +**Files:** +- Create: `core/errors.py` + +**Interfaces:** +- Produces: `PortabaseError(message, *, hint=None, cause=None)` avec attributs `message`, `hint`, `cause`, classe-attributs `code: str`, `exit_code: int` ; sous-classes `UserAbort`, `ValidationError`, `ConfigError`, `DockerError`, `TemplateError`, `NetworkError`, `UpdateError`. + +- [ ] **Step 1: Écrire le module** + +```python +"""Exception hierarchy. Every error the CLI reports to a user is one of these.""" + +from __future__ import annotations + + +class PortabaseError(Exception): + code: str = "E_GENERIC" + exit_code: int = 1 + + def __init__( + self, + message: str, + *, + hint: str | None = None, + cause: BaseException | None = None, + ) -> None: + super().__init__(message) + self.message = message + self.hint = hint + self.cause = cause + if cause is not None: + self.__cause__ = cause + + def __str__(self) -> str: + return self.message + + +class UserAbort(PortabaseError): + code = "E_ABORT" + exit_code = 130 + + def __init__(self, message: str = "Cancelled.", **kwargs) -> None: + super().__init__(message, **kwargs) + + +class ValidationError(PortabaseError): + code = "E_VALIDATION" + exit_code = 2 + + +class ConfigError(PortabaseError): + code = "E_CONFIG" + exit_code = 3 + + +class DockerError(PortabaseError): + code = "E_DOCKER" + exit_code = 4 + + +class TemplateError(PortabaseError): + code = "E_TEMPLATE" + exit_code = 5 + + +class NetworkError(PortabaseError): + code = "E_NETWORK" + exit_code = 6 + + +class UpdateError(PortabaseError): + code = "E_UPDATE" + exit_code = 7 +``` + +- [ ] **Step 2: Vérifier** + +Run: `uv run python -c "from core.errors import *; e = DockerError('daemon down', hint='start it'); print(e.code, e.exit_code, e, e.hint); assert isinstance(e, PortabaseError)"` +Expected: `E_DOCKER 4 daemon down start it`. + +- [ ] **Step 3: Commit** + +```bash +git add core/errors.py +git commit -m "feat(core): add PortabaseError hierarchy with stable codes and exit codes" +``` + +--- + +### Task 2 : `core/version.py` et `core/fields.py` + +**Files:** +- Create: `core/version.py` +- Create: `core/fields.py` +- Modify: `core/utils.py:197-214` (fonction `current_version`) + +**Interfaces:** +- Produces: `current_version() -> str` ; `parse_version(v: str) -> tuple` ; `is_prerelease(v: str) -> bool` ; `Field` dataclass. + +- [ ] **Step 1: Écrire `core/version.py`** + +```python +"""CLI version helpers. Version is read from the bundled pyproject.toml.""" + +from __future__ import annotations + +import re +import sys +import tomllib +from functools import lru_cache +from pathlib import Path + +UNKNOWN = "unknown" +_PRE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:[-.]?(rc|alpha|beta|a|b)(\d*))?$", re.I) + + +@lru_cache(maxsize=1) +def current_version() -> str: + try: + base = Path(sys._MEIPASS) if getattr(sys, "frozen", False) else Path(__file__).parent.parent + with open(base / "pyproject.toml", "rb") as f: + return tomllib.load(f)["project"]["version"] + except (FileNotFoundError, KeyError, tomllib.TOMLDecodeError, AttributeError): + return UNKNOWN + + +def is_prerelease(version: str) -> bool: + m = _PRE.match(version.strip().lstrip("v")) + return bool(m and m.group(4)) + + +def parse_version(version: str) -> tuple[int, int, int, int, int]: + """Sortable tuple. Pre-releases sort before the final release of the same number. + + (major, minor, patch, pre_rank, pre_number) — pre_rank: 0 alpha/a, 1 beta/b, 2 rc, 3 final. + """ + m = _PRE.match(version.strip().lstrip("v")) + if not m: + return (0, 0, 0, 0, 0) + major, minor, patch = (int(m.group(i)) for i in (1, 2, 3)) + tag = (m.group(4) or "").lower() + rank = {"alpha": 0, "a": 0, "beta": 1, "b": 1, "rc": 2, "": 3}[tag] + num = int(m.group(5)) if m.group(5) else 0 + return (major, minor, patch, rank, num) +``` + +- [ ] **Step 2: Écrire `core/fields.py`** + +```python +"""Declarative input field. Used by ui.Form to prompt or validate a value.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Literal + +FieldKind = Literal["text", "int", "secret", "bool", "choice", "path"] + + +@dataclass(frozen=True) +class Field: + name: str + prompt: str + kind: FieldKind = "text" + default: Any = None + choices: tuple[str, ...] = () + help: str | None = None + validator: Callable[[Any], Any] | None = None + + @property + def flag(self) -> str: + return "--" + self.name.replace("_", "-") +``` + +- [ ] **Step 3: Retirer `current_version` de `core/utils.py`** + +Supprimer la fonction `current_version` (lignes ~197–214) et ajouter en tête des imports : + +```python +from core.version import current_version # noqa: F401 — re-export for legacy modules +``` + +`core/network.py` et `core/updater.py` importent `current_version` depuis `core.utils` ; le re-export les garde fonctionnels jusqu'à leur suppression. + +- [ ] **Step 4: Vérifier** + +Run: `uv run python -c "from core.version import *; print(current_version(), is_prerelease('26.09.0rc1'), parse_version('26.09.0rc1') < parse_version('26.09.0'), parse_version('26.10.0') > parse_version('26.9.9'))" && uv run python main.py --version` +Expected: `26.07.6 True True True` puis `Portabase CLI version: 26.07.6`. + +- [ ] **Step 5: Commit** + +```bash +git add core/version.py core/fields.py core/utils.py +git commit -m "feat(core): add version helpers and Field descriptor" +``` + +--- + +### Task 3 : `GlobalConfig` + +**Files:** +- Modify: `core/config.py` + +**Interfaces:** +- Produces: `GlobalConfig(path: Path = GLOBAL_CONFIG_FILE)` avec `get(key, default=None)`, `set(key, value)`, `all() -> dict`, `cache_dir: Path` (`~/.portabase/cache`) ; propriétés typées `update_channel: str | None`, `telemetry: bool`, `telemetry_endpoint: str | None`. +- Les fonctions module-level existantes restent (legacy). + +- [ ] **Step 1: Ajouter la classe en fin de `core/config.py`** + +```python +class GlobalConfig: + """~/.portabase/config.json. Unknown keys are preserved.""" + + KNOWN_KEYS = ("update_channel", "telemetry", "telemetry_endpoint") + + def __init__(self, path: Path = GLOBAL_CONFIG_FILE) -> None: + self.path = path + self.cache_dir = path.parent / "cache" + + def all(self) -> dict: + if not self.path.exists(): + return {} + try: + with open(self.path, encoding="utf-8") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + return {} + return data if isinstance(data, dict) else {} + + def get(self, key: str, default=None): + return self.all().get(key, default) + + def set(self, key: str, value) -> None: + data = self.all() + data[key] = value + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_suffix(".json.tmp") + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + os.replace(tmp, self.path) + + @property + def update_channel(self) -> str | None: + return self.get("update_channel") + + @property + def telemetry(self) -> bool: + return str(self.get("telemetry", "false")).lower() in ("1", "true", "yes") + + @property + def telemetry_endpoint(self) -> str | None: + return self.get("telemetry_endpoint") +``` + +Ajouter au-dessus des fonctions legacy le commentaire : + +```python +# --- Legacy helpers below: used by commands/agent.py, db.py, dashboard.py, core/updater.py. +# --- Removed in plan 4. New code uses GlobalConfig. +``` + +- [ ] **Step 2: Vérifier** + +Run: `uv run python -c " +from pathlib import Path; import tempfile +from core.config import GlobalConfig +c = GlobalConfig(Path(tempfile.mkdtemp())/'config.json') +print(c.all(), c.telemetry); c.set('update_channel','beta'); c.set('telemetry', True) +print(c.update_channel, c.telemetry, c.all())"` +Expected: `{} False` puis `beta True {'update_channel': 'beta', 'telemetry': True}`. + +- [ ] **Step 3: Commit** + +```bash +git add core/config.py +git commit -m "feat(core): add GlobalConfig class over ~/.portabase/config.json" +``` + +--- + +### Task 4 : `ui/theme.py` et composants d'affichage + +**Files:** +- Create: `ui/__init__.py` (vide pour l'instant, rempli Task 6) +- Create: `ui/theme.py` +- Create: `ui/components/__init__.py` (vide) +- Create: `ui/components/base.py` +- Create: `ui/components/hints.py` +- Create: `ui/components/message.py` +- Create: `ui/components/banner.py` +- Create: `ui/components/section.py` +- Create: `ui/components/status.py` +- Create: `ui/components/progress.py` + +**Interfaces:** +- Produces: `Component(console)` ; `Hint(console).random() -> str` ; `Message(console).success/info/warning(text)`, `.error(exc: PortabaseError, *, verbose: bool, unexpected: bool)` ; `Banner(console)()` ; `Section(console)(title)` ; `Status(console)(text) -> ContextManager` ; `Progress(console).download(description, total) -> ContextManager[Callable[[int], None]]`. + +- [ ] **Step 1: `ui/theme.py`** + +```python +"""Single source of visual tokens. Rich theme and questionary style derive from PALETTE.""" + +from __future__ import annotations + +from questionary import Style +from rich.theme import Theme + +PALETTE = { + "brand": "#ff6600", + "accent": "#5f00d7", + "info": "cyan", + "warning": "magenta", + "danger": "red", + "success": "green", + "muted": "grey50", +} + +RICH_THEME = Theme( + { + "info": f"dim {PALETTE['info']}", + "warning": PALETTE["warning"], + "danger": f"bold {PALETTE['danger']}", + "success": f"bold {PALETTE['success']}", + "title": f"bold white on {PALETTE['accent']}", + "key": f"bold {PALETTE['brand']}", + "value": "white", + "hint": f"italic {PALETTE['muted']}", + "brand": f"bold {PALETTE['brand']}", + } +) + +QUESTIONARY_STYLE = Style( + [ + ("qmark", f"fg:{PALETTE['brand']} bold"), + ("question", "bold"), + ("pointer", f"fg:{PALETTE['brand']} bold"), + ("highlighted", f"fg:black bg:{PALETTE['brand']} bold"), + ("selected", f"fg:{PALETTE['brand']} bold"), + ("answer", f"fg:{PALETTE['brand']}"), + ] +) + +QUESTIONARY_STYLE_PLAIN = Style([]) +``` + +- [ ] **Step 2: `ui/components/base.py`** + +```python +from __future__ import annotations + +from rich.console import Console + + +class Component: + """Stateless renderable bound to a console. Instantiate per call.""" + + def __init__(self, console: Console) -> None: + self.console = console +``` + +- [ ] **Step 3: `ui/components/hints.py`** + +Reprendre la liste `HINTS` de `core/utils.py:42-66` telle quelle. + +```python +from __future__ import annotations + +import random + +from ui.components.base import Component + +HINTS = [ + "The Edge Key contains the connection details for dashboard and agent communication.", + "Portabase uses Docker Compose to isolate your databases.", + "You can list all configured databases using 'portabase db list '.", + "Running 'portabase stop' will gracefully shut down your containers.", + "The agent polls the github for configuration updates.", + "Logs can be viewed in real-time with 'portabase logs '.", + "Custom environment variables can be added to the generated .env file.", + "Need to update? Use 'portabase update' to get the latest version.", + "You can add multiple databases to a single agent during setup.", + "Portabase Dashboard provides a web interface to manage your infrastructure.", + "Is Docker not running? The CLI will offer to start it for you!", + "All configurations are stored locally in the component's folder.", + "The 'portabase restart' command is useful after manual .env modifications.", + "Portabase is open-source! Check our GitHub to contribute.", + "Using the --start flag with 'agent' or 'dashboard' skips the final prompt.", + "Internal databases are automatically backed up when using volumes.", + "The dashboard requires a PostgreSQL database to store its own data.", + "You can change the update channel to 'beta' in the config for early features.", + "Portabase network ensures secure communication between your containers.", + "Lost your Edge Key? You can find it in the dashboard.", + "The 'portabase uninstall' command safely removes containers and their data.", + "Use 'portabase --version' to check your current installation details.", + "The 'databases.json' file keeps track of all managed database instances.", +] + + +class Hint(Component): + def random(self) -> str: + return f"[hint]{random.choice(HINTS)}[/hint]" + + def __call__(self, text: str | None = None) -> None: + self.console.print(f"[hint]{text}[/hint]" if text else self.random()) +``` + +- [ ] **Step 4: `ui/components/message.py`** + +```python +from __future__ import annotations + +import traceback + +from core.errors import PortabaseError +from ui.components.base import Component + + +class Message(Component): + def success(self, text: str) -> None: + self.console.print(f"[success]✔ {text}[/success]") + + def info(self, text: str) -> None: + self.console.print(f"[info]ℹ {text}[/info]") + + def warning(self, text: str) -> None: + self.console.print(f"[warning]⚠ {text}[/warning]") + + def error(self, exc: PortabaseError, *, verbose: bool = False, unexpected: bool = False) -> None: + label = "Unexpected error" if unexpected else "Error" + self.console.print(f"[danger]✖ {label}:[/danger] {exc.message}") + if exc.hint: + self.console.print(f" [hint]↳ {exc.hint}[/hint]") + if verbose or unexpected: + self.console.print(f" [hint]code: {exc.code}[/hint]") + if verbose and exc.cause is not None: + self.console.print(f" [hint]cause: {type(exc.cause).__name__}: {exc.cause}[/hint]") + if verbose: + self.console.print("".join(traceback.format_exception(exc)), highlight=False, markup=False) +``` + +- [ ] **Step 5: `ui/components/banner.py`** + +```python +from __future__ import annotations + +from rich.align import Align + +from ui.components.base import Component +from ui.components.hints import Hint + +BANNER = """ +[brand]█▀█ █▀█ █▀█ ▀█▀ ▄▀█ █▄▄ ▄▀█ █▀ █▀▀[/brand] +[brand]█▀▀ █▄█ █▀▄ █ █▀█ █▄█ █▀█ ▄█ ██▄[/brand] +[hint]Deploy your infrastructure anywhere.[/hint] +""" + + +class Banner(Component): + def __call__(self) -> None: + self.console.print(Align.center(BANNER)) + self.console.print(Align.center(Hint(self.console).random() + "\n")) +``` + +- [ ] **Step 6: `ui/components/section.py`** + +```python +from __future__ import annotations + +from rich.panel import Panel + +from ui.components.base import Component + + +class Section(Component): + def __call__(self, title: str) -> None: + self.console.print("") + self.console.print(Panel(f"[bold]{title}[/bold]", style="cyan", expand=False)) +``` + +- [ ] **Step 7: `ui/components/status.py`** + +```python +from __future__ import annotations + +from contextlib import AbstractContextManager + +from ui.components.base import Component +from ui.components.hints import Hint + + +class Status(Component): + def __call__(self, text: str, *, spinner: str = "dots") -> AbstractContextManager: + message = f"[bold magenta]{text}[/bold magenta]\n{Hint(self.console).random()}" + return self.console.status(message, spinner=spinner) +``` + +- [ ] **Step 8: `ui/components/progress.py`** + +```python +from __future__ import annotations + +from collections.abc import Callable, Iterator +from contextlib import contextmanager + +from rich.progress import ( + BarColumn, + DownloadColumn, + Progress as RichProgress, + SpinnerColumn, + TextColumn, + TransferSpeedColumn, +) + +from ui.components.base import Component +from ui.components.hints import Hint + + +class Progress(Component): + @contextmanager + def download(self, description: str, total: int) -> Iterator[Callable[[int], None]]: + """Yields an advance(n_bytes) callable.""" + with RichProgress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}\n" + Hint(self.console).random()), + BarColumn(), + DownloadColumn(), + TransferSpeedColumn(), + console=self.console, + ) as progress: + task = progress.add_task(description, total=total or None) + yield lambda n: progress.update(task, advance=n) +``` + +- [ ] **Step 9: Vérifier le rendu** + +Run: `uv run python -c " +from rich.console import Console +from ui.theme import RICH_THEME +from ui.components.message import Message +from ui.components.banner import Banner +from ui.components.section import Section +from core.errors import DockerError +c = Console(theme=RICH_THEME) +Banner(c)(); Section(c)('Database Setup') +m = Message(c); m.success('ok'); m.info('note'); m.warning('careful') +m.error(DockerError('daemon down', hint='run: sudo systemctl start docker')) +m.error(DockerError('daemon down', hint='x', cause=RuntimeError('boom')), verbose=True)"` +Expected: bannière orange, panneau cyan, quatre messages avec icônes ✔ ℹ ⚠ ✖, hint indenté, puis le bloc verbose avec `code: E_DOCKER`, `cause: RuntimeError: boom` et une traceback. + +- [ ] **Step 10: Commit** + +```bash +git add ui/ +git commit -m "feat(ui): add theme tokens and display components" +``` + +--- + +### Task 5 : `ui/components/prompt.py` et `ui/form.py` + +**Files:** +- Create: `ui/components/prompt.py` +- Create: `ui/form.py` + +**Interfaces:** +- Consumes: `Field` (Task 2), `UserAbort`/`ValidationError` (Task 1), `QUESTIONARY_STYLE` (Task 4). +- Produces: `Prompt(console, style)` avec `text/integer/secret/confirm/select/path` renvoyant `None` sur Ctrl-C ; `Form(prompt, non_interactive)` avec `ask(field, value)`, `collect(fields, values) -> dict`, raccourcis `text/integer/secret/confirm/choice`. + +- [ ] **Step 1: `ui/components/prompt.py`** + +```python +from __future__ import annotations + +from collections.abc import Sequence + +import questionary +from questionary import Style +from rich.console import Console + +from ui.components.base import Component + + +class Prompt(Component): + """Thin wrapper over questionary. Every method returns None when the user aborts (Ctrl-C).""" + + def __init__(self, console: Console, style: Style) -> None: + super().__init__(console) + self.style = style + + def text(self, message: str, *, default: str | None = None) -> str | None: + return questionary.text(message, default=default or "", style=self.style).ask() + + def integer(self, message: str, *, default: int | None = None) -> int | None: + answer = questionary.text( + message, + default="" if default is None else str(default), + validate=lambda v: v.strip().lstrip("-").isdigit() or "Enter a whole number", + style=self.style, + ).ask() + return None if answer is None else int(answer) + + def secret(self, message: str) -> str | None: + return questionary.password(message, style=self.style).ask() + + def confirm(self, message: str, *, default: bool = False) -> bool | None: + return questionary.confirm(message, default=default, style=self.style).ask() + + def select(self, message: str, choices: Sequence[str], *, default: str | None = None) -> str | None: + return questionary.select(message, choices=list(choices), default=default, style=self.style).ask() + + def path(self, message: str, *, default: str | None = None) -> str | None: + return questionary.path(message, default=default or "", style=self.style).ask() +``` + +- [ ] **Step 2: `ui/form.py`** + +```python +"""Flag → prompt → default → error. The only place that knows about non-interactive mode.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import Any + +from core.errors import UserAbort, ValidationError +from core.fields import Field +from ui.components.prompt import Prompt + +_TRUE = {"1", "true", "yes", "y", "on"} +_FALSE = {"0", "false", "no", "n", "off"} + + +class Form: + def __init__(self, prompt: Prompt, non_interactive: bool) -> None: + self.prompt = prompt + self.non_interactive = non_interactive + self._askers: dict[str, Callable[[Field], Any]] = { + "text": lambda f: self.prompt.text(f.prompt, default=f.default), + "int": lambda f: self.prompt.integer(f.prompt, default=f.default), + "secret": lambda f: self.prompt.secret(f.prompt), + "bool": lambda f: self.prompt.confirm(f.prompt, default=bool(f.default)), + "choice": lambda f: self.prompt.select(f.prompt, f.choices, default=f.default), + "path": lambda f: self.prompt.path(f.prompt, default=f.default), + } + + # ---- core ------------------------------------------------------------- + + def ask(self, field: Field, value: Any | None = None) -> Any: + if value is not None: + return self._coerce_and_validate(field, value) + if self.non_interactive: + if field.default is not None: + return self._coerce_and_validate(field, field.default) + raise ValidationError( + f"Missing {field.flag}", + hint=f"Required in non-interactive mode: {field.prompt}", + ) + return self._ask_until_valid(field) + + def collect(self, fields: Sequence[Field], values: dict[str, Any]) -> dict[str, Any]: + return {f.name: self.ask(f, values.get(f.name)) for f in fields} + + # ---- shortcuts -------------------------------------------------------- + + def text(self, prompt: str, *, value=None, default=None, validator=None, name="value") -> str: + return self.ask(Field(name, prompt, "text", default=default, validator=validator), value) + + def integer(self, prompt: str, *, value=None, default=None, validator=None, name="value") -> int: + return self.ask(Field(name, prompt, "int", default=default, validator=validator), value) + + def secret(self, prompt: str, *, value=None, validator=None, name="value") -> str: + return self.ask(Field(name, prompt, "secret", validator=validator), value) + + def confirm(self, prompt: str, *, value=None, default: bool = False, name="value") -> bool: + return self.ask(Field(name, prompt, "bool", default=default), value) + + def choice(self, prompt: str, choices: Sequence[str], *, value=None, default=None, name="value") -> str: + return self.ask(Field(name, prompt, "choice", default=default, choices=tuple(choices)), value) + + # ---- internals -------------------------------------------------------- + + def _ask_until_valid(self, field: Field) -> Any: + if field.help: + self.prompt.console.print(f"[info]ℹ {field.help}[/info]") + while True: + answer = self._askers[field.kind](field) + if answer is None: + raise UserAbort() + try: + return self._coerce_and_validate(field, answer) + except ValidationError as e: + self.prompt.console.print(f"[danger]✖ {e.message}[/danger]") + + def _coerce_and_validate(self, field: Field, value: Any) -> Any: + value = self._coerce(field, value) + if field.kind == "choice" and value not in field.choices: + raise ValidationError( + f"Invalid value for {field.flag}: {value!r}", + hint="Choices: " + ", ".join(field.choices), + ) + if field.validator is not None: + value = field.validator(value) # raises ValidationError + return value + + @staticmethod + def _coerce(field: Field, value: Any) -> Any: + if field.kind == "int" and not isinstance(value, int): + try: + return int(str(value).strip()) + except ValueError as e: + raise ValidationError(f"{field.flag} must be a whole number, got {value!r}") from e + if field.kind == "bool" and not isinstance(value, bool): + s = str(value).strip().lower() + if s in _TRUE: + return True + if s in _FALSE: + return False + raise ValidationError(f"{field.flag} must be true or false, got {value!r}") + if field.kind in ("text", "secret", "path", "choice"): + return str(value) + return value +``` + +- [ ] **Step 3: Vérifier le mode non-interactif (sans terminal)** + +Run: `uv run python -c " +from rich.console import Console +from ui.theme import QUESTIONARY_STYLE +from ui.components.prompt import Prompt +from ui.form import Form +from core.fields import Field +from core.errors import ValidationError +f = Form(Prompt(Console(), QUESTIONARY_STYLE), non_interactive=True) +print(f.text('Timezone', value=None, default='UTC'), f.integer('Polling', value='7'), f.confirm('Gateway?', value='yes')) +print(f.collect([Field('engine','Engine','choice',choices=('a','b')), Field('port','Port','int',default=5432)], {'engine':'a'})) +try: f.text('Edge key') +except ValidationError as e: print('OK:', e.message, '|', e.hint) +try: f.choice('Mode', ['new','existing'], value='bogus') +except ValidationError as e: print('OK:', e.message, '|', e.hint)"` +Expected : +``` +UTC 7 True +{'engine': 'a', 'port': 5432} +OK: Missing --value | Required in non-interactive mode: Edge key +OK: Invalid value for --value: 'bogus' | Choices: new, existing +``` + +- [ ] **Step 4: Vérifier le mode interactif (terminal requis)** + +Run: `uv run python -c " +from rich.console import Console +from ui.theme import QUESTIONARY_STYLE +from ui.components.prompt import Prompt +from ui.form import Form +f = Form(Prompt(Console(), QUESTIONARY_STYLE), non_interactive=False) +print(f.choice('Mode', ['new','existing'], default='new')) +print(f.integer('Port', default=5432))"` +Répondre aux deux prompts. Puis relancer et faire Ctrl-C au premier prompt. +Expected: valeurs saisies affichées ; sur Ctrl-C, traceback se terminant par `core.errors.UserAbort: Cancelled.` (le catcher n'est pas encore branché — attendu). + +- [ ] **Step 5: Commit** + +```bash +git add ui/components/prompt.py ui/form.py +git commit -m "feat(ui): add questionary Prompt and Form with non-interactive resolution" +``` + +--- + +### Task 6 : Façade `UI` + +**Files:** +- Modify: `ui/__init__.py` + +**Interfaces:** +- Produces: `UI(console=None, *, non_interactive=False, verbose=False, no_color=False)` ; `configure(**kwargs)` ; `banner()`, `success/info/warning(text)`, `error(exc, unexpected=False)`, `hint(text=None)`, `section(title)`, `status(text)`, `progress()`, `confirm(q, default=False, value=None) -> bool`, `form() -> Form`, `print(renderable)`. Attribut `console`. + +- [ ] **Step 1: Écrire la façade** + +```python +"""Facade: the only thing `commands/` imports from ui. Rich and questionary stay inside ui/.""" + +from __future__ import annotations + +from rich.console import Console + +from core.errors import PortabaseError +from ui.components.banner import Banner +from ui.components.hints import Hint +from ui.components.message import Message +from ui.components.progress import Progress +from ui.components.prompt import Prompt +from ui.components.section import Section +from ui.components.status import Status +from ui.form import Form +from ui.theme import QUESTIONARY_STYLE, QUESTIONARY_STYLE_PLAIN, RICH_THEME + + +class UI: + def __init__( + self, + console: Console | None = None, + *, + non_interactive: bool = False, + verbose: bool = False, + no_color: bool = False, + ) -> None: + self.non_interactive = non_interactive + self.verbose = verbose + self.no_color = no_color + self.console = console or self._make_console() + + def configure(self, *, non_interactive: bool | None = None, verbose: bool | None = None, no_color: bool | None = None) -> None: + if non_interactive is not None: + self.non_interactive = non_interactive + if verbose is not None: + self.verbose = verbose + if no_color is not None and no_color != self.no_color: + self.no_color = no_color + self.console = self._make_console() + + def _make_console(self) -> Console: + return Console(theme=RICH_THEME, no_color=self.no_color) + + # ---- output ----------------------------------------------------------- + + def print(self, renderable) -> None: + self.console.print(renderable) + + def banner(self) -> None: + Banner(self.console)() + + def success(self, text: str) -> None: + Message(self.console).success(text) + + def info(self, text: str) -> None: + Message(self.console).info(text) + + def warning(self, text: str) -> None: + Message(self.console).warning(text) + + def error(self, exc: PortabaseError, *, unexpected: bool = False) -> None: + Message(self.console).error(exc, verbose=self.verbose, unexpected=unexpected) + + def hint(self, text: str | None = None) -> None: + Hint(self.console)(text) + + def section(self, title: str) -> None: + Section(self.console)(title) + + def status(self, text: str): + return Status(self.console)(text) + + def progress(self) -> Progress: + return Progress(self.console) + + # ---- input ------------------------------------------------------------ + + def form(self) -> Form: + style = QUESTIONARY_STYLE_PLAIN if self.no_color else QUESTIONARY_STYLE + return Form(Prompt(self.console, style), self.non_interactive) + + def confirm(self, question: str, *, default: bool = False, value: bool | None = None) -> bool: + return self.form().confirm(question, value=value, default=default) +``` + +- [ ] **Step 2: Vérifier** + +Run: `uv run python -c " +from ui import UI +ui = UI(non_interactive=True) +ui.banner(); ui.section('Test'); ui.success('a'); ui.warning('b'); ui.hint() +print('confirm default:', ui.confirm('Really?', default=False)) +with ui.status('Working...'): import time; time.sleep(0.5) +ui.configure(no_color=True); ui.success('no color')"` +Expected: rendu, `confirm default: False` sans prompt, spinner 0,5 s, dernière ligne sans couleur. + +- [ ] **Step 3: Commit** + +```bash +git add ui/__init__.py +git commit -m "feat(ui): add UI facade" +``` + +--- + +### Task 7 : `services/http.py` et `services/docker.py` + +**Files:** +- Create: `services/__init__.py` (vide) +- Create: `services/http.py` +- Create: `services/docker.py` + +**Interfaces:** +- Produces: + - `HttpClient(timeout=10.0)` : `get_json(url) -> Any`, `get_text(url) -> str`, `download(url, dest: Path, on_progress: Callable[[int], None] | None = None, *, timeout=30.0) -> int` (octets), `head_content_length(url) -> int | None`. Lèvent `NetworkError`. + - `DockerRunner(docker_bin: str | None = None)` : `available() -> bool`, `daemon_running() -> bool`, `start_daemon() -> bool`, `ensure_network(name)`, `compose(cwd, args, *, check=True, capture=False) -> subprocess.CompletedProcess`, `project_name(cwd) -> str`. Lèvent `DockerError`. + +- [ ] **Step 1: `services/http.py`** + +```python +"""requests wrapper. Every failure becomes NetworkError; nothing else leaks out.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import requests + +from core.errors import NetworkError + +_HINT = "Check your internet connection or proxy settings." + + +class HttpClient: + def __init__(self, timeout: float = 10.0, user_agent: str = "portabase-cli") -> None: + self.timeout = timeout + self.session = requests.Session() + self.session.headers["User-Agent"] = user_agent + + def get_json(self, url: str) -> Any: + try: + r = self.session.get(url, timeout=self.timeout) + r.raise_for_status() + return r.json() + except requests.RequestException as e: + raise NetworkError(f"GET {url} failed: {e}", hint=_HINT, cause=e) from e + except ValueError as e: + raise NetworkError(f"GET {url}: response is not JSON", cause=e) from e + + def get_text(self, url: str) -> str: + try: + r = self.session.get(url, timeout=self.timeout) + r.raise_for_status() + return r.text + except requests.RequestException as e: + raise NetworkError(f"GET {url} failed: {e}", hint=_HINT, cause=e) from e + + def status(self, url: str) -> int: + """HTTP status without raising on 4xx/5xx. Network failure still raises.""" + try: + return self.session.get(url, timeout=self.timeout, stream=True).status_code + except requests.RequestException as e: + raise NetworkError(f"GET {url} failed: {e}", hint=_HINT, cause=e) from e + + def download( + self, + url: str, + dest: Path, + on_progress: Callable[[int], None] | None = None, + *, + timeout: float = 30.0, + ) -> int: + written = 0 + try: + with self.session.get(url, stream=True, timeout=timeout) as r: + r.raise_for_status() + with open(dest, "wb") as f: + for chunk in r.iter_content(chunk_size=64 * 1024): + if not chunk: + continue + f.write(chunk) + written += len(chunk) + if on_progress: + on_progress(len(chunk)) + except requests.RequestException as e: + dest.unlink(missing_ok=True) + raise NetworkError(f"Download of {url} failed: {e}", hint=_HINT, cause=e) from e + return written + + def content_length(self, url: str) -> int | None: + try: + r = self.session.head(url, timeout=self.timeout, allow_redirects=True) + value = r.headers.get("content-length") + return int(value) if value else None + except (requests.RequestException, ValueError): + return None +``` + +- [ ] **Step 2: `services/docker.py`** + +Reprend `core/docker.py` + `check_system`/`start_docker` de `core/utils.py`, sans aucune sortie terminal. + +```python +"""Docker CLI runner. No terminal output; callers decide what to show.""" + +from __future__ import annotations + +import platform +import shutil +import subprocess +import time +from pathlib import Path + +from core.errors import DockerError +from core.utils import slugify_project_name + +_START_COMMANDS = { + "Linux": ["sudo", "systemctl", "start", "docker"], + "Darwin": ["open", "--background", "-a", "Docker"], + "Windows": ["cmd", "/c", "start", "docker"], +} + + +class DockerRunner: + def __init__(self, docker_bin: str | None = None) -> None: + self._bin = docker_bin + + @property + def binary(self) -> str: + if self._bin is None: + found = shutil.which("docker") + if found is None: + raise DockerError( + "Docker not found (binary missing).", + hint="Install Docker: https://docs.docker.com/get-docker/", + ) + self._bin = found + return self._bin + + def available(self) -> bool: + return shutil.which("docker") is not None + + def daemon_running(self) -> bool: + try: + subprocess.run( + [self.binary, "info"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True + ) + return True + except (subprocess.CalledProcessError, OSError): + return False + + def start_daemon(self, *, wait_seconds: int = 20) -> bool: + cmd = _START_COMMANDS.get(platform.system()) + if cmd is None: + return False + try: + subprocess.run(cmd, check=True) + except (subprocess.CalledProcessError, OSError) as e: + raise DockerError(f"Failed to start Docker: {e}", cause=e) from e + deadline = time.monotonic() + wait_seconds + while time.monotonic() < deadline: + if self.daemon_running(): + return True + time.sleep(2) + return False + + def ensure_network(self, name: str) -> None: + inspect = subprocess.run( + [self.binary, "network", "inspect", name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if inspect.returncode == 0: + return + try: + subprocess.run([self.binary, "network", "create", name], stdout=subprocess.DEVNULL, check=True) + except subprocess.CalledProcessError as e: + raise DockerError(f"Could not create Docker network '{name}'.", cause=e) from e + + @staticmethod + def project_name(cwd: Path) -> str: + return slugify_project_name(cwd.resolve().name) + + def compose( + self, + cwd: Path, + args: list[str], + *, + check: bool = True, + capture: bool = False, + ) -> subprocess.CompletedProcess: + cmd = [self.binary, "compose", "-p", self.project_name(cwd), *args] + try: + return subprocess.run( + cmd, + cwd=cwd, + check=check, + capture_output=capture, + text=capture, + ) + except subprocess.CalledProcessError as e: + raise DockerError( + f"docker compose {' '.join(args)} failed (exit {e.returncode}).", + hint=f"Run it manually in {cwd} to see the full output.", + cause=e, + ) from e +``` + +- [ ] **Step 3: Vérifier** + +Run: `uv run python -c " +from services.http import HttpClient +from services.docker import DockerRunner +from core.errors import NetworkError, DockerError +h = HttpClient(timeout=5) +print(type(h.get_json('https://api.github.com/repos/Portabase/cli')).__name__) +try: h.get_json('https://127.0.0.1:1/nope') +except NetworkError as e: print('NetworkError OK:', e.code) +d = DockerRunner(); print('docker available:', d.available(), '| daemon:', d.available() and d.daemon_running()) +try: DockerRunner(docker_bin='/nonexistent').compose(__import__('pathlib').Path('.'), ['version']) +except (DockerError, OSError) as e: print('error path OK:', type(e).__name__)"` +Expected: `dict`, `NetworkError OK: E_NETWORK`, état Docker local, `error path OK: FileNotFoundError` ou `DockerError` (les deux acceptables ici ; `OSError` est traité au niveau commande, Task 9). + +- [ ] **Step 4: Commit** + +```bash +git add services/ +git commit -m "feat(services): add HttpClient and DockerRunner" +``` + +--- + +### Task 8 : `services/telemetry.py` + +**Files:** +- Create: `services/telemetry.py` + +**Interfaces:** +- Produces: `Telemetry` ABC (`session(**attrs)`, `span(name, **attrs)`, `event(name, **attrs)`, `error(exc, unexpected=False)`, `flush()`) ; `NoopTelemetry` ; `ConsoleTelemetry(stream=sys.stderr)` ; `TelemetryHub(inner)` avec `.set(inner)` ; `TelemetryFactory.build(config: GlobalConfig, *, debug: bool) -> TelemetryHub`. + +- [ ] **Step 1: Écrire le module** + +```python +"""Telemetry contract. Noop by default; OTel exporter can be plugged later without touching callers. + +Never record: agent names, paths, keys, credentials, file contents. +""" + +from __future__ import annotations + +import sys +import time +from abc import ABC, abstractmethod +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Any, TextIO + +from core.config import GlobalConfig + + +class Telemetry(ABC): + @abstractmethod + def session(self, **attrs: Any): + """Context manager: root span for one CLI invocation.""" + + @abstractmethod + def span(self, name: str, **attrs: Any): + """Context manager: child span.""" + + @abstractmethod + def event(self, name: str, **attrs: Any) -> None: ... + + @abstractmethod + def error(self, exc: BaseException, *, unexpected: bool = False) -> None: ... + + def flush(self) -> None: + return None + + +class NoopTelemetry(Telemetry): + @contextmanager + def session(self, **attrs: Any) -> Iterator[None]: + yield + + @contextmanager + def span(self, name: str, **attrs: Any) -> Iterator[None]: + yield + + def event(self, name: str, **attrs: Any) -> None: + return None + + def error(self, exc: BaseException, *, unexpected: bool = False) -> None: + return None + + +class ConsoleTelemetry(Telemetry): + """--debug: prints spans and events to stderr. Development aid, not an exporter.""" + + def __init__(self, stream: TextIO = sys.stderr) -> None: + self.stream = stream + self._depth = 0 + + def _log(self, line: str) -> None: + self.stream.write(" " * self._depth + f"[telemetry] {line}\n") + self.stream.flush() + + @contextmanager + def session(self, **attrs: Any) -> Iterator[None]: + with self.span("session", **attrs): + yield + + @contextmanager + def span(self, name: str, **attrs: Any) -> Iterator[None]: + self._log(f"▶ {name} {attrs}") + self._depth += 1 + start = time.perf_counter() + try: + yield + finally: + self._depth -= 1 + self._log(f"◀ {name} {time.perf_counter() - start:.3f}s") + + def event(self, name: str, **attrs: Any) -> None: + self._log(f"• {name} {attrs}") + + def error(self, exc: BaseException, *, unexpected: bool = False) -> None: + code = getattr(exc, "code", type(exc).__name__) + self._log(f"✖ error code={code} unexpected={unexpected}") + + +class TelemetryHub(Telemetry): + """Delegates to a swappable implementation. Commands hold the hub; main swaps the inner.""" + + def __init__(self, inner: Telemetry | None = None) -> None: + self.inner: Telemetry = inner or NoopTelemetry() + + def set(self, inner: Telemetry) -> None: + self.inner = inner + + def session(self, **attrs: Any): + return self.inner.session(**attrs) + + def span(self, name: str, **attrs: Any): + return self.inner.span(name, **attrs) + + def event(self, name: str, **attrs: Any) -> None: + self.inner.event(name, **attrs) + + def error(self, exc: BaseException, *, unexpected: bool = False) -> None: + self.inner.error(exc, unexpected=unexpected) + + def flush(self) -> None: + self.inner.flush() + + +class TelemetryFactory: + @staticmethod + def build(config: GlobalConfig, *, debug: bool = False) -> TelemetryHub: + if debug: + return TelemetryHub(ConsoleTelemetry()) + # Opt-in and endpoint present → OTel exporter (future plan). Until then: noop. + return TelemetryHub(NoopTelemetry()) +``` + +- [ ] **Step 2: Vérifier** + +Run: `uv run python -c " +from services.telemetry import * +from core.config import GlobalConfig +hub = TelemetryFactory.build(GlobalConfig(), debug=True) +with hub.session(cli_version='x'): + with hub.span('command.start', command='start'): + hub.event('compose', args='up') + hub.error(RuntimeError('boom'), unexpected=True) +hub.set(NoopTelemetry()) +with hub.span('silent'): pass +print('ok')"` +Expected: lignes `[telemetry]` imbriquées sur stderr pour session/command/event/error, rien pour `silent`, puis `ok`. + +- [ ] **Step 3: Commit** + +```bash +git add services/telemetry.py +git commit -m "feat(services): add Telemetry contract with noop, console and hub implementations" +``` + +--- + +### Task 9 : `commands/base.py` — `Command`, `CommandGroup`, `LegacyCommand` + +**Files:** +- Create: `commands/base.py` + +**Interfaces:** +- Consumes: `UI`, `Telemetry`, `DockerRunner`, erreurs. +- Produces: + - `Command(ui, telemetry)` : attributs de classe `name`, `help`, `panel`, `no_args_is_help=False` ; `register(app)` ; `run(...)` abstraite ; helpers `require_docker(docker)`, `require_project_dir(path) -> Path`. + - `CommandGroup(ui, telemetry)` : `name`, `help`, `commands: list[Command]`, `typer() -> typer.Typer`, `register(app)`. + - `LegacyCommand(ui, telemetry, fn, *, name, help, panel, no_args_is_help=True)`. + +- [ ] **Step 1: Écrire le module** + +```python +"""Command base classes. Typer registers bound `run` methods; dependencies come via constructors.""" + +from __future__ import annotations + +import functools +from abc import ABC, abstractmethod +from collections.abc import Callable +from pathlib import Path + +import typer + +from core.errors import ConfigError, DockerError, UserAbort +from services.docker import DockerRunner +from services.telemetry import Telemetry +from ui import UI + + +class Command(ABC): + name: str + help: str + panel: str = "General" + no_args_is_help: bool = False + + def __init__(self, ui: UI, telemetry: Telemetry) -> None: + self.ui = ui + self.telemetry = telemetry + + # ---- registration ----------------------------------------------------- + + def register(self, app: typer.Typer) -> None: + app.command( + self.name, + help=self.help, + rich_help_panel=self.panel, + no_args_is_help=self.no_args_is_help, + )(self._traced(self.run)) + + def _traced(self, fn: Callable) -> Callable: + @functools.wraps(fn) + def wrapper(*args, **kwargs): + with self.telemetry.span(f"command.{self.name}"): + return fn(*args, **kwargs) + + return wrapper + + @abstractmethod + def run(self, *args, **kwargs) -> None: ... + + # ---- shared helpers --------------------------------------------------- + + def require_docker(self, docker: DockerRunner) -> None: + """Binary present and daemon up, offering to start it when interactive.""" + if not docker.available(): + raise DockerError( + "Docker not found (binary missing).", + hint="Install Docker: https://docs.docker.com/get-docker/", + ) + if docker.daemon_running(): + return + self.ui.warning("Docker is installed but the daemon is not running.") + if self.ui.confirm("Do you want to try starting Docker?", default=False): + with self.ui.status("Waiting for Docker to start..."): + started = docker.start_daemon() + if started: + self.ui.success("Docker started successfully.") + return + raise DockerError("Docker is required to continue.", hint="Start the Docker daemon and retry.") + + @staticmethod + def require_project_dir(path: Path) -> Path: + path = path.resolve() + if not (path / "docker-compose.yml").exists(): + raise ConfigError( + f"No Portabase configuration found in: {path}", + hint="Expected a docker-compose.yml created by 'portabase agent' or 'portabase dashboard'.", + ) + return path + + def confirm_or_abort(self, question: str, *, default: bool = False, value: bool | None = None) -> None: + if not self.ui.confirm(question, default=default, value=value): + raise UserAbort() + + +class CommandGroup: + name: str + help: str + panel: str = "General" + + def __init__(self, ui: UI, telemetry: Telemetry) -> None: + self.ui = ui + self.telemetry = telemetry + + @property + @abstractmethod + def commands(self) -> list[Command]: ... + + def typer(self) -> typer.Typer: + sub = typer.Typer(help=self.help, no_args_is_help=True) + for cmd in self.commands: + cmd.register(sub) + return sub + + def register(self, app: typer.Typer) -> None: + app.add_typer(self.typer(), name=self.name, rich_help_panel=self.panel) + + +class LegacyCommand(Command): + """Adapter for the pre-refactor function-style commands. Removed in plan 4.""" + + def __init__( + self, + ui: UI, + telemetry: Telemetry, + fn: Callable, + *, + name: str, + help: str, + panel: str, + no_args_is_help: bool = True, + ) -> None: + super().__init__(ui, telemetry) + self.name, self.help, self.panel, self.no_args_is_help = name, help, panel, no_args_is_help + self._fn = fn + + def register(self, app: typer.Typer) -> None: + app.command( + self.name, + help=self.help, + rich_help_panel=self.panel, + no_args_is_help=self.no_args_is_help, + )(self._traced(self._fn)) + + def run(self, *args, **kwargs) -> None: + return self._fn(*args, **kwargs) +``` + +Note : `_traced` utilise `functools.wraps`, donc Typer voit la signature de la fonction d'origine (`__wrapped__`) — c'est ce qui permet d'envelopper sans casser l'introspection des paramètres. + +- [ ] **Step 2: Vérifier l'introspection Typer à travers `_traced`** + +Run: `uv run python -c " +from typing import Annotated +import typer +from commands.base import Command +from ui import UI +from services.telemetry import NoopTelemetry +class Hello(Command): + name, help, panel = 'hello', 'Say hello', 'Test' + def run(self, name: Annotated[str, typer.Argument()], loud: Annotated[bool, typer.Option('--loud')] = False): + print('hello', name.upper() if loud else name) +app = typer.Typer(add_completion=False) +@app.callback() +def root(): pass +Hello(UI(), NoopTelemetry()).register(app) +app(['hello', 'bob', '--loud'], standalone_mode=False)"` +Expected: `hello BOB`. + +- [ ] **Step 3: Commit** + +```bash +git add commands/base.py +git commit -m "feat(commands): add Command, CommandGroup and LegacyCommand base classes" +``` + +--- + +### Task 10 : `services/updater.py` + +**Files:** +- Create: `services/updater.py` + +**Interfaces:** +- Consumes: `HttpClient`, `GlobalConfig`, `core.version`. +- Produces: `Release(tag, assets: dict[str, str], prerelease: bool)` ; `UpdateChecker(http, config, current: str)` : `include_prerelease -> bool`, `latest(force=False) -> Release | None` (cache 24 h, `None` si réseau KO), `available() -> str | None` (tag plus récent ou `None`) ; `Updater(http, current: str)` : `asset_name() -> str`, `target_path() -> Path`, `download(release, on_progress) -> Path` (vérifie sha256 via `checksums.txt`), `install(tmp: Path, target: Path) -> None`. + +- [ ] **Step 1: Écrire le module** + +```python +"""Update check (notify only) and manual update with checksum verification.""" + +from __future__ import annotations + +import hashlib +import json +import os +import platform +import shutil +import subprocess +import sys +import tempfile +import time +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +from core.config import GlobalConfig +from core.errors import NetworkError, UpdateError +from core.version import UNKNOWN, is_prerelease, parse_version +from services.http import HttpClient + +GITHUB_REPO = "Portabase/cli" +RELEASES_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases" +CACHE_TTL = 24 * 3600 + + +@dataclass(frozen=True) +class Release: + tag: str + assets: dict[str, str] # name -> browser_download_url + prerelease: bool + + @classmethod + def from_api(cls, data: dict) -> Release: + return cls( + tag=str(data.get("tag_name", "")).lstrip("v"), + assets={a["name"]: a["browser_download_url"] for a in data.get("assets", [])}, + prerelease=bool(data.get("prerelease", False)), + ) + + +def platform_asset_name() -> str: + system = platform.system().lower() + system = "macos" if system == "darwin" else system + machine = platform.machine().lower() + arch = "arm64" if machine in ("arm64", "aarch64") else "amd64" + name = f"portabase_{system}_{arch}" + return name + ".exe" if system == "windows" else name + + +def is_frozen() -> bool: + return bool(getattr(sys, "frozen", False)) + + +class UpdateChecker: + def __init__(self, http: HttpClient, config: GlobalConfig, current: str) -> None: + self.http = http + self.config = config + self.current = current + self.cache_file = config.cache_dir / "release.json" + + @property + def include_prerelease(self) -> bool: + channel = self.config.update_channel + if channel: + return channel == "beta" + return is_prerelease(self.current) + + def fetch_latest(self) -> Release | None: + """Network call. Returns None when nothing is published.""" + if self.include_prerelease: + releases = self.http.get_json(RELEASES_URL) + return Release.from_api(releases[0]) if releases else None + return Release.from_api(self.http.get_json(f"{RELEASES_URL}/latest")) + + def latest(self, *, force: bool = False) -> Release | None: + """Cached 24h. Returns None on any network failure — never raises.""" + if not force: + cached = self._read_cache() + if cached is not None: + return cached + try: + release = self.fetch_latest() + except NetworkError: + return None + if release is not None: + self._write_cache(release) + return release + + def available(self, *, force: bool = False) -> str | None: + if self.current == UNKNOWN: + return None + release = self.latest(force=force) + if release is None: + return None + if parse_version(release.tag) > parse_version(self.current): + return release.tag + return None + + def _read_cache(self) -> Release | None: + try: + with open(self.cache_file, encoding="utf-8") as f: + data = json.load(f) + if time.time() - float(data.get("checked_at", 0)) > CACHE_TTL: + return None + if data.get("channel_pre") != self.include_prerelease: + return None + return Release(tag=data["tag"], assets=data.get("assets", {}), prerelease=bool(data.get("prerelease"))) + except (OSError, ValueError, KeyError): + return None + + def _write_cache(self, release: Release) -> None: + try: + self.cache_file.parent.mkdir(parents=True, exist_ok=True) + with open(self.cache_file, "w", encoding="utf-8") as f: + json.dump( + { + "checked_at": time.time(), + "channel_pre": self.include_prerelease, + "tag": release.tag, + "assets": release.assets, + "prerelease": release.prerelease, + }, + f, + ) + except OSError: + pass + + +class Updater: + CHECKSUMS_ASSET = "checksums.txt" + + def __init__(self, http: HttpClient, current: str) -> None: + self.http = http + self.current = current + + def target_path(self) -> Path: + if is_frozen(): + return Path(sys.executable).resolve() + if platform.system().lower() == "windows": + return Path(os.environ.get("APPDATA", "")) / "Portabase" / "portabase.exe" + default = Path("/usr/local/bin/portabase") + return default if default.exists() else Path.home() / ".local" / "bin" / "portabase" + + def download(self, release: Release, on_progress: Callable[[int], None] | None = None) -> Path: + name = platform_asset_name() + url = release.assets.get(name) + if url is None: + raise UpdateError( + f"No binary for this platform ({name}) in release {release.tag}.", + hint="Available: " + ", ".join(sorted(release.assets)) if release.assets else None, + ) + fd, tmp = tempfile.mkstemp(prefix="portabase_update_") + os.close(fd) + tmp_path = Path(tmp) + try: + self.http.download(url, tmp_path, on_progress, timeout=60) + self._verify(release, name, tmp_path) + except Exception: + tmp_path.unlink(missing_ok=True) + raise + return tmp_path + + def expected_size(self, release: Release) -> int | None: + url = release.assets.get(platform_asset_name()) + return self.http.content_length(url) if url else None + + def _verify(self, release: Release, name: str, path: Path) -> None: + url = release.assets.get(self.CHECKSUMS_ASSET) + if url is None: + raise UpdateError(f"Release {release.tag} has no {self.CHECKSUMS_ASSET}; refusing to install.") + expected = None + for line in self.http.get_text(url).splitlines(): + parts = line.split() + if len(parts) == 2 and parts[1].lstrip("*") == name: + expected = parts[0].lower() + if expected is None: + raise UpdateError(f"{name} not listed in {self.CHECKSUMS_ASSET}; refusing to install.") + digest = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + digest.update(chunk) + if digest.hexdigest() != expected: + raise UpdateError("Checksum mismatch for downloaded binary; refusing to install.") + + def install(self, tmp: Path, target: Path) -> None: + system = platform.system().lower() + if system != "windows": + tmp.chmod(0o755) + target.parent.mkdir(parents=True, exist_ok=True) + backup = target.with_name(target.name + ".old") + writable = os.access(target.parent, os.W_OK) and (not target.exists() or os.access(target, os.W_OK)) + try: + if writable or system == "windows": + if target.exists(): + backup.unlink(missing_ok=True) + target.rename(backup) + shutil.move(str(tmp), str(target)) + else: + if target.exists(): + subprocess.run(["sudo", "mv", str(target), str(backup)], check=True) + subprocess.run(["sudo", "mv", str(tmp), str(target)], check=True) + subprocess.run(["sudo", "chmod", "+x", str(target)], check=True) + except (OSError, subprocess.CalledProcessError) as e: + raise UpdateError(f"Could not install to {target}: {e}", cause=e) from e +``` + +- [ ] **Step 2: Vérifier le checker (réseau requis)** + +Run: `uv run python -c " +from pathlib import Path; import tempfile +from services.http import HttpClient +from services.updater import UpdateChecker, platform_asset_name +from core.config import GlobalConfig +cfg = GlobalConfig(Path(tempfile.mkdtemp())/'config.json') +c = UpdateChecker(HttpClient(), cfg, '0.0.1') +r = c.latest(force=True); print('latest:', r.tag, 'pre:', r.prerelease, 'assets:', len(r.assets)) +print('cached:', c.latest().tag == r.tag, '| available from 0.0.1:', c.available()) +print('asset for this machine:', platform_asset_name(), platform_asset_name() in r.assets)"` +Expected: tag de la dernière release stable (ex. `26.07.6`), `cached: True`, `available from 0.0.1: `, asset présent `True` sur linux/macos. + +- [ ] **Step 3: Commit** + +```bash +git add services/updater.py +git commit -m "feat(services): add UpdateChecker (notify, cached) and Updater with checksum verification" +``` + +--- + +### Task 11 : `commands/lifecycle.py`, `commands/config.py`, `commands/update.py` + +**Files:** +- Create: `commands/lifecycle.py` +- Modify: `commands/config.py` (réécriture complète) +- Create: `commands/update.py` +- Delete: `commands/common.py` (Task 12, après bascule de `main.py`) + +**Interfaces:** +- Consumes: `Command`, `CommandGroup`, `DockerRunner`, `UpdateChecker`, `Updater`, `GlobalConfig`. +- Produces: classes `StartCommand`, `StopCommand`, `RestartCommand`, `LogsCommand`, `UninstallCommand` (constructeur `(ui, telemetry, docker)`) ; `ConfigCommands(ui, telemetry, config)` groupe `config` avec `show`, `get`, `set`, `channel` ; `UpdateCommand(ui, telemetry, checker, updater)`. + +- [ ] **Step 1: `commands/lifecycle.py`** + +```python +"""start / stop / restart / logs / uninstall. No rendering: work on any folder with a compose file.""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Annotated + +import typer + +from commands.base import Command +from services.docker import DockerRunner +from services.telemetry import Telemetry +from ui import UI + +PathArg = Annotated[Path, typer.Argument(help="Path to the component folder")] + + +class _ComposeCommand(Command): + panel = "Lifecycle" + no_args_is_help = True + verb: str + compose_args: list[str] + done: str + + def __init__(self, ui: UI, telemetry: Telemetry, docker: DockerRunner) -> None: + super().__init__(ui, telemetry) + self.docker = docker + + def run(self, path: PathArg) -> None: + path = self.require_project_dir(path) + self.require_docker(self.docker) + with self.ui.status(f"{self.verb} {path.name}..."): + self.docker.compose(path, self.compose_args) + self.ui.success(self.done) + + +class StartCommand(_ComposeCommand): + name, help = "start", "Start a Portabase component." + verb, compose_args, done = "Starting", ["up", "-d"], "Started" + + +class StopCommand(_ComposeCommand): + name, help = "stop", "Stop a Portabase component." + verb, compose_args, done = "Stopping", ["stop"], "Stopped" + + +class RestartCommand(_ComposeCommand): + name, help = "restart", "Restart a Portabase component." + verb, compose_args, done = "Restarting", ["restart"], "Restarted" + + +class LogsCommand(Command): + name, help, panel = "logs", "View logs of a Portabase component.", "Lifecycle" + no_args_is_help = True + + def __init__(self, ui: UI, telemetry: Telemetry, docker: DockerRunner) -> None: + super().__init__(ui, telemetry) + self.docker = docker + + def run( + self, + path: PathArg, + follow: Annotated[bool, typer.Option("--follow/--no-follow", "-f", help="Follow log output")] = True, + ) -> None: + path = self.require_project_dir(path) + self.require_docker(self.docker) + args = ["logs", "-f"] if follow else ["logs"] + try: + self.docker.compose(path, args, check=False) + except KeyboardInterrupt: + pass + + +class UninstallCommand(Command): + name, help, panel = "uninstall", "Uninstall and delete a Portabase component.", "Lifecycle" + no_args_is_help = True + + def __init__(self, ui: UI, telemetry: Telemetry, docker: DockerRunner) -> None: + super().__init__(ui, telemetry) + self.docker = docker + + def run( + self, + path: PathArg, + force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation")] = False, + ) -> None: + path = self.require_project_dir(path) + self.require_docker(self.docker) + if not force: + self.ui.warning(f"This will delete containers, volumes and all data in {path}.") + self.confirm_or_abort("Are you sure?", default=False) + with self.ui.status("Uninstalling..."): + self.docker.compose(path, ["down", "-v"]) + try: + shutil.rmtree(path) + except OSError as e: + self.ui.warning(f"Could not remove directory: {e}") + self.ui.success("Uninstalled") +``` + +- [ ] **Step 2: `commands/config.py` (réécriture)** + +```python +"""Global configuration (~/.portabase/config.json).""" + +from __future__ import annotations + +from typing import Annotated + +import typer + +from commands.base import Command, CommandGroup +from core.config import GlobalConfig +from core.errors import ValidationError +from services.telemetry import Telemetry +from ui import UI + +CHANNELS = ("stable", "beta") +BOOL_KEYS = ("telemetry",) + + +class _ConfigCommand(Command): + panel = "Configuration" + + def __init__(self, ui: UI, telemetry: Telemetry, config: GlobalConfig) -> None: + super().__init__(ui, telemetry) + self.config = config + + +class ConfigShow(_ConfigCommand): + name, help = "show", "Show the current configuration." + + def run(self) -> None: + data = self.config.all() + self.ui.info(f"Configuration file: {self.config.path}") + for key in GlobalConfig.KNOWN_KEYS: + value = data.get(key, "[hint]unset[/hint]") + self.ui.print(f" [key]{key}[/key]: {value}") + for key in sorted(set(data) - set(GlobalConfig.KNOWN_KEYS)): + self.ui.print(f" [key]{key}[/key]: {data[key]} [hint](unknown key)[/hint]") + + +class ConfigGet(_ConfigCommand): + name, help = "get", "Print one configuration value." + no_args_is_help = True + + def run(self, key: Annotated[str, typer.Argument(help="Configuration key")]) -> None: + value = self.config.get(key) + if value is None: + raise ValidationError(f"'{key}' is not set.", hint="Known keys: " + ", ".join(GlobalConfig.KNOWN_KEYS)) + self.ui.print(str(value)) + + +class ConfigSet(_ConfigCommand): + name, help = "set", "Set a configuration value." + no_args_is_help = True + + def run( + self, + key: Annotated[str, typer.Argument(help="Configuration key")], + value: Annotated[str, typer.Argument(help="Value")], + ) -> None: + if key == "update_channel" and value not in CHANNELS: + raise ValidationError(f"Invalid channel '{value}'.", hint="Choose 'stable' or 'beta'.") + stored: object = value + if key in BOOL_KEYS: + lowered = value.lower() + if lowered not in ("true", "false", "1", "0", "yes", "no"): + raise ValidationError(f"'{key}' expects true or false.") + stored = lowered in ("true", "1", "yes") + self.config.set(key, stored) + self.ui.success(f"{key} = {stored}") + + +class ConfigChannel(_ConfigCommand): + """Kept for compatibility with the previous `config channel ` command.""" + + name, help = "channel", "Set the update channel (stable or beta)." + no_args_is_help = True + + def run(self, name: Annotated[str, typer.Argument(help="stable or beta")]) -> None: + ConfigSet(self.ui, self.telemetry, self.config).run("update_channel", name.lower()) + + +class ConfigCommands(CommandGroup): + name, help, panel = "config", "Manage global CLI configuration.", "Configuration" + + def __init__(self, ui: UI, telemetry: Telemetry, config: GlobalConfig) -> None: + super().__init__(ui, telemetry) + self.config = config + + @property + def commands(self) -> list[Command]: + deps = (self.ui, self.telemetry, self.config) + return [ConfigShow(*deps), ConfigGet(*deps), ConfigSet(*deps), ConfigChannel(*deps)] +``` + +- [ ] **Step 3: `commands/update.py`** + +```python +"""Manual update. Auto-update is gone; main.py only prints a notice after commands.""" + +from __future__ import annotations + +from commands.base import Command +from core.errors import UpdateError +from core.version import UNKNOWN, parse_version +from services.telemetry import Telemetry +from services.updater import Release, UpdateChecker, Updater, is_frozen +from ui import UI + + +class UpdateCommand(Command): + name, help, panel = "update", "Update the CLI to the latest version.", "System" + + def __init__(self, ui: UI, telemetry: Telemetry, checker: UpdateChecker, updater: Updater) -> None: + super().__init__(ui, telemetry) + self.checker = checker + self.updater = updater + + def run(self) -> None: + if not is_frozen(): + self.ui.warning("The update command is only available for the binary version of Portabase CLI.") + self.ui.info("If you installed from source, use [bold]git pull[/bold] to update.") + return + + current = self.checker.current + release = self._latest() + if release.tag == current: + self.ui.success(f"Portabase CLI is already up to date ({current}).") + return + if current != UNKNOWN and parse_version(release.tag) < parse_version(current): + self.ui.warning(f"Current version ({current}) is newer than the latest remote version ({release.tag}).") + self.confirm_or_abort("Continue with the downgrade?", default=False) + + target = self.updater.target_path() + self.ui.info(f"Updating Portabase CLI from {current} to {release.tag}") + self.ui.info(f"Target installation path: {target}") + + total = self.updater.expected_size(release) or 0 + with self.ui.progress().download(f"Downloading {release.tag}...", total) as advance: + tmp = self.updater.download(release, advance) + self.updater.install(tmp, target) + self.ui.success(f"Successfully updated to {release.tag}!") + + def _latest(self) -> Release: + try: + release = self.checker.fetch_latest() + except Exception as e: # NetworkError + raise UpdateError("Could not fetch latest release data from GitHub.", cause=e) from e + if release is None: + raise UpdateError("No release found for this channel.") + return release +``` + +- [ ] **Step 4: Vérifier le lint des nouveaux fichiers** + +Run: `uv run ruff check commands/lifecycle.py commands/config.py commands/update.py commands/base.py services ui core` +Expected: `All checks passed!`. (`except Exception` dans `_latest` : remplacer par `except NetworkError` en important `NetworkError` depuis `core.errors` si ruff `BLE001` se plaint — c'est de toute façon plus précis.) + +- [ ] **Step 5: Commit** + +```bash +git add commands/lifecycle.py commands/config.py commands/update.py +git commit -m "feat(commands): rewrite lifecycle, config and update commands as classes" +``` + +--- + +### Task 12 : `main.py` — câblage, catcher, bascule + +**Files:** +- Modify: `main.py` (réécriture complète) +- Delete: `commands/common.py`, `core/updater.py` +- Modify: `pyproject.toml` (`TID251`, per-file-ignores) + +**Interfaces:** +- Consumes: tout ce qui précède + fonctions legacy `commands.agent.agent`, `commands.dashboard.dashboard`, `commands.db.app`. +- Produces: `Settings`, `build_app(ui, telemetry, config, settings) -> tuple[typer.Typer, UpdateChecker]`, `main() -> None`. + +- [ ] **Step 1: Réécrire `main.py`** + +```python +"""Entry point. Builds dependencies, registers commands, owns the single error boundary.""" + +from __future__ import annotations + +import os +import platform +import sys +from dataclasses import dataclass +from typing import Annotated + +import click +import typer + +from commands import agent as legacy_agent +from commands import dashboard as legacy_dashboard +from commands import db as legacy_db +from commands.base import LegacyCommand +from commands.config import ConfigCommands +from commands.lifecycle import LogsCommand, RestartCommand, StartCommand, StopCommand, UninstallCommand +from commands.update import UpdateCommand +from core.config import GlobalConfig +from core.errors import PortabaseError, UserAbort, ValidationError +from core.version import current_version +from services.docker import DockerRunner +from services.http import HttpClient +from services.telemetry import ConsoleTelemetry, TelemetryFactory, TelemetryHub +from services.updater import UpdateChecker, Updater, is_frozen +from ui import UI + + +@dataclass +class Settings: + non_interactive: bool = False + verbose: bool = False + debug: bool = False + no_color: bool = False + + @classmethod + def from_env(cls) -> Settings: + return cls( + non_interactive=os.environ.get("PORTABASE_NON_INTERACTIVE", "").lower() in ("1", "true", "yes") + or not sys.stdin.isatty(), + no_color=bool(os.environ.get("NO_COLOR")), + ) + + +def build_app( + ui: UI, telemetry: TelemetryHub, config: GlobalConfig, settings: Settings +) -> tuple[typer.Typer, UpdateChecker]: + app = typer.Typer(no_args_is_help=True, add_completion=False, rich_markup_mode="rich") + http = HttpClient() + docker = DockerRunner() + version = current_version() + checker = UpdateChecker(http, config, version) + updater = Updater(http, version) + + def version_callback(value: bool) -> None: + if value: + ui.print(f"Portabase CLI version: {version}") + latest = checker.available(force=True) + if latest: + ui.warning(f"A new version is available: [bold]{latest}[/bold]") + raise typer.Exit() + + @app.callback() + def root( + _version: Annotated[ + bool | None, + typer.Option("--version", help="Show the version and exit.", callback=version_callback, is_eager=True), + ] = None, + verbose: Annotated[bool, typer.Option("--verbose", help="Show error causes and tracebacks.")] = False, + debug: Annotated[bool, typer.Option("--debug", help="Verbose plus telemetry trace on stderr.")] = False, + no_color: Annotated[bool, typer.Option("--no-color", help="Disable colours.")] = False, + non_interactive: Annotated[ + bool, + typer.Option("--non-interactive", envvar="PORTABASE_NON_INTERACTIVE", help="Never prompt; fail on missing input."), + ] = False, + ) -> None: + """Portabase CLI to manage agents, dashboards and databases.""" + settings.verbose = verbose or debug + settings.debug = debug + settings.no_color = settings.no_color or no_color + settings.non_interactive = settings.non_interactive or non_interactive + ui.configure(verbose=settings.verbose, no_color=settings.no_color, non_interactive=settings.non_interactive) + if debug: + telemetry.set(ConsoleTelemetry()) + + commands = [ + LegacyCommand(ui, telemetry, legacy_agent.agent, name="agent", help="Create a new Portabase Agent instance.", panel="Creation"), + LegacyCommand(ui, telemetry, legacy_dashboard.dashboard, name="dashboard", help="Create a new Portabase Dashboard instance.", panel="Creation"), + StartCommand(ui, telemetry, docker), + StopCommand(ui, telemetry, docker), + RestartCommand(ui, telemetry, docker), + LogsCommand(ui, telemetry, docker), + UninstallCommand(ui, telemetry, docker), + UpdateCommand(ui, telemetry, checker, updater), + ] + for cmd in commands: + cmd.register(app) + + app.add_typer(legacy_db.app, name="db", rich_help_panel="Configuration") # legacy, replaced in plan 4 + ConfigCommands(ui, telemetry, config).register(app) + + return app, checker + + +def _notify_update(ui: UI, checker: UpdateChecker, settings: Settings, invoked: str | None) -> None: + if not is_frozen() or settings.non_interactive or invoked in ("update", None): + return + latest = checker.available() + if latest: + ui.print("") + ui.warning(f"A new version of Portabase CLI is available: [bold]{latest}[/bold] (current: {checker.current})") + ui.info("Run [bold]portabase update[/bold] to update.") + + +def main() -> None: + settings = Settings.from_env() + config = GlobalConfig() + ui = UI(non_interactive=settings.non_interactive, no_color=settings.no_color) + telemetry = TelemetryFactory.build(config, debug=False) + app, checker = build_app(ui, telemetry, config, settings) + invoked = next((a for a in sys.argv[1:] if not a.startswith("-")), None) + exit_code = 0 + + try: + with telemetry.session(cli_version=current_version(), os=platform.system()): + app(standalone_mode=False) + except UserAbort as e: + ui.warning(e.message) + telemetry.event("abort") + exit_code = e.exit_code + except PortabaseError as e: + ui.error(e) + telemetry.error(e) + exit_code = e.exit_code + except click.exceptions.NoArgsIsHelpError: + exit_code = 0 # help already printed by Typer + except click.exceptions.Exit as e: # typer.Exit from legacy code or --help + exit_code = e.exit_code + except click.exceptions.Abort: # typer.Abort from legacy code + ui.warning("Cancelled.") + exit_code = 130 + except click.UsageError as e: + err = ValidationError(e.format_message(), hint="Run 'portabase --help' for usage.") + ui.error(err) + telemetry.error(err) + exit_code = err.exit_code + except KeyboardInterrupt: + ui.console.print("") + ui.warning("Cancelled.") + exit_code = 130 + except Exception as e: # noqa: BLE001 — last resort: a bug, not an expected error + wrapped = PortabaseError("Unexpected error: " + str(e), cause=e) + ui.error(wrapped, unexpected=True) + telemetry.error(e, unexpected=True) + exit_code = 1 + finally: + telemetry.flush() + + if exit_code == 0: + _notify_update(ui, checker, settings, invoked) + raise SystemExit(exit_code) + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 2: Supprimer les modules remplacés** + +Run: `git rm commands/common.py core/updater.py` + +Puis vérifier qu'aucun import ne subsiste : +Run: `grep -rn "commands.common\|core.updater\|check_for_updates\|update_cli" --include=*.py . | grep -v ".venv"` +Expected: aucune sortie. + +- [ ] **Step 3: Mettre à jour `pyproject.toml`** + +Remplacer le bloc `[tool.ruff.lint.per-file-ignores]` par : + +```toml +# Code legacy supprimé au plan 4. Ne pas étendre cette liste. +[tool.ruff.lint.per-file-ignores] +"commands/agent.py" = ["BLE001", "E722", "S110", "SIM102", "TID251"] +"commands/db.py" = ["BLE001", "E722", "S110", "TID251"] +"commands/dashboard.py" = ["BLE001", "TID251"] +"core/config.py" = ["BLE001", "E722", "S110"] +"core/utils.py" = ["BLE001", "E722", "S110", "PLR1730", "TID251"] +"core/network.py" = ["BLE001", "TID251"] +"main.py" = ["TID251"] + +[tool.ruff.lint.flake8-tidy-imports.banned-api] +"rich.prompt".msg = "Use ui.form() / ui.confirm() instead." +"rich.console".msg = "Only ui/ may build a Console. Use the UI facade." +"typer.prompt".msg = "Use ui.form() instead." +"typer.confirm".msg = "Use ui.confirm() instead." +``` + +Et ajouter `"TID251"` dans `select` s'il n'y est pas déjà (il y est depuis Plan 1). `main.py` importe `click` et `typer.Exit`, pas de prompt : `TID251` sur `main.py` est là uniquement pour `ui.console.print` ? Non — `rich.console` n'y est pas importé. Retirer `"main.py" = ["TID251"]` si `ruff check` passe sans. + +Ajouter `known-first-party = ["commands", "core", "services", "ui", "templates"]` dans `[tool.ruff.lint.isort]`. + +- [ ] **Step 4: Lint complet** + +Run: `uv run ruff check . && uv run ruff format --check .` +Expected: passe. Sinon `uv run ruff format .` puis corriger les erreurs signalées **dans les nouveaux fichiers uniquement**. + +- [ ] **Step 5: Vérifier l'aide et les erreurs de saisie** + +Run: `uv run python main.py; echo "exit=$?"` +Expected: aide affichée, `exit=0`. + +Run: `uv run python main.py --help | head -30` +Expected: panneaux `Creation` (agent, dashboard), `Lifecycle` (start, stop, restart, logs, uninstall), `Configuration` (db, config), `System` (update) ; options `--version`, `--verbose`, `--debug`, `--no-color`, `--non-interactive`. + +Run: `uv run python main.py start; echo "exit=$?"` +Expected: aide de `start` (no_args_is_help), `exit=0`. + +Run: `uv run python main.py bogus; echo "exit=$?"` +Expected: `✖ Error: No such command 'bogus'.` + hint, `exit=2`. + +Run: `uv run python main.py start /tmp/does-not-exist; echo "exit=$?"` +Expected: `✖ Error: No Portabase configuration found in: /tmp/does-not-exist` + hint, `exit=3`. + +Run: `uv run python main.py --verbose start /tmp/does-not-exist 2>&1 | grep -c "code: E_CONFIG"` +Expected: `1`. + +- [ ] **Step 6: Vérifier config** + +Run: `uv run python main.py config show && uv run python main.py config set update_channel beta && uv run python main.py config get update_channel && uv run python main.py config channel stable && uv run python main.py config set update_channel nope; echo "exit=$?"` +Expected: affichage, `✔ update_channel = beta`, `beta`, `✔ update_channel = stable`, puis `✖ Error: Invalid channel 'nope'.` `exit=2`. + +- [ ] **Step 7: Vérifier update et --version (non-frozen)** + +Run: `uv run python main.py update; echo "exit=$?"; uv run python main.py --version; echo "exit=$?"` +Expected: avertissement "only available for the binary version", `exit=0` ; version puis éventuellement "A new version is available", `exit=0`. + +- [ ] **Step 8: Vérifier le mode non-interactif et Ctrl-C** + +Run: `uv run python main.py --non-interactive uninstall /tmp/does-not-exist; echo "exit=$?"` +Expected: `E_CONFIG`, `exit=3` (l'erreur dossier précède la confirmation). + +Créer un faux projet : `mkdir -p /tmp/pb-fake && touch /tmp/pb-fake/docker-compose.yml`. +Run: `uv run python main.py --non-interactive uninstall /tmp/pb-fake; echo "exit=$?"` +Expected (Docker présent) : confirm par défaut `False` → `⚠ Cancelled.` `exit=130`, dossier intact. (Docker absent : `E_DOCKER`, `exit=4`.) + +Run: `uv run python main.py uninstall /tmp/pb-fake` puis Ctrl-C au prompt. +Expected: `⚠ Cancelled.`, `exit=130`, pas de traceback. + +- [ ] **Step 9: Vérifier les commandes legacy à travers le catcher** + +Run: `uv run python main.py agent; echo "exit=$?"` puis `uv run python main.py db list /tmp/does-not-exist; echo "exit=$?"` +Expected: aide de `agent` `exit=0` ; message legacy `No Portabase configuration found` (ancien style) et `exit=1` (via `typer.Exit(1)` → `click.exceptions.Exit`). + +- [ ] **Step 10: Vérifier le lifecycle réel (si Docker disponible)** + +```bash +cd /tmp && rm -rf pb-smoke && uv --directory /home/soluce/Documents/PROJETS/Portabase/cli run python /home/soluce/Documents/PROJETS/Portabase/cli/main.py dashboard pb-smoke --port 8899 +``` +Répondre `internal` au choix DB, `N` à "Start dashboard now?". Puis : + +```bash +M=/home/soluce/Documents/PROJETS/Portabase/cli/main.py +uv --directory /home/soluce/Documents/PROJETS/Portabase/cli run python $M start /tmp/pb-smoke +uv --directory /home/soluce/Documents/PROJETS/Portabase/cli run python $M logs /tmp/pb-smoke --no-follow | tail -3 +uv --directory /home/soluce/Documents/PROJETS/Portabase/cli run python $M restart /tmp/pb-smoke +uv --directory /home/soluce/Documents/PROJETS/Portabase/cli run python $M stop /tmp/pb-smoke +uv --directory /home/soluce/Documents/PROJETS/Portabase/cli run python $M uninstall /tmp/pb-smoke --force +ls /tmp/pb-smoke 2>&1 +``` +Expected: `✔ Started`, quelques lignes de logs, `✔ Restarted`, `✔ Stopped`, `✔ Uninstalled`, `No such file or directory`. + +- [ ] **Step 11: Commit** + +```bash +git add main.py pyproject.toml +git commit -m "refactor: wire commands through DI container and single error boundary + +Lifecycle, config and update run on the new Command classes; agent, +dashboard and db stay on legacy code behind LegacyCommand until plan 4. +Auto-update is replaced by a post-command notice." +``` + +--- + +### Task 13 : Build smoke, PR + +**Files:** aucun nouveau. + +- [ ] **Step 1: Binaire local** + +Run: `rm -rf build dist *.spec && uv run pyinstaller --onefile --name portabase_smoke --paths=. --collect-all rich --collect-all requests --collect-data certifi --add-data "pyproject.toml:." main.py && ./dist/portabase_smoke --version && ./dist/portabase_smoke config show && ./dist/portabase_smoke start /tmp/nope; echo "exit=$?"; rm -rf build dist *.spec` +Expected: version, config, `E_CONFIG` `exit=3`. Aucun `ModuleNotFoundError` (questionary, services, ui embarqués via `--paths=.`). + +- [ ] **Step 2: PR** + +```bash +git checkout -b refactor/foundations +git push -u origin refactor/foundations +``` +Ouvrir la PR « refactor: foundations (errors, ui, services, Command) + lifecycle rewrite ». Checks Plan 1 verts attendus. + +- [ ] **Step 3: Release candidate (optionnel mais recommandé)** + +Après merge : Actions → Bump version → `26.08.0rc1`, channel `rc`. Installer le binaire rc sur une machine avec une install existante et dérouler `start/stop/logs/restart` + `--version` (la notification de mise à jour après commande s'affiche seulement en binaire). + +--- + +## Self-review + +**Spec coverage :** +- §3 structure : `core/errors`, `core/version`, `core/config` (GlobalConfig), `ui/*`, `services/{http,docker,telemetry,updater}`, `commands/{base,lifecycle,config,update}`, `main.py` ✔. `services/{envfile,ports,templates,renderer,project,compose_facts}`, `engines/`, `commands/{agent,dashboard,build,db,flows}` → Plans 3–4. `core/fields.py` : déviation documentée. +- §4.1 `Command`, `register`, `_traced`, injection ✔ (T9). `Annotated` ✔. +- §7 ui : tokens ✔, composants Banner/Message/Section/Status/Hint/Prompt ✔ + Progress (appelant : update). `Summary`, `DataTable`, `Diff` → Plan 4 (appelants). `Form` ✔ avec flag→prompt→défaut→erreur, `UserAbort` sur `None` ✔. `NO_COLOR` ✔. Pas de prompt sous status : respecté dans lifecycle (confirm avant status). +- §8.1 hiérarchie et codes ✔. §8.2 catcher, `standalone_mode=False`, mapping click ✔ (T12). §8.3 télémétrie contrat + noop + console + hub ✔ ; opt-in config lu par `TelemetryFactory` (endpoint ignoré tant qu'aucun exporter — documenté). §8.4 updater : notif après commande, cache 24 h, silencieux offline, checksum ✔. +- §10 B+C : shippable, legacy via `LegacyCommand` ✔. + +**Placeholders :** aucun. + +**Cohérence des types :** `UI.confirm(question, *, default, value)` utilisé par `Command.confirm_or_abort` et `require_docker` ✔ ; `Telemetry.span` context manager utilisé par `_traced` ✔ ; `UpdateChecker.available(force=)` utilisé par `version_callback` et `_notify_update` ✔ ; `Updater.expected_size/download/install` utilisés par `UpdateCommand` ✔ ; `HttpClient.content_length` utilisé par `Updater.expected_size` ✔ (`head_content_length` cité dans l'interface T7 = `content_length` ; nom retenu : `content_length`). + +**Écarts connus :** +- `UpdateCommand._latest` : utiliser `except NetworkError` (T11 step 4). diff --git a/docs/superpowers/plans/2026-09-11-plan-3-templates-engines.md b/docs/superpowers/plans/2026-09-11-plan-3-templates-engines.md new file mode 100644 index 0000000..3b5be5d --- /dev/null +++ b/docs/superpowers/plans/2026-09-11-plan-3-templates-engines.md @@ -0,0 +1,1944 @@ +# Plan 3 — Templates Jinja2 et moteurs DB (chantier D) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Introduire les templates Jinja2 versionnés (source `templates/` à la racine, manifest, cache, `TemplateRepository`), le registre de moteurs DB en classes, et les garde-fous CI (`render-check`, `engines-check`, manifest à l'upload, hotfix) — sans encore brancher le rendu sur les commandes (Plan 4). Le CLI reste fonctionnel : les commandes legacy continuent de lire `agent.yml` / `dashboard.yml` (conservés dans `templates/` jusqu'au Plan 4). + +**Architecture:** `TemplateRepository` résout une version → dossier local (`./templates` en dev, cache `~/.portabase/cache/templates//` en binaire), vérifie un `manifest.json` (sha256) et expose des `jinja2.Template`. Chaque `DbEngine` déclare ses champs, génère un `DatabaseSpec`, produit ses variables `.env`, son contexte de template et sa projection `databases.json`. `render_check.py` rend chaque template avec des fixtures et valide le YAML puis `docker compose config`. + +**Tech Stack:** Jinja2 3.1, PyYAML, Python 3.12, GitHub Actions, s3cmd, jq. + +**Spec:** `docs/superpowers/specs/2026-09-11-cli-refactor-design.md` — sections 5.4, 5.6, 6, 6.1, 9.1 (`render-check`, `engines-check`), 9.3 (hotfix, manifest), 10 (D). + +## Global Constraints + +- Prérequis : Plans 1 et 2 exécutés. +- Règle de dépendance : `engines → core` uniquement. `services → engines, core`. Vérifié par revue ; ruff ne le détecte pas. +- Déviations spec assumées : + - `DatabaseSpec` vit dans `core/specs.py` (produit par `engines`, consommé par `services`), pas dans `services/project.py`. + - Pas de `mysql.yml.j2` : le moteur `mysql` utilise `engines/mariadb.yml.j2`, comme le code legacy (image `mariadb:latest`). Changer d'image casserait les volumes des installs existantes. +- Les fichiers legacy `agent.yml` et `dashboard.yml` sont déplacés tels quels dans `templates/` et restent uploadés (le code legacy les fetch sous `/`). Supprimés au Plan 4. +- Conventions de nommage legacy conservées à l'identique (service `db-pg-`, `db-mongo-auth-`, db `pg_`, user `admin`, firebird user `alice` / `mirror.fdb`, mssql `sa` / `master` / name `MSSQL`, redis/valkey `database: "0"`), pour que les nouvelles installs ressemblent aux anciennes. +- `generate_password` retire `$` et `` ` `` des symboles (Task 1). +- Pas de tests unitaires. `render_check.py` est la vérification exécutable de ce plan et devient un job CI. +- Aucune commande utilisateur ne change dans ce plan. + +--- + +## File Structure + +| Fichier | Action | Responsabilité | +|---|---|---| +| `core/utils.py` | modifier | `generate_password` sans `$`/`` ` `` | +| `core/specs.py` | créer | `DatabaseSpec` | +| `services/ports.py` | créer | `PortAllocator` | +| `engines/__init__.py` | créer | `registry` | +| `engines/base.py` | créer | `DbEngine` | +| `engines/registry.py` | créer | `EngineRegistry` | +| `engines/sql.py` | créer | `StandardSqlEngine`, `PostgresEngine`, `PostgresClusterEngine`, `MySqlEngine`, `MariaDbEngine`, `MssqlEngine`, `FirebirdEngine` | +| `engines/redis.py` | créer | `RedisEngine` | +| `engines/valkey.py` | créer | `ValkeyEngine` | +| `engines/mongo.py` | créer | `MongoEngine` | +| `engines/sqlite.py` | créer | `SqliteEngine` | +| `engines/docker_volume.py` | créer | `DockerVolumeEngine` | +| `templates/agent.yml.j2`, `dashboard.yml.j2`, `engines/*.yml.j2` | créer | templates Jinja2 | +| `templates/engines.map.json` | créer | clé moteur → template | +| `templates/agent.yml`, `dashboard.yml` | déplacer depuis `.github/assets/templates/` | legacy | +| `services/templates.py` | créer | `Manifest`, `TemplateRepository` | +| `scripts/render_check.py` | créer | validation des templates | +| `.github/workflows/ci.yml` | modifier | jobs `render-check`, `engines-check` | +| `.github/workflows/templates-upload.yml` | modifier | source `templates/`, manifest | +| `.github/workflows/templates-hotfix.yml` | créer | re-upload d'une version | +| `pyproject.toml` | modifier | `jinja2` | +| `.gitleaks.toml` | modifier | chemin `templates/` déjà allowlisté ; retirer `.github/assets/templates` | + +--- + +### Task 1 : `core/specs.py`, `services/ports.py`, mot de passe + +**Files:** +- Create: `core/specs.py` +- Create: `services/ports.py` +- Modify: `core/utils.py:70-92` (`generate_password`) + +**Interfaces:** +- Produces: `DatabaseSpec` (frozen dataclass) avec `env_prefix`, `is_service`, `with_options()` ; `PortAllocator().free() -> int` ; `generate_password(length=16)` sans `$` ni `` ` ``. + +- [ ] **Step 1: `core/specs.py`** + +```python +"""Typed view of one databases.json entry plus what the CLI needs to render it.""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from typing import Any + + +@dataclass(frozen=True) +class DatabaseSpec: + id: str + engine: str + name: str + managed: bool = False # True: a Compose service is rendered for it + host: str | None = None # service name when managed, remote host otherwise + port: int | None = None # container/remote port (what the agent connects to) + host_port: int | None = None # published port on the Docker host (managed only) + database: str | None = None + username: str | None = None + password: str | None = None + root_password: str | None = None # firebird + path: str | None = None # sqlite + volume: str | None = None # docker-volume + container: str | None = None # docker-volume + options: dict[str, Any] = field(default_factory=dict) + + @property + def env_prefix(self) -> str: + if not self.host: + raise ValueError("env_prefix requires a host/service name") + return self.host.upper().replace("-", "_") + + @property + def auth(self) -> bool: + return bool(self.password) + + def with_options(self, options: dict[str, Any]) -> DatabaseSpec: + return replace(self, options=dict(options)) +``` + +- [ ] **Step 2: `services/ports.py`** + +```python +"""Free TCP port allocation. Remembers ports handed out during the process to avoid duplicates.""" + +from __future__ import annotations + +import socket + + +class PortAllocator: + def __init__(self) -> None: + self._given: set[int] = set() + + def free(self) -> int: + for _ in range(50): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + port = s.getsockname()[1] + if port not in self._given: + self._given.add(port) + return port + raise RuntimeError("Could not allocate a free port") + + +class FixedPortAllocator(PortAllocator): + """Deterministic ports for render checks and fixtures.""" + + def __init__(self, start: int = 40000) -> None: + super().__init__() + self._next = start + + def free(self) -> int: + port = self._next + self._next += 1 + return port +``` + +- [ ] **Step 3: Corriger `generate_password` dans `core/utils.py`** + +Remplacer la ligne `symbols = "!@#$%^&*()-_=+[]{}|;:,.<>?"` par : + +```python + # No '$' (Compose interpolation / shell), no '`' or quotes (shell command args in templates). + symbols = "!@#%^&*()-_=+[]{}|;:,.<>?" +``` + +- [ ] **Step 4: Vérifier** + +Run: `uv run python -c " +from core.specs import DatabaseSpec +from services.ports import PortAllocator, FixedPortAllocator +from core.utils import generate_password +s = DatabaseSpec(id='1', engine='postgresql', name='x', managed=True, host='db-pg-a1f2', password='p') +print(s.env_prefix, s.auth, s.with_options({'a':1}).options) +p = PortAllocator(); a, b = p.free(), p.free(); print(a != b, FixedPortAllocator().free()) +pw = generate_password(); print(len(pw), '\$' not in pw and '\`' not in pw)"` +Expected: `DB_PG_A1F2 True {'a': 1}`, `True 40000`, `16 True`. + +- [ ] **Step 5: Commit** + +```bash +git add core/specs.py services/ports.py core/utils.py +git commit -m "feat: add DatabaseSpec, PortAllocator; drop shell-unsafe symbols from generated passwords" +``` + +--- + +### Task 2 : `engines/base.py` et `engines/registry.py` + +**Files:** +- Create: `engines/__init__.py` (rempli Task 4) +- Create: `engines/base.py` +- Create: `engines/registry.py` + +**Interfaces:** +- Produces: `DbEngine` ABC : + - classe-attributs `key`, `display`, `default_port: int | None`, `template: str | None`, `auth_variants=False`, `warning=None`, `has_modes=True` + - `fields_existing() -> list[Field]`, `fields_new() -> list[Field]`, `option_fields() -> list[Field]` + - `generate(*, auth: bool, ports: PortAllocator, answers: dict) -> DatabaseSpec` + - `from_existing(answers: dict) -> DatabaseSpec` + - `env_vars(spec) -> dict[str, str]` + - `template_ctx(spec, *, inline: bool = False) -> dict` + - `agent_entry(spec) -> dict` + - `describe(spec) -> str` (pour `db list` : « host:port », « Local File », « volume: x ») + - helpers `new_id()`, `service_name(slug, auth)`, `var(spec, suffix, value, inline)` +- `EngineRegistry(engines)` : `get(key)`, `keys()`, `choices()`, `__iter__`. + +- [ ] **Step 1: `engines/base.py`** + +```python +"""DbEngine: everything the CLI needs to know about one database engine.""" + +from __future__ import annotations + +import secrets +import uuid +from abc import ABC, abstractmethod +from typing import Any + +from core.fields import Field +from core.specs import DatabaseSpec +from services.ports import PortAllocator + +STANDARD_EXISTING_FIELDS = ( + Field("host", "Host", "text", default="localhost"), + Field("port", "Port", "int"), # default filled per engine + Field("database", "Database Name", "text"), + Field("username", "Username", "text"), + Field("password", "Password", "secret"), +) + + +class DbEngine(ABC): + key: str + display: str + default_port: int | None = None + template: str | None = None # e.g. "engines/postgresql.yml.j2"; None: no Compose service + auth_variants: bool = False # offer with-auth / no-auth when creating a container + warning: str | None = None # shown before collecting answers + has_modes: bool = True # new/existing choice applies + + # ---- declarations ----------------------------------------------------- + + def fields_existing(self) -> list[Field]: + return [ + Field("port", "Port", "int", default=self.default_port) if f.name == "port" else f + for f in STANDARD_EXISTING_FIELDS + ] + + def fields_new(self) -> list[Field]: + return [] + + def option_fields(self) -> list[Field]: + return [] + + # ---- construction ----------------------------------------------------- + + @abstractmethod + def generate(self, *, auth: bool, ports: PortAllocator, answers: dict[str, Any]) -> DatabaseSpec: ... + + def from_existing(self, answers: dict[str, Any]) -> DatabaseSpec: + return DatabaseSpec( + id=self.new_id(), + engine=self.key, + name=answers.get("label") or "External DB", + managed=False, + host=answers["host"], + port=int(answers["port"]), + database=answers["database"], + username=answers["username"], + password=answers["password"], + ) + + # ---- rendering inputs ------------------------------------------------- + + def env_vars(self, spec: DatabaseSpec) -> dict[str, str]: + """Variables written to .env for a managed service. Default: PORT, DB, USER, PASS.""" + p = spec.env_prefix + return { + f"{p}_PORT": str(spec.host_port), + f"{p}_DB": spec.database or "", + f"{p}_USER": spec.username or "", + f"{p}_PASS": spec.password or "", + } + + def template_ctx(self, spec: DatabaseSpec, *, inline: bool = False) -> dict[str, Any]: + return { + "name": spec.host, + "volume": f"{spec.host}-data", + "auth": spec.auth, + "port_var": self.var(spec, "PORT", spec.host_port, inline), + "db_var": self.var(spec, "DB", spec.database, inline), + "user_var": self.var(spec, "USER", spec.username, inline), + "password_var": self.var(spec, "PASS", spec.password, inline), + } + + def agent_entry(self, spec: DatabaseSpec) -> dict[str, Any]: + """Projection to databases.json. Same shape as the legacy CLI.""" + entry: dict[str, Any] = { + "name": spec.name, + "database": self.agent_database(spec), + "type": self.key, + "username": spec.username or "", + "password": spec.password or "", + "port": spec.port, + "host": spec.host, + "generated_id": spec.id, + } + options = self.non_default_options(spec) + if options: + entry["options"] = options + return entry + + def agent_database(self, spec: DatabaseSpec) -> str: + return spec.database or "" + + def describe(self, spec: DatabaseSpec) -> str: + return f"{spec.host}:{spec.port}" + + # ---- helpers ---------------------------------------------------------- + + def non_default_options(self, spec: DatabaseSpec) -> dict[str, Any]: + defaults = {f.name: f.default for f in self.option_fields()} + return {k: v for k, v in spec.options.items() if k in defaults and v != defaults[k]} + + @staticmethod + def new_id() -> str: + return str(uuid.uuid4()) + + @staticmethod + def service_name(slug: str, auth: bool = False) -> str: + suffix = "auth-" if auth else "" + return f"db-{slug}-{suffix}{secrets.token_hex(2)}" + + @staticmethod + def var(spec: DatabaseSpec, suffix: str, value: Any, inline: bool) -> str: + return str(value if value is not None else "") if inline else f"${{{spec.env_prefix}_{suffix}}}" +``` + +- [ ] **Step 2: `engines/registry.py`** + +```python +from __future__ import annotations + +from collections.abc import Iterable, Iterator + +from core.errors import ValidationError +from engines.base import DbEngine + + +class EngineRegistry: + def __init__(self, engines: Iterable[DbEngine]) -> None: + self._by_key: dict[str, DbEngine] = {} + for engine in engines: + if engine.key in self._by_key: + raise ValueError(f"Duplicate engine key: {engine.key}") + self._by_key[engine.key] = engine + + def get(self, key: str) -> DbEngine: + try: + return self._by_key[key] + except KeyError: + raise ValidationError( + f"Unknown engine '{key}'.", + hint="Available: " + ", ".join(self.keys()), + ) from None + + def keys(self) -> list[str]: + return list(self._by_key) + + def choices(self) -> list[str]: + return self.keys() + + def __iter__(self) -> Iterator[DbEngine]: + return iter(self._by_key.values()) + + def __contains__(self, key: str) -> bool: + return key in self._by_key +``` + +- [ ] **Step 3: Vérifier** + +Run: `uv run python -c " +from engines.base import DbEngine +from engines.registry import EngineRegistry +from core.errors import ValidationError +print([f.name for f in DbEngine.fields_existing(type('E',(DbEngine,),{'key':'x','display':'X','default_port':1,'generate':lambda *a,**k: None})())]) +try: EngineRegistry([]).get('nope') +except ValidationError as e: print(e.message, '|', e.hint)"` +Expected: `['host', 'port', 'database', 'username', 'password']` puis `Unknown engine 'nope'. | Available: `. + +- [ ] **Step 4: Commit** + +```bash +git add engines/ +git commit -m "feat(engines): add DbEngine base class and EngineRegistry" +``` + +--- + +### Task 3 : Moteurs SQL (`engines/sql.py`) + +**Files:** +- Create: `engines/sql.py` + +**Interfaces:** +- Produces: `StandardSqlEngine` et sous-classes `PostgresEngine` (`postgresql`), `PostgresClusterEngine` (`postgresql-cluster`), `MySqlEngine` (`mysql`), `MariaDbEngine` (`mariadb`), `MssqlEngine` (`mssql`), `FirebirdEngine` (`firebird`). + +- [ ] **Step 1: Écrire le module** + +```python +"""SQL engines rendered as Compose services. Naming mirrors the legacy CLI.""" + +from __future__ import annotations + +import secrets +from typing import Any + +from core.fields import Field +from core.specs import DatabaseSpec +from core.utils import generate_password +from engines.base import DbEngine +from services.ports import PortAllocator + + +class StandardSqlEngine(DbEngine): + slug: str # service name fragment: db--xxxx + db_prefix: str # generated database name: _xxxxxxxx + default_user = "admin" + + def generate(self, *, auth: bool, ports: PortAllocator, answers: dict[str, Any]) -> DatabaseSpec: + db_name = f"{self.db_prefix}_{secrets.token_hex(4)}" + return DatabaseSpec( + id=self.new_id(), + engine=self.key, + name=db_name, + managed=True, + host=self.service_name(self.slug), + port=self.default_port, + host_port=ports.free(), + database=db_name, + username=self.default_user, + password=generate_password(16), + options=dict(answers.get("options", {})), + ) + + +class PostgresEngine(StandardSqlEngine): + key, display, default_port = "postgresql", "PostgreSQL", 5432 + template, slug, db_prefix = "engines/postgresql.yml.j2", "pg", "pg" + + def option_fields(self) -> list[Field]: + return [ + Field( + "keep_ownership", + "Keep ownership?", + "bool", + default=False, + help=( + "When enabled, omits --no-owner and --no-privileges from the dump. Ownership and role " + "assignments are preserved. By default these flags are applied to keep restores portable " + "across users and environments." + ), + ), + Field( + "clean_mode", + "Clean mode", + "choice", + default="clean", + choices=("clean", "none", "drop_schemas", "drop_database"), + help=( + "How the target database is cleaned before a restore. clean: pg_restore --clean --if-exists. " + "none: no pre-clean. drop_schemas: drop every non-system schema CASCADE (works on managed " + "Postgres). drop_database: DROP DATABASE + CREATE DATABASE — requires CREATEDB or superuser; " + "most managed providers do not allow it." + ), + ), + ] + + +class PostgresClusterEngine(StandardSqlEngine): + key, display, default_port = "postgresql-cluster", "PostgreSQL Cluster", 5432 + template, slug, db_prefix = "engines/postgresql.yml.j2", "pg", "pg" + warning = ( + "Postgres Cluster requires a superuser. Cluster backup/restore uses pg_dumpall, which dumps all " + "databases and global objects (roles, tablespaces). The provided user must be a Postgres superuser." + ) + + +class MariaDbEngine(StandardSqlEngine): + key, display, default_port = "mariadb", "MariaDB", 3306 + template, slug, db_prefix = "engines/mariadb.yml.j2", "mariadb", "mysql" + + +class MySqlEngine(MariaDbEngine): + """Legacy behaviour: a 'mysql' container is a MariaDB image. Kept for volume compatibility.""" + + key, display = "mysql", "MySQL" + + +class MssqlEngine(StandardSqlEngine): + key, display, default_port = "mssql", "Microsoft SQL Server", 1433 + template, slug, db_prefix = "engines/mssql.yml.j2", "mssql", "master" + + def generate(self, *, auth: bool, ports: PortAllocator, answers: dict[str, Any]) -> DatabaseSpec: + return DatabaseSpec( + id=self.new_id(), + engine=self.key, + name="MSSQL", + managed=True, + host=self.service_name(self.slug), + port=self.default_port, + host_port=ports.free(), + database="master", + username="sa", + password=generate_password(16), + ) + + def env_vars(self, spec: DatabaseSpec) -> dict[str, str]: + p = spec.env_prefix + return {f"{p}_PORT": str(spec.host_port), f"{p}_PASS": spec.password or ""} + + +class FirebirdEngine(StandardSqlEngine): + key, display, default_port = "firebird", "Firebird", 3050 + template, slug, db_prefix = "engines/firebird.yml.j2", "firebird", "fb" + DATA_DIR = "/var/lib/firebird/data" + + def generate(self, *, auth: bool, ports: PortAllocator, answers: dict[str, Any]) -> DatabaseSpec: + db_file = "mirror.fdb" + return DatabaseSpec( + id=self.new_id(), + engine=self.key, + name=db_file, + managed=True, + host=self.service_name(self.slug), + port=self.default_port, + host_port=ports.free(), + database=f"{self.DATA_DIR}/{db_file}", + username="alice", + password=generate_password(16), + root_password=generate_password(16), + ) + + def env_vars(self, spec: DatabaseSpec) -> dict[str, str]: + base = super().env_vars(spec) + # Compose template expects the bare file name; databases.json carries the container path. + base[f"{spec.env_prefix}_DB"] = (spec.database or "").rsplit("/", 1)[-1] + base[f"{spec.env_prefix}_ROOT_PASS"] = spec.root_password or "" + return base + + def template_ctx(self, spec: DatabaseSpec, *, inline: bool = False) -> dict[str, Any]: + ctx = super().template_ctx(spec, inline=inline) + ctx["db_var"] = self.var(spec, "DB", (spec.database or "").rsplit("/", 1)[-1], inline) + ctx["root_password_var"] = self.var(spec, "ROOT_PASS", spec.root_password, inline) + return ctx +``` + +- [ ] **Step 2: Vérifier** + +Run: `uv run python -c " +from engines.sql import * +from services.ports import FixedPortAllocator +p = FixedPortAllocator() +for E in (PostgresEngine, MySqlEngine, MssqlEngine, FirebirdEngine): + e = E(); s = e.generate(auth=True, ports=p, answers={'options': {'clean_mode': 'none'}}) + print(E.key, s.host[:9], sorted(e.env_vars(s)), e.agent_entry(s).get('options'), e.template_ctx(s)['port_var']) +print(PostgresEngine().agent_entry(PostgresEngine().generate(auth=True, ports=p, answers={})).get('options'))"` +Expected (hex variable) : +``` +postgresql db-pg-xxx ['DB_PG_XXXX_DB', 'DB_PG_XXXX_PASS', 'DB_PG_XXXX_PORT', 'DB_PG_XXXX_USER'] {'clean_mode': 'none'} ${DB_PG_XXXX_PORT} +mysql db-mariad [... 4 vars] None ... +mssql db-mssql- [..._PASS, ..._PORT] None ... +firebird db-fireb [..._DB, ..._PASS, ..._PORT, ..._ROOT_PASS, ..._USER] None ... +None +``` +La dernière ligne : options par défaut → pas de clé `options`. + +- [ ] **Step 3: Commit** + +```bash +git add engines/sql.py +git commit -m "feat(engines): add SQL engines (postgresql, cluster, mysql, mariadb, mssql, firebird)" +``` + +--- + +### Task 4 : Redis, Valkey, Mongo, SQLite, Docker volume, registre + +**Files:** +- Create: `engines/redis.py`, `engines/valkey.py`, `engines/mongo.py`, `engines/sqlite.py`, `engines/docker_volume.py` +- Modify: `engines/__init__.py` + +**Interfaces:** +- Produces: `RedisEngine`, `ValkeyEngine`, `MongoEngine`, `SqliteEngine`, `DockerVolumeEngine` ; `engines.registry: EngineRegistry` (instance module-level) ; `engines.ALL: tuple[DbEngine, ...]`. + +- [ ] **Step 1: `engines/redis.py`** + +```python +from __future__ import annotations + +import secrets +from typing import Any + +from core.fields import Field +from core.specs import DatabaseSpec +from core.utils import generate_password +from engines.base import DbEngine +from services.ports import PortAllocator + + +class RedisEngine(DbEngine): + key, display, default_port = "redis", "Redis", 6379 + template = "engines/redis.yml.j2" + auth_variants = True + + def fields_existing(self) -> list[Field]: + return [ + Field("host", "Host", "text", default="localhost"), + Field("port", "Port", "int", default=self.default_port), + Field("database", "Database index", "text", default="0"), + Field("username", "Username (empty if none)", "text", default=""), + Field("password", "Password (empty if none)", "text", default=""), + ] + + def generate(self, *, auth: bool, ports: PortAllocator, answers: dict[str, Any]) -> DatabaseSpec: + return DatabaseSpec( + id=self.new_id(), + engine=self.key, + name=f"redis_{secrets.token_hex(4)}", + managed=True, + host=self.service_name("redis", auth), + port=self.default_port, + host_port=ports.free(), + database="0", + username="", + password=generate_password(16) if auth else None, + ) + + def env_vars(self, spec: DatabaseSpec) -> dict[str, str]: + p = spec.env_prefix + out = {f"{p}_PORT": str(spec.host_port)} + if spec.auth: + out[f"{p}_PASS"] = spec.password or "" + return out + + def agent_database(self, spec: DatabaseSpec) -> str: + return spec.database or "0" +``` + +- [ ] **Step 2: `engines/valkey.py`** + +Identique à Redis sauf identité et template : + +```python +from __future__ import annotations + +import secrets +from typing import Any + +from core.fields import Field +from core.specs import DatabaseSpec +from core.utils import generate_password +from engines.base import DbEngine +from services.ports import PortAllocator + + +class ValkeyEngine(DbEngine): + key, display, default_port = "valkey", "Valkey", 6379 + template = "engines/valkey.yml.j2" + auth_variants = True + + def fields_existing(self) -> list[Field]: + return [ + Field("host", "Host", "text", default="localhost"), + Field("port", "Port", "int", default=self.default_port), + Field("database", "Database index", "text", default="0"), + Field("username", "Username (empty if none)", "text", default=""), + Field("password", "Password (empty if none)", "text", default=""), + ] + + def generate(self, *, auth: bool, ports: PortAllocator, answers: dict[str, Any]) -> DatabaseSpec: + return DatabaseSpec( + id=self.new_id(), + engine=self.key, + name=f"valkey_{secrets.token_hex(4)}", + managed=True, + host=self.service_name("valkey", auth), + port=self.default_port, + host_port=ports.free(), + database="0", + username="", + password=generate_password(16) if auth else None, + ) + + def env_vars(self, spec: DatabaseSpec) -> dict[str, str]: + p = spec.env_prefix + out = {f"{p}_PORT": str(spec.host_port)} + if spec.auth: + out[f"{p}_PASS"] = spec.password or "" + return out + + def agent_database(self, spec: DatabaseSpec) -> str: + return spec.database or "0" +``` + +- [ ] **Step 3: `engines/mongo.py`** + +```python +from __future__ import annotations + +import secrets +from typing import Any + +from core.specs import DatabaseSpec +from core.utils import generate_password +from engines.base import DbEngine +from services.ports import PortAllocator + + +class MongoEngine(DbEngine): + key, display, default_port = "mongodb", "MongoDB", 27017 + template = "engines/mongodb.yml.j2" + auth_variants = True + + def generate(self, *, auth: bool, ports: PortAllocator, answers: dict[str, Any]) -> DatabaseSpec: + db_name = f"mongo_{secrets.token_hex(4)}" + return DatabaseSpec( + id=self.new_id(), + engine=self.key, + name=db_name, + managed=True, + host=self.service_name("mongo", auth), + port=self.default_port, + host_port=ports.free(), + database=db_name, + username="admin" if auth else "", + password=generate_password(16) if auth else None, + ) + + def env_vars(self, spec: DatabaseSpec) -> dict[str, str]: + p = spec.env_prefix + out = {f"{p}_PORT": str(spec.host_port), f"{p}_DB": spec.database or ""} + if spec.auth: + out[f"{p}_USER"] = spec.username or "" + out[f"{p}_PASS"] = spec.password or "" + return out +``` + +- [ ] **Step 4: `engines/sqlite.py`** + +```python +"""SQLite: a file mounted into the agent. No Compose service.""" + +from __future__ import annotations + +from typing import Any + +from core.fields import Field +from core.specs import DatabaseSpec +from engines.base import DbEngine +from services.ports import PortAllocator + +CONFIG_DIR = "/config" + + +class SqliteEngine(DbEngine): + key, display = "sqlite", "SQLite" + template = None + auth_variants = False + + def fields_existing(self) -> list[Field]: + return [Field("path", "Database Path (relative or absolute)", "text")] + + def fields_new(self) -> list[Field]: + return [Field("name", "Database Name", "text", default="local")] + + def generate(self, *, auth: bool, ports: PortAllocator, answers: dict[str, Any]) -> DatabaseSpec: + name = str(answers.get("name") or "local") + if not name.endswith(".sqlite"): + name += ".sqlite" + return DatabaseSpec( + id=self.new_id(), + engine=self.key, + name=name, + managed=False, + path=name, # relative: ./name mounted to /config/name + database=f"{CONFIG_DIR}/{name}", + ) + + def from_existing(self, answers: dict[str, Any]) -> DatabaseSpec: + raw = str(answers["path"]) + absolute = raw.startswith("/") + return DatabaseSpec( + id=self.new_id(), + engine=self.key, + name=answers.get("label") or "External DB", + managed=False, + path=raw, + database=raw if absolute else f"{CONFIG_DIR}/{raw}", + ) + + @staticmethod + def mount_for(spec: DatabaseSpec) -> tuple[str, str] | None: + """(host_path, container_path) if the file must be bind-mounted into the agent.""" + if spec.database and spec.database.startswith(f"{CONFIG_DIR}/"): + rel = spec.database[len(CONFIG_DIR) + 1 :] + return (f"./{rel}", spec.database) + return None + + def env_vars(self, spec: DatabaseSpec) -> dict[str, str]: + return {} + + def agent_entry(self, spec: DatabaseSpec) -> dict[str, Any]: + return {"name": spec.name, "database": spec.database, "type": self.key, "generated_id": spec.id} + + def describe(self, spec: DatabaseSpec) -> str: + return "Local File" +``` + +- [ ] **Step 5: `engines/docker_volume.py`** + +```python +"""Docker volume backup target. Requires the Docker socket on the agent. No Compose service.""" + +from __future__ import annotations + +from typing import Any + +from core.fields import Field +from core.specs import DatabaseSpec +from engines.base import DbEngine +from services.ports import PortAllocator + + +class DockerVolumeEngine(DbEngine): + key, display = "docker-volume", "Docker Volume" + template = None + has_modes = False + warning = "Requires the Docker socket. It will be mounted on the agent (/var/run/docker.sock)." + + def fields_existing(self) -> list[Field]: + return [ + Field("volume", "Volume Name (e.g. databases_sqlite-data)", "text"), + Field("container", "Container Name (optional, enables auto-restart after restore)", "text", default=""), + ] + + def fields_new(self) -> list[Field]: + return self.fields_existing() + + def generate(self, *, auth: bool, ports: PortAllocator, answers: dict[str, Any]) -> DatabaseSpec: + return self.from_existing(answers) + + def from_existing(self, answers: dict[str, Any]) -> DatabaseSpec: + return DatabaseSpec( + id=self.new_id(), + engine=self.key, + name=answers.get("label") or "Docker Volume", + managed=False, + volume=str(answers["volume"]).strip(), + container=(str(answers.get("container") or "").strip() or None), + ) + + def env_vars(self, spec: DatabaseSpec) -> dict[str, str]: + return {} + + def agent_entry(self, spec: DatabaseSpec) -> dict[str, Any]: + entry = {"name": spec.name, "type": self.key, "volume_name": spec.volume, "generated_id": spec.id} + if spec.container: + entry["container_name"] = spec.container + return entry + + def describe(self, spec: DatabaseSpec) -> str: + return f"volume: {spec.volume}" +``` + +- [ ] **Step 6: `engines/__init__.py`** + +```python +"""Engine registry. Explicit imports keep PyInstaller happy (no dynamic discovery).""" + +from __future__ import annotations + +from engines.docker_volume import DockerVolumeEngine +from engines.mongo import MongoEngine +from engines.redis import RedisEngine +from engines.registry import EngineRegistry +from engines.sql import ( + FirebirdEngine, + MariaDbEngine, + MssqlEngine, + MySqlEngine, + PostgresClusterEngine, + PostgresEngine, +) +from engines.sqlite import SqliteEngine +from engines.valkey import ValkeyEngine + +ALL = ( + PostgresEngine(), + PostgresClusterEngine(), + MySqlEngine(), + MariaDbEngine(), + SqliteEngine(), + FirebirdEngine(), + MongoEngine(), + RedisEngine(), + ValkeyEngine(), + MssqlEngine(), + DockerVolumeEngine(), +) + +registry = EngineRegistry(ALL) + +__all__ = ["ALL", "EngineRegistry", "registry"] +``` + +L'ordre = ordre d'affichage dans le select (identique au legacy). + +- [ ] **Step 7: Vérifier** + +Run: `uv run python -c " +from engines import registry +from services.ports import FixedPortAllocator +p = FixedPortAllocator() +print(registry.keys()) +for e in registry: + if e.template is None: continue + for auth in ((True, False) if e.auth_variants else (True,)): + s = e.generate(auth=auth, ports=p, answers={}) + ctx = e.template_ctx(s); assert ctx['name'] == s.host and set(e.env_vars(s)) >= {s.env_prefix + '_PORT'} + print(f'{e.key:20} auth={auth!s:5} {s.host:24} env={len(e.env_vars(s))} entry.db={e.agent_entry(s)[\"database\"]!r}') +sq = registry.get('sqlite'); s = sq.generate(auth=False, ports=p, answers={'name':'x'}); print(sq.agent_entry(s), sq.mount_for(s)) +dv = registry.get('docker-volume'); print(dv.agent_entry(dv.from_existing({'volume':'v','container':''})))"` +Expected: 11 clés dans l'ordre legacy ; une ligne par moteur/variante avec `entry.db` = `'0'` pour redis/valkey, `'master'` mssql, `/var/lib/firebird/data/mirror.fdb` firebird ; sqlite `{'name': 'x.sqlite', 'database': '/config/x.sqlite', 'type': 'sqlite', 'generated_id': ...} ('./x.sqlite', '/config/x.sqlite')` ; docker-volume sans `container_name`. + +- [ ] **Step 8: Commit** + +```bash +git add engines/ +git commit -m "feat(engines): add redis, valkey, mongodb, sqlite, docker-volume engines and registry" +``` + +--- + +### Task 5 : Templates Jinja2 + +**Files:** +- Create: `templates/agent.yml.j2`, `templates/dashboard.yml.j2` +- Create: `templates/engines/postgresql.yml.j2`, `mariadb.yml.j2`, `mssql.yml.j2`, `firebird.yml.j2`, `mongodb.yml.j2`, `redis.yml.j2`, `valkey.yml.j2` +- Create: `templates/engines.map.json` +- Move: `.github/assets/templates/agent.yml` → `templates/agent.yml`, `dashboard.yml` → `templates/dashboard.yml` +- Modify: `pyproject.toml` (`jinja2`), `.gitleaks.toml` + +**Interfaces:** +- Produces: contrat de contexte. + - `agent.yml.j2` : `host_gateway: bool`, `docker_socket: bool`, `mounts: list[{host, container}]`, `services: list[{name, volume, body}]`, `tz_var, edge_key_var, log_level_var, polling_var: str`. + - `dashboard.yml.j2` : `db_mode: "external"|"internal"|"custom"`, `project_name_var, host_port_var, tz_var, log_level_var, project_secret_var, project_url_var, pg_port_var, postgres_db_var, postgres_user_var, postgres_password_var: str`. + - `engines/*.yml.j2` : `name, volume, auth, port_var, db_var, user_var, password_var` (+ `root_password_var` firebird). + +- [ ] **Step 1: Ajouter Jinja2** + +Run: `uv add "jinja2>=3.1"` +Expected: `pyproject.toml` et `uv.lock` mis à jour. + +- [ ] **Step 2: Déplacer les templates legacy** + +Run: `mkdir -p templates/engines && git mv .github/assets/templates/agent.yml templates/agent.yml && git mv .github/assets/templates/dashboard.yml templates/dashboard.yml && rmdir .github/assets/templates 2>/dev/null; ls templates` + +- [ ] **Step 3: `templates/agent.yml.j2`** + +```jinja +services: + agent: + restart: unless-stopped + image: portabase/agent:latest + volumes: + - ./databases.json:/config/config.json +{%- for m in mounts %} + - {{ m.host }}:{{ m.container }} +{%- endfor %} +{%- if docker_socket %} + - /var/run/docker.sock:/var/run/docker.sock +{%- endif %} +{%- if host_gateway %} + extra_hosts: + - "localhost:host-gateway" +{%- endif %} + environment: + TZ: "{{ tz_var }}" + EDGE_KEY: "{{ edge_key_var }}" + LOG_LEVEL: "{{ log_level_var }}" + POLLING: "{{ polling_var }}" + networks: + - portabase +{% for s in services %} +{{ s.body }} +{%- endfor %} +{% if services %} +volumes: +{%- for s in services %} + {{ s.volume }}: +{%- endfor %} +{% endif %} +networks: + portabase: + name: portabase_network + external: true +``` + +- [ ] **Step 4: `templates/dashboard.yml.j2`** + +```jinja +name: {{ project_name_var }} +services: + portabase: + container_name: {{ project_name_var }}-app + image: portabase/portabase:latest + restart: unless-stopped + env_file: + - .env + ports: + - "{{ host_port_var }}:80" + environment: + - TZ={{ tz_var }} + - LOG_LEVEL={{ log_level_var }} + - PROJECT_SECRET={{ project_secret_var }} + - PROJECT_URL={{ project_url_var }} + volumes: + - portabase-data:/data +{%- if db_mode == "external" %} + depends_on: + db: + condition: service_healthy +{%- endif %} + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost/api/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s +{%- if db_mode == "external" %} + db: + container_name: {{ project_name_var }}-pg + image: postgres:17-alpine + restart: unless-stopped + ports: + - "{{ pg_port_var }}:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + - POSTGRES_DB={{ postgres_db_var }} + - POSTGRES_USER={{ postgres_user_var }} + - POSTGRES_PASSWORD={{ postgres_password_var }} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U {{ postgres_user_var }} -d {{ postgres_db_var }}"] + interval: 10s + timeout: 5s + retries: 5 +{%- endif %} +volumes: +{%- if db_mode == "external" %} + postgres-data: +{%- endif %} + portabase-data: +``` + +- [ ] **Step 5: Templates moteurs** + +`templates/engines/postgresql.yml.j2` : + +```jinja + {{ name }}: + image: postgres:17-alpine + restart: unless-stopped + networks: + - portabase + ports: + - "{{ port_var }}:5432" + volumes: + - {{ volume }}:/var/lib/postgresql/data + environment: + - POSTGRES_DB={{ db_var }} + - POSTGRES_USER={{ user_var }} + - POSTGRES_PASSWORD={{ password_var }} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U {{ user_var }} -d {{ db_var }}"] + interval: 10s + timeout: 5s + retries: 5 +``` + +`templates/engines/mariadb.yml.j2` : + +```jinja + {{ name }}: + image: mariadb:latest + restart: unless-stopped + networks: + - portabase + ports: + - "{{ port_var }}:3306" + environment: + - MYSQL_DATABASE={{ db_var }} + - MYSQL_USER={{ user_var }} + - MYSQL_PASSWORD={{ password_var }} + - MYSQL_RANDOM_ROOT_PASSWORD=yes + volumes: + - {{ volume }}:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "mariadb-admin ping -h localhost -u {{ user_var }} -p{{ password_var }}"] + interval: 10s + timeout: 5s + retries: 5 +``` + +`templates/engines/mssql.yml.j2` : + +```jinja + {{ name }}: + image: mcr.microsoft.com/azure-sql-edge:latest + restart: unless-stopped + networks: + - portabase + ports: + - "{{ port_var }}:1433" + environment: + - ACCEPT_EULA=Y + - MSSQL_SA_PASSWORD={{ password_var }} + volumes: + - {{ volume }}:/var/opt/mssql + healthcheck: + test: ["CMD-SHELL", "cat /proc/net/tcp6 | grep -q '059901' || exit 1"] + interval: 10s + timeout: 5s + retries: 20 +``` + +`templates/engines/firebird.yml.j2` : + +```jinja + {{ name }}: + image: firebirdsql/firebird + restart: unless-stopped + networks: + - portabase + ports: + - "{{ port_var }}:3050" + volumes: + - {{ volume }}:/var/lib/firebird/data + environment: + - FIREBIRD_DATABASE={{ db_var }} + - FIREBIRD_USER={{ user_var }} + - FIREBIRD_PASSWORD={{ password_var }} + - FIREBIRD_ROOT_PASSWORD={{ root_password_var }} + - FIREBIRD_DATABASE_DEFAULT_CHARSET=UTF8 + healthcheck: + test: ["CMD-SHELL", "nc -z localhost 3050"] + interval: 10s + timeout: 5s + retries: 5 +``` + +`templates/engines/mongodb.yml.j2` : + +```jinja + {{ name }}: + image: mongo:latest + restart: unless-stopped + networks: + - portabase + ports: + - "{{ port_var }}:27017" + environment: +{%- if auth %} + - MONGO_INITDB_ROOT_USERNAME={{ user_var }} + - MONGO_INITDB_ROOT_PASSWORD={{ password_var }} +{%- endif %} + - MONGO_INITDB_DATABASE={{ db_var }} +{%- if auth %} + command: mongod --auth +{%- endif %} + volumes: + - {{ volume }}:/data/db + healthcheck: + test: ["CMD-SHELL", "mongosh --eval 'db.runCommand({ping:1})' --quiet"] + interval: 10s + timeout: 5s + retries: 5 +``` + +`templates/engines/redis.yml.j2` : + +```jinja + {{ name }}: + image: redis:latest + restart: unless-stopped + ports: + - "{{ port_var }}:6379" + volumes: + - {{ volume }}:/data +{%- if auth %} + environment: + - REDIS_PASSWORD={{ password_var }} + command: ["redis-server", "--requirepass", "{{ password_var }}", "--appendonly", "yes"] +{%- else %} + command: ["redis-server", "--appendonly", "yes"] +{%- endif %} + networks: + - portabase + - default + healthcheck: + test: ["CMD-SHELL", "redis-cli {% if auth %}-a {{ password_var }} {% endif %}ping | grep PONG"] + interval: 10s + timeout: 5s + retries: 5 +``` + +`templates/engines/valkey.yml.j2` : + +```jinja + {{ name }}: + image: valkey/valkey:latest + restart: unless-stopped +{%- if auth %} + command: --requirepass "{{ password_var }}" +{%- else %} + environment: + - ALLOW_EMPTY_PASSWORD=yes +{%- endif %} + ports: + - "{{ port_var }}:6379" + volumes: + - {{ volume }}:/data + networks: + - portabase + - default + healthcheck: + test: ["CMD-SHELL", "valkey-cli {% if auth %}-a {{ password_var }} {% endif %}ping | grep PONG"] + interval: 10s + timeout: 5s + retries: 5 +``` + +Différence assumée vs snippets legacy : `restart: unless-stopped` ajouté sur redis et valkey (spec §5.7). + +- [ ] **Step 6: `templates/engines.map.json`** + +```json +{ + "postgresql": "engines/postgresql.yml.j2", + "postgresql-cluster": "engines/postgresql.yml.j2", + "mysql": "engines/mariadb.yml.j2", + "mariadb": "engines/mariadb.yml.j2", + "mssql": "engines/mssql.yml.j2", + "firebird": "engines/firebird.yml.j2", + "mongodb": "engines/mongodb.yml.j2", + "redis": "engines/redis.yml.j2", + "valkey": "engines/valkey.yml.j2" +} +``` + +- [ ] **Step 7: `.gitleaks.toml`** + +Retirer la ligne `'''\.github/assets/templates/.*''',` de `paths`. + +- [ ] **Step 8: Rendu manuel de contrôle** + +Run: `uv run python -c " +import jinja2, yaml +env = jinja2.Environment(loader=jinja2.FileSystemLoader('templates'), undefined=jinja2.StrictUndefined, keep_trailing_newline=True, autoescape=False) +body = env.get_template('engines/redis.yml.j2').render(name='db-redis-auth-ab12', volume='db-redis-auth-ab12-data', auth=True, port_var='\${DB_REDIS_AUTH_AB12_PORT}', db_var='', user_var='', password_var='\${DB_REDIS_AUTH_AB12_PASS}') +out = env.get_template('agent.yml.j2').render(host_gateway=True, docker_socket=True, mounts=[{'host':'./x.sqlite','container':'/config/x.sqlite'}], services=[{'name':'db-redis-auth-ab12','volume':'db-redis-auth-ab12-data','body':body}], tz_var='\${TZ}', edge_key_var='\${EDGE_KEY}', log_level_var='\${LOG_LEVEL}', polling_var='\${POLLING}') +print(out); d = yaml.safe_load(out); print(sorted(d['services']), d['volumes'], d['services']['agent']['extra_hosts']) +for mode in ('external','internal','custom'): + o = env.get_template('dashboard.yml.j2').render(db_mode=mode, project_name_var='pb', host_port_var='8887', tz_var='\${TZ}', log_level_var='\${LOG_LEVEL}', project_secret_var='\${PROJECT_SECRET}', project_url_var='\${PROJECT_URL}', pg_port_var='\${PG_PORT}', postgres_db_var='\${POSTGRES_DB}', postgres_user_var='\${POSTGRES_USER}', postgres_password_var='\${POSTGRES_PASSWORD}') + print(mode, sorted(yaml.safe_load(o)['services']), sorted(yaml.safe_load(o)['volumes']))"` +Expected: compose agent imprimé avec socket, extra_hosts, mount sqlite, service redis ; `['agent', 'db-redis-auth-ab12'] {'db-redis-auth-ab12-data': None} ['localhost:host-gateway']` ; dashboard `external ['db', 'portabase'] ['portabase-data', 'postgres-data']`, `internal ['portabase'] ['portabase-data']`, `custom ['portabase'] ['portabase-data']`. + +- [ ] **Step 9: Commit** + +```bash +git add templates/ pyproject.toml uv.lock .gitleaks.toml +git commit -m "feat(templates): add Jinja2 compose templates at repo root, move legacy templates" +``` + +--- + +### Task 6 : `services/templates.py` — `Manifest`, `TemplateRepository` + +**Files:** +- Create: `services/templates.py` + +**Interfaces:** +- Consumes: `HttpClient`, `GlobalConfig.cache_dir`, `core.version`, `TemplateError`. +- Produces: + - `Manifest(schema, version, files: dict[str, FileEntry], engines: dict[str, str], generated_at, commit)` avec `from_json(data)`, `from_directory(dir, version)`. + - `TemplateRepository(http, cache_dir, version, base_url=TEMPLATE_BASE_URL, local_dir: Path | None = None)` : `resolve() -> Path` (dossier prêt, fetch si besoin), `get(name) -> jinja2.Template`, `engine_template(key) -> jinja2.Template`, `manifest -> Manifest`, propriété `source: str` (`local` / `cache` / `remote`). + - `TemplateRepository.from_environment(http, config) -> TemplateRepository` : lit `PORTABASE_TEMPLATES_DIR`, `PORTABASE_TEMPLATES_VERSION`, détection dev (`./templates` à côté de `main.py` si non frozen). + - `TEMPLATE_BASE_URL` importée depuis `core/config.py` (inchangée). + +- [ ] **Step 1: Écrire le module** + +```python +"""Versioned remote templates with manifest verification and a local cache. + +Resolution order: explicit local dir (dev) → cache hit → remote fetch. No 'latest' fallback: +a CLI version only ever renders with the templates published for that exact version. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import sys +from dataclasses import dataclass +from pathlib import Path + +import jinja2 + +from core.config import TEMPLATE_BASE_URL, GlobalConfig +from core.errors import NetworkError, TemplateError +from core.version import UNKNOWN, current_version +from services.http import HttpClient + +MANIFEST_NAME = "manifest.json" +SUPPORTED_SCHEMA = 1 + + +@dataclass(frozen=True) +class FileEntry: + sha256: str + size: int + + +@dataclass(frozen=True) +class Manifest: + schema: int + version: str + files: dict[str, FileEntry] + engines: dict[str, str] + generated_at: str = "" + commit: str = "" + + @classmethod + def from_json(cls, data: dict) -> Manifest: + try: + schema = int(data["schema"]) + if schema != SUPPORTED_SCHEMA: + raise TemplateError( + f"Unsupported template manifest schema {schema} (this CLI supports {SUPPORTED_SCHEMA}).", + hint="Update the CLI: portabase update", + ) + files = { + name: FileEntry(sha256=str(e["sha256"]).lower(), size=int(e["size"])) + for name, e in data["files"].items() + } + return cls( + schema=schema, + version=str(data["version"]), + files=files, + engines=dict(data.get("engines", {})), + generated_at=str(data.get("generated_at", "")), + commit=str(data.get("commit", "")), + ) + except (KeyError, TypeError, ValueError) as e: + raise TemplateError("Template manifest is malformed.", cause=e) from e + + @classmethod + def from_directory(cls, directory: Path, version: str) -> Manifest: + """Manifest computed from a local directory (dev mode / render checks).""" + files = {} + for path in sorted(directory.rglob("*.j2")): + rel = path.relative_to(directory).as_posix() + files[rel] = FileEntry(sha256=_sha256(path), size=path.stat().st_size) + engines_map = directory / "engines.map.json" + engines = json.loads(engines_map.read_text(encoding="utf-8")) if engines_map.exists() else {} + return cls(schema=SUPPORTED_SCHEMA, version=version, files=files, engines=engines) + + +def _sha256(path: Path) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +class TemplateRepository: + def __init__( + self, + http: HttpClient, + cache_dir: Path, + version: str, + *, + base_url: str = TEMPLATE_BASE_URL, + local_dir: Path | None = None, + ) -> None: + self.http = http + self.version = version + self.base_url = base_url.rstrip("/") + self.local_dir = local_dir + self.cache_dir = cache_dir / "templates" / version + self._manifest: Manifest | None = None + self._env: jinja2.Environment | None = None + self.source = "unresolved" + + # ---- construction ----------------------------------------------------- + + @classmethod + def from_environment(cls, http: HttpClient, config: GlobalConfig) -> TemplateRepository: + version = os.environ.get("PORTABASE_TEMPLATES_VERSION") or current_version() + local = os.environ.get("PORTABASE_TEMPLATES_DIR") + local_dir = Path(local) if local else None + if local_dir is None and not getattr(sys, "frozen", False): + candidate = Path(__file__).resolve().parent.parent / "templates" + if (candidate / "agent.yml.j2").exists(): + local_dir = candidate + return cls(http, config.cache_dir, version, local_dir=local_dir) + + # ---- resolution ------------------------------------------------------- + + @property + def manifest(self) -> Manifest: + if self._manifest is None: + self.resolve() + assert self._manifest is not None + return self._manifest + + def resolve(self) -> Path: + """Ensure a verified template directory exists locally and return it.""" + if self.local_dir is not None: + if not (self.local_dir / "agent.yml.j2").exists(): + raise TemplateError(f"Template directory {self.local_dir} has no agent.yml.j2.") + self._manifest = Manifest.from_directory(self.local_dir, self.version) + self.source = "local" + return self.local_dir + + if self.version == UNKNOWN: + raise TemplateError( + "Cannot resolve template version (CLI version unknown).", + hint="Set PORTABASE_TEMPLATES_DIR to a local templates folder or PORTABASE_TEMPLATES_VERSION.", + ) + + remote_manifest = self._fetch_manifest() + if remote_manifest is None: + cached = self._cached_manifest() + if cached is None: + raise TemplateError( + f"Templates for version {self.version} are unavailable and not cached.", + hint="Check your internet connection, or set PORTABASE_TEMPLATES_DIR.", + ) + self._manifest = cached + self.source = "cache" + self._verify_cache_complete(cached) + return self.cache_dir + + if remote_manifest.version != self.version: + raise TemplateError( + f"Template manifest is for version {remote_manifest.version}, expected {self.version}." + ) + self._sync(remote_manifest) + self._manifest = remote_manifest + self.source = "remote" + return self.cache_dir + + # ---- access ----------------------------------------------------------- + + def get(self, name: str) -> jinja2.Template: + directory = self.resolve() + if name not in self.manifest.files: + raise TemplateError(f"Template '{name}' is not part of version {self.version}.") + if self._env is None: + self._env = jinja2.Environment( + loader=jinja2.FileSystemLoader(str(directory)), + undefined=jinja2.StrictUndefined, + keep_trailing_newline=True, + autoescape=False, + ) + try: + return self._env.get_template(name) + except jinja2.TemplateError as e: + raise TemplateError(f"Template '{name}' failed to load: {e}", cause=e) from e + + def engine_template(self, engine_key: str) -> jinja2.Template: + name = self.manifest.engines.get(engine_key) + if name is None: + raise TemplateError(f"No template mapped for engine '{engine_key}' in version {self.version}.") + return self.get(name) + + # ---- internals -------------------------------------------------------- + + def _url(self, name: str) -> str: + return f"{self.base_url}/{self.version}/{name}" + + def _fetch_manifest(self) -> Manifest | None: + try: + return Manifest.from_json(self.http.get_json(self._url(MANIFEST_NAME))) + except NetworkError: + return None + + def _cached_manifest(self) -> Manifest | None: + path = self.cache_dir / MANIFEST_NAME + if not path.exists(): + return None + try: + return Manifest.from_json(json.loads(path.read_text(encoding="utf-8"))) + except (OSError, ValueError, TemplateError): + return None + + def _verify_cache_complete(self, manifest: Manifest) -> None: + for name, entry in manifest.files.items(): + path = self.cache_dir / name + if not path.exists() or _sha256(path) != entry.sha256: + raise TemplateError( + f"Cached template '{name}' is missing or corrupt and the network is unavailable.", + hint="Reconnect and retry; the cache will be refreshed.", + ) + + def _sync(self, manifest: Manifest) -> None: + self.cache_dir.mkdir(parents=True, exist_ok=True) + for name, entry in manifest.files.items(): + path = self.cache_dir / name + if path.exists() and path.stat().st_size == entry.size and _sha256(path) == entry.sha256: + continue + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + try: + self.http.download(self._url(name), tmp) + except NetworkError as e: + raise TemplateError(f"Could not download template '{name}'.", cause=e) from e + if tmp.stat().st_size != entry.size or _sha256(tmp) != entry.sha256: + tmp.unlink(missing_ok=True) + raise TemplateError(f"Template '{name}' failed integrity check (sha256 mismatch).") + os.replace(tmp, path) + for stale in self.cache_dir.rglob("*.j2"): + if stale.relative_to(self.cache_dir).as_posix() not in manifest.files: + stale.unlink(missing_ok=True) + (self.cache_dir / MANIFEST_NAME).write_text( + json.dumps( + { + "schema": manifest.schema, + "version": manifest.version, + "generated_at": manifest.generated_at, + "commit": manifest.commit, + "files": {n: {"sha256": e.sha256, "size": e.size} for n, e in manifest.files.items()}, + "engines": manifest.engines, + }, + indent=2, + ), + encoding="utf-8", + ) +``` + +`TEMPLATE_BASE_URL` reste définie dans `core/config.py` (le legacy `core/network.py` l'importe de là) ; `services/templates.py` l'importe depuis `core.config`. Pas de circularité : `core` n'importe jamais `services`. + +- [ ] **Step 2: Vérifier en mode local (dev)** + +Run: `uv run python -c " +from pathlib import Path +from services.http import HttpClient +from services.templates import TemplateRepository +from core.config import GlobalConfig +r = TemplateRepository.from_environment(HttpClient(), GlobalConfig()) +print(r.source, r.resolve(), r.source, len(r.manifest.files), r.manifest.engines['mysql']) +print(r.engine_template('redis').render(name='n', volume='v', auth=False, port_var='1', db_var='', user_var='', password_var='')[:40].strip())"` +Expected: `unresolved /templates local 9 engines/mariadb.yml.j2` puis `n:` (début du service rendu). + +- [ ] **Step 3: Vérifier le mode remote contre un serveur local** + +```bash +# Terminal 1 — publie templates/ comme S3 sous la version 99.0.0 avec un manifest +mkdir -p /tmp/pb-s3/99.0.0 && cp -r templates/. /tmp/pb-s3/99.0.0/ && cd /tmp/pb-s3/99.0.0 && \ +uv --directory /home/soluce/Documents/PROJETS/Portabase/cli run python -c " +import json, hashlib, pathlib +files = {p.as_posix(): {'sha256': hashlib.sha256(p.read_bytes()).hexdigest(), 'size': p.stat().st_size} for p in sorted(pathlib.Path('.').rglob('*.j2'))} +json.dump({'schema':1,'version':'99.0.0','generated_at':'now','commit':'x','files':files,'engines':json.load(open('engines.map.json'))}, open('manifest.json','w'), indent=2)" && \ +cd /tmp/pb-s3 && python3 -m http.server 8765 +``` + +Terminal 2 : +```bash +uv run python -c " +import tempfile; from pathlib import Path +from services.http import HttpClient +from services.templates import TemplateRepository +cache = Path(tempfile.mkdtemp()) +r = TemplateRepository(HttpClient(), cache, '99.0.0', base_url='http://127.0.0.1:8765') +print(r.resolve(), r.source, sorted(p.name for p in (cache/'templates'/'99.0.0').iterdir())) +r2 = TemplateRepository(HttpClient(), cache, '99.0.0', base_url='http://127.0.0.1:8765'); r2.resolve(); print('second:', r2.source) +r3 = TemplateRepository(HttpClient(), cache, '99.0.0', base_url='http://127.0.0.1:1'); r3.resolve(); print('offline:', r3.source) +try: TemplateRepository(HttpClient(), Path(tempfile.mkdtemp()), '99.0.0', base_url='http://127.0.0.1:1').resolve() +except Exception as e: print('offline no cache:', type(e).__name__, e.code) +try: TemplateRepository(HttpClient(), cache, '98.0.0', base_url='http://127.0.0.1:8765').resolve() +except Exception as e: print('missing version:', type(e).__name__, e.code)" +``` +Expected: `... remote ['agent.yml.j2', 'dashboard.yml.j2', 'engines', 'manifest.json']`, `second: remote` (manifest re-fetché, fichiers en cache non re-téléchargés), `offline: cache`, `offline no cache: TemplateError E_TEMPLATE`, `missing version: TemplateError E_TEMPLATE`. Arrêter le serveur. + +- [ ] **Step 4: Commit** + +```bash +git add services/templates.py +git commit -m "feat(services): add TemplateRepository with manifest verification and cache" +``` + +--- + +### Task 7 : `scripts/render_check.py` + +**Files:** +- Create: `scripts/render_check.py` + +**Interfaces:** +- Consumes: `TemplateRepository` (mode local), `engines.registry`, `FixedPortAllocator`. +- Produces: script exécutable, exit 0 si tous les rendus sont du YAML valide (et `docker compose config` valide si Docker disponible), exit 1 sinon. Réutilisé par la CI (Task 8) et remplacé par un appel à `ComposeRenderer` au Plan 4. + +- [ ] **Step 1: Écrire le script** + +```python +#!/usr/bin/env python3 +"""Render every template with fixture contexts and validate the output. + +Usage: uv run python scripts/render_check.py [--templates DIR] [--no-compose] +Exit 0 on success. Prints one line per rendered case. +""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from core.config import GlobalConfig # noqa: E402 +from engines import registry # noqa: E402 +from services.http import HttpClient # noqa: E402 +from services.ports import FixedPortAllocator # noqa: E402 +from services.templates import TemplateRepository # noqa: E402 + +AGENT_GLOBALS = { + "tz_var": "${TZ}", + "edge_key_var": "${EDGE_KEY}", + "log_level_var": "${LOG_LEVEL}", + "polling_var": "${POLLING}", +} +AGENT_ENV = 'TZ="UTC"\nEDGE_KEY="x"\nLOG_LEVEL="info"\nPOLLING="5"\n' +DASHBOARD_VARS = { + "project_name_var": "pb", + "host_port_var": "${HOST_PORT}", + "tz_var": "${TZ}", + "log_level_var": "${LOG_LEVEL}", + "project_secret_var": "${PROJECT_SECRET}", + "project_url_var": "${PROJECT_URL}", + "pg_port_var": "${PG_PORT}", + "postgres_db_var": "${POSTGRES_DB}", + "postgres_user_var": "${POSTGRES_USER}", + "postgres_password_var": "${POSTGRES_PASSWORD}", +} +DASHBOARD_ENV = ( + 'HOST_PORT="8887"\nTZ="UTC"\nLOG_LEVEL="info"\nPROJECT_SECRET="s"\nPROJECT_URL="http://localhost"\n' + 'PG_PORT="5433"\nPOSTGRES_DB="pb"\nPOSTGRES_USER="pb"\nPOSTGRES_PASSWORD="p"\n' +) + + +class Failure(Exception): + pass + + +def validate(label: str, compose: str, env_text: str, use_compose: bool) -> None: + try: + doc = yaml.safe_load(compose) + except yaml.YAMLError as e: + raise Failure(f"{label}: invalid YAML: {e}\n{compose}") from e + if not isinstance(doc, dict) or "services" not in doc: + raise Failure(f"{label}: no services key\n{compose}") + if use_compose: + with tempfile.TemporaryDirectory() as tmp: + Path(tmp, "docker-compose.yml").write_text(compose, encoding="utf-8") + Path(tmp, ".env").write_text(env_text, encoding="utf-8") + Path(tmp, "databases.json").write_text('{"databases": []}', encoding="utf-8") + proc = subprocess.run( + ["docker", "compose", "-p", "rendercheck", "config", "--quiet"], + cwd=tmp, + capture_output=True, + text=True, + check=False, + ) + if proc.returncode != 0: + raise Failure(f"{label}: docker compose config failed:\n{proc.stderr}\n{compose}") + print(f"ok {label}") + + +def agent_cases(repo: TemplateRepository) -> list[tuple[str, str, str]]: + ports = FixedPortAllocator() + cases = [] + # 1. empty agent, all toggles off + cases.append(("agent/empty", render_agent(repo, [], False, False, []), AGENT_ENV)) + # 2. toggles on, sqlite mount + cases.append( + ( + "agent/toggles", + render_agent(repo, [], True, True, [{"host": "./x.sqlite", "container": "/config/x.sqlite"}]), + AGENT_ENV, + ) + ) + # 3. one case per engine/variant + all_services, all_env = [], AGENT_ENV + for engine in registry: + if engine.template is None: + continue + for auth in (True, False) if engine.auth_variants else (True,): + spec = engine.generate(auth=auth, ports=ports, answers={}) + env = engine.env_vars(spec) + env_text = AGENT_ENV + "".join(f'{k}="{v}"\n' for k, v in env.items()) + body = repo.engine_template(engine.key).render(**engine.template_ctx(spec)) + service = {"name": spec.host, "volume": f"{spec.host}-data", "body": body} + cases.append((f"agent/{engine.key}{'/auth' if auth else '/noauth' if engine.auth_variants else ''}", + render_agent(repo, [service], False, False, []), env_text)) + all_services.append(service) + all_env += "".join(f'{k}="{v}"\n' for k, v in env.items()) + # 4. everything at once + cases.append(("agent/all", render_agent(repo, all_services, True, True, []), all_env)) + return cases + + +def render_agent(repo, services, host_gateway, docker_socket, mounts) -> str: + return repo.get("agent.yml.j2").render( + services=services, host_gateway=host_gateway, docker_socket=docker_socket, mounts=mounts, **AGENT_GLOBALS + ) + + +def dashboard_cases(repo: TemplateRepository) -> list[tuple[str, str, str]]: + return [ + (f"dashboard/{mode}", repo.get("dashboard.yml.j2").render(db_mode=mode, **DASHBOARD_VARS), DASHBOARD_ENV) + for mode in ("external", "internal", "custom") + ] + + +def engines_check(repo: TemplateRepository) -> None: + mapped = repo.manifest.engines + for engine in registry: + if engine.template is None: + continue + if mapped.get(engine.key) != engine.template: + raise Failure(f"engines.map.json: {engine.key} -> {mapped.get(engine.key)} but code says {engine.template}") + if engine.template not in repo.manifest.files: + raise Failure(f"{engine.key}: template {engine.template} not found") + for key in mapped: + if key not in registry: + raise Failure(f"engines.map.json maps unknown engine '{key}'") + print(f"ok engines-check ({len(mapped)} mapped)") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--templates", default=os.environ.get("PORTABASE_TEMPLATES_DIR", "templates")) + parser.add_argument("--no-compose", action="store_true", help="Skip docker compose config validation") + args = parser.parse_args() + + use_compose = not args.no_compose and shutil.which("docker") is not None + if not use_compose: + print("note: docker not available, YAML validation only") + repo = TemplateRepository(HttpClient(), GlobalConfig().cache_dir, "local", local_dir=Path(args.templates)) + try: + engines_check(repo) + for label, compose, env_text in agent_cases(repo) + dashboard_cases(repo): + validate(label, compose, env_text, use_compose) + except Failure as e: + print(f"FAIL {e}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) +``` + +- [ ] **Step 2: Exécuter** + +Run: `uv run python scripts/render_check.py` +Expected: `ok engines-check (9 mapped)` puis une ligne `ok` par cas : `agent/empty`, `agent/toggles`, `agent/postgresql`, `agent/postgresql-cluster`, `agent/mysql`, `agent/mariadb`, `agent/firebird`, `agent/mongodb/auth`, `agent/mongodb/noauth`, `agent/redis/auth`, `agent/redis/noauth`, `agent/valkey/auth`, `agent/valkey/noauth`, `agent/mssql`, `agent/all`, `dashboard/external`, `dashboard/internal`, `dashboard/custom`. Exit 0. + +Si `docker compose config` échoue sur un cas : lire l'erreur, corriger le template (pas le script). + +- [ ] **Step 3: Ruff sur le script** + +Run: `uv run ruff check scripts/ && uv run ruff format scripts/` + +- [ ] **Step 4: Commit** + +```bash +git add scripts/render_check.py +git commit -m "ci: add render_check script validating every template with fixtures" +``` + +--- + +### Task 8 : CI — `render-check`, `engines-check`, manifest à l'upload, hotfix + +**Files:** +- Modify: `.github/workflows/ci.yml` +- Modify: `.github/workflows/templates-upload.yml` +- Create: `.github/workflows/templates-hotfix.yml` + +- [ ] **Step 1: Ajouter le job `render-check` à `ci.yml`** (après `test`) + +```yaml + render-check: + name: render-check + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 + - name: Install + run: uv sync --frozen --all-groups + - name: Render and validate templates (YAML + docker compose config) + run: uv run python scripts/render_check.py --templates templates +``` + +Le job `engines-check` de la spec est couvert par la fonction `engines_check()` du même script (une seule exécution, deux vérifications). Pas de job séparé. + +- [ ] **Step 2: `templates-upload.yml` — source et manifest** + +Remplacer les deux étapes d'upload par : + +```yaml + - name: Generate manifest + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + CLEAN_VERSION="${VERSION#v}" + cd templates + FILES=$(find . -name '*.j2' -type f | sort | while read -r f; do + rel="${f#./}" + printf '{"%s":{"sha256":"%s","size":%s}}\n' "$rel" "$(sha256sum "$f" | cut -d' ' -f1)" "$(stat -c%s "$f")" + done | jq -s 'add') + jq -n \ + --arg version "$CLEAN_VERSION" \ + --arg commit "$GITHUB_SHA" \ + --arg date "$(date -u +%FT%TZ)" \ + --argjson files "$FILES" \ + --argjson engines "$(cat engines.map.json)" \ + '{schema:1, version:$version, generated_at:$date, commit:$commit, files:$files, engines:$engines}' \ + > manifest.json + cat manifest.json + + - name: Upload versioned templates + env: + VERSION: ${{ inputs.version }} + run: | + CLEAN_VERSION="${VERSION#v}" + s3cmd $S3CMD_ARGS sync templates/ \ + "s3://${{ secrets.S3_BUCKET }}/cli/public/templates/${CLEAN_VERSION}/" --acl-public --delete-removed + + - name: Upload latest templates (stable only, legacy fallback) + if: ${{ !inputs.is_prerelease }} + run: | + s3cmd $S3CMD_ARGS sync templates/ \ + "s3://${{ secrets.S3_BUCKET }}/cli/public/templates/latest/" --acl-public +``` + +`latest/` reste alimenté pour les vieux binaires (fallback legacy). Le nouveau code ne le lit jamais. À retirer quand plus aucune version legacy n'est supportée. + +- [ ] **Step 3: `templates-hotfix.yml`** + +```yaml +name: Templates hotfix + +on: + workflow_dispatch: + inputs: + version: + description: "Existing CLI version to re-publish templates for (e.g. 26.09.0). Templates must stay compatible with that version's code." + required: true + type: string + +permissions: {} + +jobs: + hotfix: + uses: ./.github/workflows/templates-upload.yml + with: + version: ${{ inputs.version }} + is_prerelease: true # never touch latest/ from a hotfix + secrets: + S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }} + S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} + S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} + S3_BUCKET: ${{ secrets.S3_BUCKET }} +``` + +- [ ] **Step 4: Valider les YAML** + +Run: `for f in .github/workflows/ci.yml .github/workflows/templates-upload.yml .github/workflows/templates-hotfix.yml; do uv run python -c "import yaml,sys; yaml.safe_load(open('$f')); print('ok $f')"; done` + +- [ ] **Step 5: Tester la génération du manifest en local** + +Run: `cd templates && FILES=$(find . -name '*.j2' -type f | sort | while read -r f; do rel="${f#./}"; printf '{"%s":{"sha256":"%s","size":%s}}\n' "$rel" "$(sha256sum "$f" | cut -d' ' -f1)" "$(stat -c%s "$f")"; done | jq -s 'add') && jq -n --arg version 0.0.0 --arg commit x --arg date now --argjson files "$FILES" --argjson engines "$(cat engines.map.json)" '{schema:1, version:$version, generated_at:$date, commit:$commit, files:$files, engines:$engines}' | uv run python -c "import json,sys; from services.templates import Manifest; m = Manifest.from_json(json.load(sys.stdin)); print(len(m.files), 'files,', len(m.engines), 'engines')"; cd ..` +Expected: `9 files, 9 engines`. + +- [ ] **Step 6: Commit** + +```bash +git add .github/workflows/ +git commit -m "ci: render-check job, manifest generation on template upload, hotfix workflow" +``` + +--- + +### Task 9 : PR et release candidate + +- [ ] **Step 1: Lint complet et PR** + +Run: `uv run ruff check . && uv run ruff format --check . && uv run python scripts/render_check.py --no-compose` +Puis : +```bash +git checkout -b refactor/templates-engines +git push -u origin refactor/templates-engines +``` +PR « feat: Jinja2 templates, engine registry, template repository ». Checks attendus verts : `lint`, `test`, `render-check`, `gitleaks`, `plumber`, `build-smoke`. + +- [ ] **Step 2: Release candidate** + +Après merge : Bump version `26.08.0rc2` (ou suivant), channel `rc`. Vérifier sur S3 que `cli/public/templates/26.08.0rc2/` contient `manifest.json`, `agent.yml.j2`, `engines/`, **et** `agent.yml` / `dashboard.yml` legacy. Installer le binaire rc et lancer `portabase agent test-rc` (code legacy) : doit fonctionner comme avant (fetch `agent.yml` sous la version exacte). + +--- + +## Self-review + +**Spec coverage :** +- §5.4 `TemplateRepository` : résolution version/env/dev ✔, cache ✔, manifest sha256+size ✔, suppression fichiers obsolètes ✔, pas de `latest` côté client ✔, Jinja2 `StrictUndefined` ✔, schéma inconnu → `TemplateError` ✔, version ≠ → `TemplateError` ✔. +- §5.6 templates : `agent.yml.j2` avec `mounts`, `docker_socket`, `host_gateway`, `services`, `volumes` ✔ ; moteurs avec `{% if auth %}` ✔ ; `dashboard.yml.j2` avec `db_mode` ✔. +- §6 moteurs : hiérarchie, hooks, registre imports explicites ✔ ; `agent_database` hook ✔ ; Redis/Valkey séparés ✔ ; `describe` pour `db list` (Plan 4). +- §6.1 options : `option_fields`, `non_default_options`, projection ✔ ; parsing `-o` et prompts → Plan 4 (flow). +- §9.1 `render-check` ✔, `engines-check` (fusionné dans le script) ✔. §9.3 manifest ✔, hotfix ✔. +- §10 D : shippable en rc, legacy intact ✔. + +**Placeholders :** aucun. + +**Cohérence :** `DbEngine.template_ctx` produit `name/volume/auth/*_var` = variables consommées par tous les `.j2` ✔ ; `FirebirdEngine.template_ctx` ajoute `root_password_var` consommé par `firebird.yml.j2` ✔ ; `render_check.render_agent` passe `AGENT_GLOBALS` = variables `*_var` de `agent.yml.j2` ✔ ; `Manifest.engines` clé → `TemplateRepository.engine_template` ✔ ; `FixedPortAllocator` défini Task 1, utilisé Task 7 ✔. + +**Écarts connus :** +- `DatabaseSpec.host_port` est `None` pour les specs chargées depuis une install legacy tant que Plan 4 ne lit pas `.env` ; sans effet ici. diff --git a/docs/superpowers/plans/2026-09-11-plan-4-render-commands.md b/docs/superpowers/plans/2026-09-11-plan-4-render-commands.md new file mode 100644 index 0000000..c4c183b --- /dev/null +++ b/docs/superpowers/plans/2026-09-11-plan-4-render-commands.md @@ -0,0 +1,1797 @@ +# Plan 4 — Rendu déclaratif et commandes agent / dashboard / db / build (chantiers E + F) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remplacer le code legacy (`agent.py`, `db.py`, `dashboard.py`, chirurgie texte du compose) par un rendu complet depuis l'état (`.env` + `databases.json` + faits du compose), un flux d'ajout de base partagé, des commandes entièrement pilotables par flags, et la commande `build`. Fin de la refonte : plus aucun `LegacyCommand`, plus de `console` global, plus de fallback `latest`. + +**Architecture:** `AgentProject`/`DashboardProject` chargent l'état depuis le dossier ; `ComposeRenderer` produit `docker-compose.yml` (+ `databases.json`) via Jinja2 ; `RenderResult.write()` valide, sauvegarde un compose legacy en `.legacy.yml`, écrit atomiquement. `AddDatabaseFlow` collecte un `DatabaseSpec` (flags → prompts → défauts) et mute le projet ; `AgentCommand` et `DbAddCommand` l'utilisent tous deux. + +**Tech Stack:** Python 3.12, Typer, Jinja2, PyYAML, questionary/Rich via `ui/`. + +**Spec:** `docs/superpowers/specs/2026-09-11-cli-refactor-design.md` — sections 4.2, 4.3, 5.1–5.3, 5.5, 5.7, 6.1, 7.2 (Summary, DataTable, Diff), 10 (E, F). + +## Global Constraints + +- Prérequis : Plans 1–3 exécutés. +- Règle de dépendance descendante (`commands → services, engines, ui, core` ; `services → engines, core` ; `flows` dans `commands/`). +- `.env` ne contient que des variables consommées par les conteneurs. Aucune clé `PORTABASE_*`. +- `databases.json` conserve exactement le format legacy (projection par `DbEngine.agent_entry`). +- Détection `managed` : `.env` contient `{PREFIX}_PORT` pour le `host` de l'entrée. +- Le compose généré porte l'en-tête `# Generated by Portabase CLI . Do not edit — use docker-compose.override.yml.` ; un compose sans cet en-tête est sauvegardé en `docker-compose.legacy.yml` avant la première réécriture (une seule fois). +- Toute commande mutante : `templates.resolve()` **avant** de muter quoi que ce soit. +- Pas de tests unitaires. Vérifications exécutables par commande, en interactif et en `--non-interactive`, sur une install neuve et sur une install legacy générée avec le binaire 26.07.6. +- Ce plan supprime : `commands/common.py` (déjà), `core/network.py`, `core/docker.py`, `templates/compose.py`, `templates/__init__.py`, `templates/agent.yml`, `templates/dashboard.yml`, `LegacyCommand`, les fonctions legacy de `core/config.py`, `console`/`print_banner`/`HINTS`/`check_system`/`start_docker`/`validate_work_dir`/`get_free_port`/`get_random_hint` de `core/utils.py`, toutes les `per-file-ignores` ruff. + +--- + +## File Structure + +| Fichier | Action | Responsabilité | +|---|---|---| +| `engines/base.py` | modifier | `label_default` | +| `services/envfile.py` | créer | `EnvFile` | +| `services/compose_facts.py` | créer | `ComposeFacts` | +| `services/project.py` | créer | `AgentProject`, `DashboardProject`, `ProjectKind`, `detect_kind`, `spec_from_entry` | +| `services/renderer.py` | créer | `ComposeRenderer`, `RenderResult`, `WriteReport` | +| `services/docker.py` | modifier | `remove_volume` | +| `ui/components/summary.py`, `table.py`, `diff.py` | créer | composants | +| `ui/__init__.py` | modifier | `summary`, `table`, `diff` | +| `commands/flows/__init__.py`, `add_database.py` | créer | `AddDatabaseFlow` | +| `commands/agent.py` | réécrire | `AgentCommand` | +| `commands/dashboard.py` | réécrire | `DashboardCommand` | +| `commands/db.py` | réécrire | `DbCommands` (`add`, `remove`, `list`) | +| `commands/build.py` | créer | `BuildCommand` | +| `commands/base.py` | modifier | retirer `LegacyCommand` | +| `main.py` | modifier | câblage final | +| `core/config.py`, `core/utils.py` | modifier | retirer le legacy | +| `scripts/render_check.py` | modifier | utiliser `ComposeRenderer` | +| `.github/workflows/ci.yml` | modifier | `build-smoke` avec `agent --non-interactive` | +| `pyproject.toml` | modifier | retirer `per-file-ignores` | +| `README.md` | modifier | note migration | + +--- + +### Task 1 : `EnvFile` + +**Files:** +- Create: `services/envfile.py` + +**Interfaces:** +- Produces: `EnvFile(path)` : `load() -> EnvFile` (classmethod `EnvFile.load(path)`), `get(key, default=None)`, `set(key, value)`, `merge(mapping)`, `remove(key)`, `remove_prefix(prefix)`, `as_dict() -> dict[str, str]`, `save()`, `exists`. Préserve ordre, commentaires, lignes vides. + +- [ ] **Step 1: Écrire le module** + +```python +"""Dotenv file kept as a list of lines so comments and order survive rewrites. + +Only container runtime variables live here. Values are always written double-quoted. +""" + +from __future__ import annotations + +import os +import re +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path + +_LINE = re.compile(r"""^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$""") + + +def _unquote(raw: str) -> str: + if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "\"'": + inner = raw[1:-1] + if raw[0] == '"': + return inner.replace('\\"', '"').replace("\\\\", "\\") + return inner + # unquoted: strip trailing comment + return raw.split(" #", 1)[0].rstrip() + + +def _quote(value: str) -> str: + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +@dataclass +class EnvFile: + path: Path + _lines: list[str] = field(default_factory=list) # raw lines, without newline + _index: dict[str, int] = field(default_factory=dict) # key -> line number + + @classmethod + def load(cls, path: Path) -> EnvFile: + env = cls(path) + if path.exists(): + text = path.read_text(encoding="utf-8") + env._lines = text.splitlines() + for i, line in enumerate(env._lines): + m = _LINE.match(line) + if m and not line.lstrip().startswith("#"): + env._index[m.group(1)] = i + return env + + @property + def exists(self) -> bool: + return self.path.exists() + + def get(self, key: str, default: str | None = None) -> str | None: + i = self._index.get(key) + if i is None: + return default + m = _LINE.match(self._lines[i]) + return _unquote(m.group(2)) if m else default + + def as_dict(self) -> dict[str, str]: + return {k: self.get(k) or "" for k in self._index} + + def set(self, key: str, value: str) -> None: + line = f"{key}={_quote(str(value))}" + i = self._index.get(key) + if i is None: + self._lines.append(line) + self._index[key] = len(self._lines) - 1 + else: + self._lines[i] = line + + def merge(self, mapping: Mapping[str, str]) -> None: + for k, v in mapping.items(): + self.set(k, v) + + def remove(self, key: str) -> None: + i = self._index.pop(key, None) + if i is None: + return + del self._lines[i] + self._index = {k: (n - 1 if n > i else n) for k, n in self._index.items()} + + def remove_prefix(self, prefix: str) -> None: + for key in [k for k in self._index if k.startswith(prefix + "_")]: + self.remove(key) + + def save(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_suffix(".env.tmp") + tmp.write_text("\n".join(self._lines) + "\n", encoding="utf-8") + os.replace(tmp, self.path) +``` + +- [ ] **Step 2: Vérifier** + +Run: `uv run python -c " +import tempfile; from pathlib import Path +from services.envfile import EnvFile +p = Path(tempfile.mkdtemp())/'.env' +p.write_text('# header\nTZ=\"UTC\"\nEDGE_KEY=\"a=b\"\n\nDB_PG_A1_PORT=\"5433\"\nDB_PG_A1_PASS=\"p\\\"q\"\nCUSTOM=plain # note\n') +e = EnvFile.load(p); print(e.get('TZ'), e.get('EDGE_KEY'), e.get('DB_PG_A1_PASS'), e.get('CUSTOM')) +e.set('TZ','Europe/Paris'); e.merge({'NEW':'x'}); e.remove_prefix('DB_PG_A1'); e.save() +print(p.read_text())"` +Expected: `UTC a=b p"q plain` puis le fichier avec `# header`, `TZ="Europe/Paris"`, `EDGE_KEY`, ligne vide conservée, `CUSTOM` réécrit tel quel, `NEW="x"` en fin, plus aucune `DB_PG_A1_*`. + +- [ ] **Step 3: Commit** + +```bash +git add services/envfile.py +git commit -m "feat(services): add EnvFile preserving order and comments" +``` + +--- + +### Task 2 : `ComposeFacts`, `project.py`, `label_default` + +**Files:** +- Create: `services/compose_facts.py` +- Create: `services/project.py` +- Modify: `engines/base.py` (ajouter `label_default = "External DB"` ; `DockerVolumeEngine.label_default = "Docker Volume"` dans `engines/docker_volume.py`) + +**Interfaces:** +- Produces: + - `ComposeFacts(path)` : `exists`, `is_generated` (en-tête présent), `host_gateway -> bool`, `raw -> dict`. + - `ProjectKind = Literal["agent", "dashboard"]`, `detect_kind(path) -> ProjectKind` (`ConfigError` sinon). + - `spec_from_entry(entry: dict, env: EnvFile) -> DatabaseSpec`. + - `AgentProject(path, env, databases, host_gateway)` : `load(path)`, `create(path, env_vars: dict, host_gateway)`, `managed`, `needs_docker_socket`, `sqlite_mounts`, `add(spec, engine)`, `remove(spec, engine)`, `find(id_or_name) -> DatabaseSpec`, `save_state()` (écrit `.env` seulement ; `databases.json` est rendu). + - `DashboardProject(path, env)` : `load(path)`, `create(path, env_vars)`, `db_mode`, `save_state()`. + +- [ ] **Step 1: `services/compose_facts.py`** + +```python +"""Read-only structural facts from an existing docker-compose.yml. Never writes.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +GENERATED_MARKER = "# Generated by Portabase CLI" + + +class ComposeFacts: + def __init__(self, path: Path) -> None: + self.path = path + self.raw: dict = {} + self.text = "" + if path.exists(): + try: + self.text = path.read_text(encoding="utf-8") + loaded = yaml.safe_load(self.text) + self.raw = loaded if isinstance(loaded, dict) else {} + except (OSError, yaml.YAMLError): + self.raw = {} + + @property + def exists(self) -> bool: + return self.path.exists() + + @property + def is_generated(self) -> bool: + return self.text.startswith(GENERATED_MARKER) + + def _service(self, name: str) -> dict: + services = self.raw.get("services") or {} + svc = services.get(name) if isinstance(services, dict) else None + return svc if isinstance(svc, dict) else {} + + @property + def host_gateway(self) -> bool: + extra = self._service("agent").get("extra_hosts") + if isinstance(extra, list): + return any("host-gateway" in str(x) for x in extra) + if isinstance(extra, dict): + return any("host-gateway" in str(v) for v in extra.values()) + return False +``` + +- [ ] **Step 2: `services/project.py`** + +```python +"""Project state loaded from .env + databases.json + compose facts. Nothing else is stored.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal + +from core.errors import ConfigError, ValidationError +from core.specs import DatabaseSpec +from engines.base import DbEngine +from engines.sqlite import SqliteEngine +from services.compose_facts import ComposeFacts +from services.envfile import EnvFile + +ProjectKind = Literal["agent", "dashboard"] +DATABASES_FILE = "databases.json" +COMPOSE_FILE = "docker-compose.yml" +ENV_FILE = ".env" + + +def detect_kind(path: Path) -> ProjectKind: + if (path / DATABASES_FILE).exists(): + return "agent" + env = EnvFile.load(path / ENV_FILE) + if env.get("PROJECT_SECRET") is not None: + return "dashboard" + raise ConfigError( + f"{path} is not a Portabase agent or dashboard folder.", + hint="Expected databases.json (agent) or a .env with PROJECT_SECRET (dashboard).", + ) + + +def spec_from_entry(entry: dict[str, Any], env: EnvFile) -> DatabaseSpec: + engine = str(entry.get("type", "")) + host = entry.get("host") + managed, host_port, root_password = False, None, None + if host: + prefix = str(host).upper().replace("-", "_") + raw_port = env.get(f"{prefix}_PORT") + if raw_port and raw_port.isdigit(): + managed, host_port = True, int(raw_port) + root_password = env.get(f"{prefix}_ROOT_PASS") + return DatabaseSpec( + id=str(entry.get("generated_id") or DbEngine.new_id()), + engine=engine, + name=str(entry.get("name", "")), + managed=managed, + host=str(host) if host else None, + port=int(entry["port"]) if entry.get("port") not in (None, "") else None, + host_port=host_port, + database=str(entry["database"]) if entry.get("database") is not None else None, + username=str(entry["username"]) if entry.get("username") is not None else None, + password=str(entry["password"]) if entry.get("password") not in (None, "") else None, + root_password=root_password, + path=str(entry["database"]) if engine == "sqlite" and entry.get("database") else None, + volume=str(entry["volume_name"]) if entry.get("volume_name") else None, + container=str(entry["container_name"]) if entry.get("container_name") else None, + options=dict(entry.get("options") or {}), + ) + + +@dataclass +class AgentProject: + path: Path + env: EnvFile + databases: list[DatabaseSpec] = field(default_factory=list) + host_gateway: bool = False + + # ---- construction ----------------------------------------------------- + + @classmethod + def load(cls, path: Path) -> AgentProject: + path = path.resolve() + env_path, db_path = path / ENV_FILE, path / DATABASES_FILE + if not env_path.exists() or not db_path.exists(): + raise ConfigError( + f"Not a Portabase agent folder: {path}", + hint=f"Expected {ENV_FILE} and {DATABASES_FILE}.", + ) + env = EnvFile.load(env_path) + try: + data = json.loads(db_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as e: + raise ConfigError(f"{db_path} is not valid JSON.", cause=e) from e + entries = data.get("databases", []) if isinstance(data, dict) else [] + databases = [spec_from_entry(e, env) for e in entries if isinstance(e, dict)] + project = cls(path, env, databases, ComposeFacts(path / COMPOSE_FILE).host_gateway) + project.validate() + return project + + @classmethod + def create(cls, path: Path, env_vars: dict[str, str], *, host_gateway: bool) -> AgentProject: + path.mkdir(parents=True, exist_ok=True) + env = EnvFile.load(path / ENV_FILE) + env.merge(env_vars) + return cls(path, env, [], host_gateway) + + # ---- derived facts ---------------------------------------------------- + + @property + def managed(self) -> list[DatabaseSpec]: + return [d for d in self.databases if d.managed] + + @property + def needs_docker_socket(self) -> bool: + return any(d.engine == "docker-volume" for d in self.databases) + + @property + def sqlite_mounts(self) -> list[tuple[str, str]]: + mounts = [] + for d in self.databases: + if d.engine == "sqlite": + m = SqliteEngine.mount_for(d) + if m and m not in mounts: + mounts.append(m) + return mounts + + def validate(self) -> None: + seen: set[str] = set() + for d in self.managed: + if d.host in seen: + raise ConfigError(f"Two managed databases share the service name '{d.host}'.") + seen.add(d.host or "") + + # ---- mutation --------------------------------------------------------- + + def add(self, spec: DatabaseSpec, engine: DbEngine) -> None: + if spec.managed: + self.env.merge(engine.env_vars(spec)) + self.databases.append(spec) + self.validate() + + def remove(self, spec: DatabaseSpec, engine: DbEngine) -> None: + self.databases = [d for d in self.databases if d.id != spec.id] + if spec.managed and spec.host: + self.env.remove_prefix(spec.env_prefix) + + def find(self, id_or_name: str) -> DatabaseSpec: + matches = [d for d in self.databases if d.id == id_or_name or d.id.startswith(id_or_name) or d.name == id_or_name] + if not matches: + raise ValidationError(f"No database matching '{id_or_name}'.", hint="See: portabase db list") + if len(matches) > 1: + raise ValidationError(f"'{id_or_name}' matches several databases; use the id.") + return matches[0] + + def save_state(self) -> None: + self.env.save() + + +@dataclass +class DashboardProject: + path: Path + env: EnvFile + + @classmethod + def load(cls, path: Path) -> DashboardProject: + path = path.resolve() + env = EnvFile.load(path / ENV_FILE) + if env.get("PROJECT_SECRET") is None: + raise ConfigError(f"Not a Portabase dashboard folder: {path}", hint="Expected a .env with PROJECT_SECRET.") + return cls(path, env) + + @classmethod + def create(cls, path: Path, env_vars: dict[str, str]) -> DashboardProject: + path.mkdir(parents=True, exist_ok=True) + env = EnvFile.load(path / ENV_FILE) + env.merge(env_vars) + return cls(path, env) + + @property + def db_mode(self) -> Literal["external", "internal", "custom"]: + host = self.env.get("POSTGRES_HOST") + if host is None: + return "internal" + return "external" if host == "db" else "custom" + + @property + def project_name(self) -> str: + return self.env.get("PROJECT_NAME") or self.path.name + + def save_state(self) -> None: + self.env.save() +``` + +- [ ] **Step 3: `label_default`** + +Dans `engines/base.py`, après `has_modes: bool = True` : `label_default: str = "External DB"`. Dans `engines/docker_volume.py`, après `has_modes = False` : `label_default = "Docker Volume"`. Remplacer dans `from_existing` de `base.py` et `sqlite.py` `or "External DB"` par `or self.label_default`, et dans `docker_volume.py` `or "Docker Volume"` par `or self.label_default`. + +- [ ] **Step 4: Vérifier sur une install legacy réelle** + +Générer une install avec le binaire 26.07.6 (télécharger depuis la release GitHub) ou avec `git stash`/checkout du tag : + +```bash +cd /tmp && rm -rf legacy-agent && git -C /home/soluce/Documents/PROJETS/Portabase/cli stash -u -q; git -C /home/soluce/Documents/PROJETS/Portabase/cli checkout -q 26.07.6 +uv --directory /home/soluce/Documents/PROJETS/Portabase/cli run python /home/soluce/Documents/PROJETS/Portabase/cli/main.py agent legacy-agent --key "$(printf '{"serverUrl":"http://x","agentId":"a","masterKeyB64":"k"}' | base64 -w0)" +``` +Au wizard : tz `UTC`, polling `5`, extra_hosts `y`, puis `database` → `new` → `postgresql` (ownership `n`, clean `clean`), puis `database` → `new` → `redis` → `with-auth`, puis `database` → `existing` → `sqlite` → `Display` / path `ext.sqlite`, puis `docker-volume` (`Vol`, `myvol`, container vide), puis `done`, ne pas démarrer. + +```bash +git -C /home/soluce/Documents/PROJETS/Portabase/cli checkout -q main; git -C /home/soluce/Documents/PROJETS/Portabase/cli stash pop -q +uv --directory /home/soluce/Documents/PROJETS/Portabase/cli run python -c " +from pathlib import Path +from services.project import AgentProject, detect_kind +p = AgentProject.load(Path('/tmp/legacy-agent')) +print(detect_kind(p.path), 'gateway:', p.host_gateway, 'socket:', p.needs_docker_socket, 'mounts:', p.sqlite_mounts) +for d in p.databases: print(f'{d.engine:14} managed={d.managed!s:5} host={d.host} host_port={d.host_port} db={d.database} opts={d.options}')" +``` +Expected: `agent gateway: True socket: True mounts: [('./ext.sqlite', '/config/ext.sqlite')]` ; postgresql `managed=True host=db-pg-xxxx host_port=` ; redis `managed=True host=db-redis-auth-xxxx` ; sqlite `managed=False` ; docker-volume `managed=False`. + +- [ ] **Step 5: Commit** + +```bash +git add services/compose_facts.py services/project.py engines/base.py engines/sqlite.py engines/docker_volume.py +git commit -m "feat(services): add ComposeFacts and project state loaded from .env, databases.json and compose" +``` + +--- + +### Task 3 : `ComposeRenderer` et `RenderResult` + +**Files:** +- Create: `services/renderer.py` +- Modify: `services/docker.py` (ajouter `remove_volume`) + +**Interfaces:** +- Consumes: `TemplateRepository`, `EngineRegistry`, `AgentProject`, `DashboardProject`, `SqliteEngine.mount_for`. +- Produces: + - `ComposeRenderer(templates, engines, cli_version)` : `render_agent(project, *, inline=False) -> RenderResult`, `render_dashboard(project, *, inline=False) -> RenderResult`. + - `RenderResult(compose: str, databases: list[dict] | None)` : `validate()` (`TemplateError`), `write(path) -> WriteReport`, `diff_against(path) -> str`. + - `WriteReport(backed_up: Path | None, wrote: list[Path])`. + - `DockerRunner.remove_volume(name) -> None`. + +- [ ] **Step 1: `services/renderer.py`** + +```python +"""State → docker-compose.yml (+ databases.json). The only writer of those files.""" + +from __future__ import annotations + +import difflib +import json +import os +import shutil +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import jinja2 +import yaml + +from core.errors import TemplateError +from core.specs import DatabaseSpec +from engines.registry import EngineRegistry +from services.compose_facts import GENERATED_MARKER, ComposeFacts +from services.envfile import EnvFile +from services.project import COMPOSE_FILE, DATABASES_FILE, AgentProject, DashboardProject +from services.templates import TemplateRepository + +LEGACY_BACKUP = "docker-compose.legacy.yml" + + +@dataclass +class WriteReport: + backed_up: Path | None = None + wrote: list[Path] = field(default_factory=list) + + +@dataclass +class RenderResult: + compose: str + databases: list[dict[str, Any]] | None = None + + def validate(self) -> None: + try: + doc = yaml.safe_load(self.compose) + except yaml.YAMLError as e: + raise TemplateError("Rendered compose is not valid YAML; templates are broken.", cause=e) from e + if not isinstance(doc, dict) or "services" not in doc: + raise TemplateError("Rendered compose has no 'services' section; templates are broken.") + + def write(self, path: Path) -> WriteReport: + self.validate() + report = WriteReport() + compose_path = path / COMPOSE_FILE + facts = ComposeFacts(compose_path) + if facts.exists and not facts.is_generated: + backup = path / LEGACY_BACKUP + if not backup.exists(): + shutil.copy2(compose_path, backup) + report.backed_up = backup + _atomic_write(compose_path, self.compose) + report.wrote.append(compose_path) + if self.databases is not None: + db_path = path / DATABASES_FILE + _atomic_write(db_path, json.dumps({"databases": self.databases}, indent=2) + "\n") + try: + os.chmod(db_path, 0o666) # agent container may run as another uid (legacy behaviour) + except OSError: + pass + report.wrote.append(db_path) + return report + + def diff_against(self, path: Path) -> str: + current = (path / COMPOSE_FILE).read_text(encoding="utf-8") if (path / COMPOSE_FILE).exists() else "" + return "".join( + difflib.unified_diff( + current.splitlines(keepends=True), + self.compose.splitlines(keepends=True), + fromfile=f"{COMPOSE_FILE} (current)", + tofile=f"{COMPOSE_FILE} (rendered)", + ) + ) + + +def _atomic_write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(content, encoding="utf-8") + os.replace(tmp, path) + + +class ComposeRenderer: + def __init__(self, templates: TemplateRepository, engines: EngineRegistry, cli_version: str) -> None: + self.templates = templates + self.engines = engines + self.cli_version = cli_version + + def header(self) -> str: + return f"{GENERATED_MARKER} {self.cli_version}. Do not edit — use docker-compose.override.yml.\n" + + # ---- agent ------------------------------------------------------------ + + def render_agent(self, project: AgentProject, *, inline: bool = False) -> RenderResult: + env = project.env + ctx = { + "host_gateway": project.host_gateway, + "docker_socket": project.needs_docker_socket, + "mounts": [{"host": h, "container": c} for h, c in project.sqlite_mounts], + "services": [self._service(spec, inline) for spec in project.managed], + "tz_var": _var(env, "TZ", inline), + "edge_key_var": _var(env, "EDGE_KEY", inline), + "log_level_var": _var(env, "LOG_LEVEL", inline), + "polling_var": _var(env, "POLLING", inline), + } + compose = self.header() + self._render("agent.yml.j2", ctx) + databases = [self.engines.get(d.engine).agent_entry(d) for d in project.databases] + return RenderResult(compose=compose, databases=databases) + + def _service(self, spec: DatabaseSpec, inline: bool) -> dict[str, str]: + engine = self.engines.get(spec.engine) + body = self._render_template(self.templates.engine_template(spec.engine), engine.template_ctx(spec, inline=inline)) + return {"name": spec.host or "", "volume": f"{spec.host}-data", "body": body} + + # ---- dashboard -------------------------------------------------------- + + def render_dashboard(self, project: DashboardProject, *, inline: bool = False) -> RenderResult: + env = project.env + ctx = { + "db_mode": project.db_mode, + "project_name_var": project.project_name, # literal, as the legacy CLI did + "host_port_var": _var(env, "HOST_PORT", inline), + "tz_var": _var(env, "TZ", inline), + "log_level_var": _var(env, "LOG_LEVEL", inline), + "project_secret_var": _var(env, "PROJECT_SECRET", inline), + "project_url_var": _var(env, "PROJECT_URL", inline), + "pg_port_var": _var(env, "PG_PORT", inline), + "postgres_db_var": _var(env, "POSTGRES_DB", inline), + "postgres_user_var": _var(env, "POSTGRES_USER", inline), + "postgres_password_var": _var(env, "POSTGRES_PASSWORD", inline), + } + return RenderResult(compose=self.header() + self._render("dashboard.yml.j2", ctx), databases=None) + + # ---- internals -------------------------------------------------------- + + def _render(self, name: str, ctx: dict[str, Any]) -> str: + return self._render_template(self.templates.get(name), ctx) + + @staticmethod + def _render_template(template: jinja2.Template, ctx: dict[str, Any]) -> str: + try: + return template.render(**ctx) + except jinja2.TemplateError as e: + raise TemplateError(f"Template rendering failed: {e}", cause=e) from e + + +def _var(env: EnvFile, key: str, inline: bool) -> str: + return (env.get(key) or "") if inline else f"${{{key}}}" +``` + +- [ ] **Step 2: `DockerRunner.remove_volume`** (dans `services/docker.py`, après `ensure_network`) + +```python + def remove_volume(self, name: str) -> bool: + """True if removed, False if it did not exist. Raises on other failures.""" + proc = subprocess.run([self.binary, "volume", "rm", name], capture_output=True, text=True, check=False) + if proc.returncode == 0: + return True + if "no such volume" in (proc.stderr or "").lower(): + return False + raise DockerError(f"Could not remove volume '{name}': {proc.stderr.strip()}") +``` + +- [ ] **Step 3: Vérifier le rendu sur l'install legacy et le `--diff`** + +Run: `uv run python -c " +from pathlib import Path +from services.project import AgentProject +from services.renderer import ComposeRenderer +from services.templates import TemplateRepository +from services.http import HttpClient +from core.config import GlobalConfig +from engines import registry +import yaml +repo = TemplateRepository.from_environment(HttpClient(), GlobalConfig()) +r = ComposeRenderer(repo, registry, '0.0.0-dev') +p = AgentProject.load(Path('/tmp/legacy-agent')) +res = r.render_agent(p); res.validate() +doc = yaml.safe_load(res.compose) +print(sorted(doc['services']), doc['services']['agent']['volumes'], doc['services']['agent'].get('extra_hosts')) +print(res.diff_against(p.path)[:1200]) +print(len(res.databases), [d['type'] for d in res.databases])"` +Expected: services = `agent` + le service postgres + le service redis (mêmes noms que le compose legacy) ; volumes agent = `databases.json`, `./ext.sqlite:/config/ext.sqlite`, socket ; `extra_hosts` présent ; diff limité à l'en-tête, l'ordre des lignes, `restart: unless-stopped` sur redis ; `4 ['postgresql', 'redis', 'sqlite', 'docker-volume']`. + +Comparer aussi `res.databases` à `/tmp/legacy-agent/databases.json` : mêmes clés et valeurs par entrée (à l'ordre des clés près). + +- [ ] **Step 4: Commit** + +```bash +git add services/renderer.py services/docker.py +git commit -m "feat(services): add ComposeRenderer with validation, atomic write and legacy backup" +``` + +--- + +### Task 4 : Composants `Summary`, `DataTable`, `Diff` + +**Files:** +- Create: `ui/components/summary.py`, `ui/components/table.py`, `ui/components/diff.py` +- Modify: `ui/__init__.py` + +**Interfaces:** +- Produces: `UI.summary(rows: list[tuple[str, str]], *, title: str | None = None)`, `UI.table(columns: list[str], rows: list[list[str]], *, title: str | None = None)`, `UI.diff(text: str)`. + +- [ ] **Step 1: `ui/components/summary.py`** + +```python +from __future__ import annotations + +import re + +from rich.panel import Panel +from rich.table import Table + +from ui.components.base import Component + +_SENSITIVE = re.compile(r"(password|secret|key|token)", re.I) +_URL_CREDS = re.compile(r"://([^:/@]+):([^@/]+)@") + + +def mask(label: str, value: str) -> str: + if _SENSITIVE.search(label): + return "••••••••" + return _URL_CREDS.sub(r"://\1:****@", value) + + +class Summary(Component): + def __call__(self, rows: list[tuple[str, str]], *, title: str | None = None) -> None: + table = Table(show_header=False, box=None, padding=(0, 2)) + table.add_column("Property", style="bold cyan") + table.add_column("Value", style="white") + for label, value in rows: + table.add_row(label, mask(label, str(value))) + self.console.print("") + self.console.print(Panel(table, title=f"[bold white]{title}[/bold white]" if title else None, border_style="bold blue", expand=False)) +``` + +- [ ] **Step 2: `ui/components/table.py`** + +```python +from __future__ import annotations + +from rich.table import Table + +from ui.components.base import Component + +_STYLES = ["cyan", "blue", "magenta", "green", "white", "dim"] + + +class DataTable(Component): + def __call__(self, columns: list[str], rows: list[list[str]], *, title: str | None = None) -> None: + table = Table(title=title) + for i, col in enumerate(columns): + table.add_column(col, style=_STYLES[i % len(_STYLES)]) + for row in rows: + table.add_row(*[str(c) for c in row]) + self.console.print(table) +``` + +- [ ] **Step 3: `ui/components/diff.py`** + +```python +from __future__ import annotations + +from rich.syntax import Syntax + +from ui.components.base import Component + + +class Diff(Component): + def __call__(self, text: str) -> None: + if not text.strip(): + self.console.print("[info]ℹ No changes.[/info]") + return + self.console.print(Syntax(text, "diff", theme="ansi_dark", word_wrap=False)) +``` + +- [ ] **Step 4: Façade** — ajouter à `ui/__init__.py` les imports et méthodes : + +```python +from ui.components.diff import Diff +from ui.components.summary import Summary +from ui.components.table import DataTable + + def summary(self, rows: list[tuple[str, str]], *, title: str | None = None) -> None: + Summary(self.console)(rows, title=title) + + def table(self, columns: list[str], rows: list[list[str]], *, title: str | None = None) -> None: + DataTable(self.console)(columns, rows, title=title) + + def diff(self, text: str) -> None: + Diff(self.console)(text) +``` + +- [ ] **Step 5: Vérifier** + +Run: `uv run python -c " +from ui import UI +ui = UI() +ui.summary([('Name','x'),('Password','hunter2'),('Connection URL','postgresql://u:p@h:5432/d')], title='PROPOSED') +ui.table(['A','B'], [['1','2']], title='T') +ui.diff('--- a\n+++ b\n@@ -1 +1 @@\n-old\n+new\n'); ui.diff('')"` +Expected: panneau avec `••••••••` et `:****@`, table, diff colorisé, `ℹ No changes.`. + +- [ ] **Step 6: Commit** + +```bash +git add ui/ +git commit -m "feat(ui): add Summary, DataTable and Diff components" +``` + +--- + +### Task 5 : `AddDatabaseFlow` + +**Files:** +- Create: `commands/flows/__init__.py` (vide) +- Create: `commands/flows/add_database.py` + +**Interfaces:** +- Consumes: `UI`, `EngineRegistry`, `PortAllocator`, `Form`, `Field`, `DatabaseSpec`, `AgentProject`. +- Produces: `AddDatabaseFlow(ui, engines, ports)` : `collect(values: dict) -> tuple[DatabaseSpec, DbEngine]`, `apply(project, spec, engine) -> None`, `parse_options(items: list[str]) -> dict[str, str]` (static). + +- [ ] **Step 1: Écrire le module** + +```python +"""Shared 'add a database' wizard. Flags fill `values`; anything missing is prompted or errors.""" + +from __future__ import annotations + +from typing import Any + +from core.errors import ValidationError +from core.fields import Field +from core.specs import DatabaseSpec +from engines.base import DbEngine +from engines.registry import EngineRegistry +from services.ports import PortAllocator +from services.project import AgentProject +from ui import UI + +FLOW_KEYS = {"engine", "mode", "auth", "label", "options"} + + +class AddDatabaseFlow: + def __init__(self, ui: UI, engines: EngineRegistry, ports: PortAllocator) -> None: + self.ui = ui + self.engines = engines + self.ports = ports + + # ---- public ----------------------------------------------------------- + + @staticmethod + def parse_options(items: list[str] | None) -> dict[str, str]: + out: dict[str, str] = {} + for item in items or []: + if "=" not in item: + raise ValidationError(f"Invalid option '{item}'.", hint="Use -o KEY=VALUE") + key, value = item.split("=", 1) + out[key.strip()] = value.strip() + return out + + def collect(self, values: dict[str, Any]) -> tuple[DatabaseSpec, DbEngine]: + form = self.ui.form() + engine = self.engines.get( + form.choice("Select Database Engine", self.engines.choices(), value=values.get("engine"), name="engine") + ) + if engine.warning: + self.ui.warning(engine.warning) + + mode = "new" + if engine.has_modes: + mode = form.choice("Configuration Mode", ["new", "existing"], value=values.get("mode"), default="new", name="mode") + + auth = True + if mode == "new" and engine.auth_variants: + raw = values.get("auth") + if raw is None: + auth = form.choice("Variant", ["with-auth", "no-auth"], name="auth") == "with-auth" + else: + auth = bool(raw) + + fields = list(engine.fields_new() if mode == "new" else engine.fields_existing()) + if mode == "existing" or not engine.has_modes: + fields.insert(0, Field("label", "Display Name", "text", default=engine.label_default)) + + self._reject_irrelevant(values, fields, engine, mode) + + if mode == "existing": + self.ui.info(f"{engine.display} — existing database") + answers = form.collect(fields, values) + answers["options"] = self._collect_options(form, engine, values.get("options") or {}) + + if mode == "new": + spec = engine.generate(auth=auth, ports=self.ports, answers=answers) + else: + spec = engine.from_existing(answers) + return spec.with_options(answers["options"]), engine + + def apply(self, project: AgentProject, spec: DatabaseSpec, engine: DbEngine) -> None: + project.add(spec, engine) + + # ---- internals -------------------------------------------------------- + + def _collect_options(self, form, engine: DbEngine, provided: dict[str, str]) -> dict[str, Any]: + option_fields = engine.option_fields() + known = {f.name for f in option_fields} + unknown = set(provided) - known + if unknown: + raise ValidationError( + f"Unknown option(s) for {engine.key}: {', '.join(sorted(unknown))}.", + hint=("Valid options: " + ", ".join(sorted(known))) if known else f"{engine.key} has no options.", + ) + if not option_fields: + return {} + return form.collect(option_fields, provided) + + @staticmethod + def _reject_irrelevant(values: dict[str, Any], fields: list[Field], engine: DbEngine, mode: str) -> None: + relevant = {f.name for f in fields} | FLOW_KEYS + extra = sorted(k for k, v in values.items() if v is not None and k not in relevant) + if extra: + raise ValidationError( + f"Option(s) not applicable to {engine.key} in '{mode}' mode: {', '.join('--' + k.replace('_', '-') for k in extra)}.", + hint="Applicable: " + ", ".join(f"--{f.name.replace('_', '-')}" for f in fields) if fields else "No extra input needed.", + ) +``` + +- [ ] **Step 2: Vérifier en non-interactif** + +Run: `uv run python -c " +from ui import UI +from engines import registry +from services.ports import FixedPortAllocator +from commands.flows.add_database import AddDatabaseFlow +from core.errors import ValidationError +f = AddDatabaseFlow(UI(non_interactive=True), registry, FixedPortAllocator()) +s, e = f.collect({'engine':'postgresql','mode':'new','options': f.parse_options(['clean_mode=none'])}); print(e.key, s.managed, s.options) +s, e = f.collect({'engine':'redis','mode':'new','auth':False}); print(s.host[:9], s.auth) +s, e = f.collect({'engine':'sqlite','mode':'existing','path':'x.sqlite'}); print(s.name, s.database) +s, e = f.collect({'engine':'docker-volume','volume':'v'}); print(s.name, s.volume, s.container) +for bad in ({'engine':'postgresql','mode':'existing'}, {'engine':'redis','mode':'new','host':'h'}, {'engine':'mysql','mode':'new','options':{'clean_mode':'x'}}, {'engine':'nope'}): + try: f.collect(bad) + except ValidationError as err: print('ERR', err.message)"` +Expected: +``` +postgresql True {'clean_mode': 'none', 'keep_ownership': False} +db-redis- False +External DB /config/x.sqlite +Docker Volume v None +ERR Missing --host +ERR Option(s) not applicable to redis in 'new' mode: --host. +ERR Unknown option(s) for mysql: clean_mode. +ERR Unknown engine 'nope'. +``` + +- [ ] **Step 3: Commit** + +```bash +git add commands/flows/ +git commit -m "feat(commands): add AddDatabaseFlow shared by agent and db add" +``` + +--- + +### Task 6 : `commands/db.py` — `add`, `remove`, `list` + +**Files:** +- Modify: `commands/db.py` (réécriture complète) + +**Interfaces:** +- Produces: `DbCommands(ui, telemetry, engines, ports, templates, renderer, docker)` groupe `db` avec `DbAddCommand`, `DbRemoveCommand`, `DbListCommand`. + +- [ ] **Step 1: Réécrire le module** + +```python +"""db add / remove / list.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Annotated + +import typer + +from commands.base import Command, CommandGroup +from commands.flows.add_database import AddDatabaseFlow +from core.errors import ValidationError +from engines.registry import EngineRegistry +from services.docker import DockerRunner +from services.ports import PortAllocator +from services.project import AgentProject +from services.renderer import ComposeRenderer, WriteReport +from services.telemetry import Telemetry +from services.templates import TemplateRepository +from ui import UI + +NameArg = Annotated[Path, typer.Argument(help="Agent folder")] + + +def report_write(ui: UI, report: WriteReport) -> None: + if report.backed_up: + ui.warning(f"Legacy compose backed up to {report.backed_up.name}. Manual edits belong in docker-compose.override.yml.") + + +class _DbCommand(Command): + panel = "Configuration" + no_args_is_help = True + + def __init__(self, ui, telemetry, engines: EngineRegistry, ports: PortAllocator, templates: TemplateRepository, renderer: ComposeRenderer, docker: DockerRunner) -> None: + super().__init__(ui, telemetry) + self.engines, self.ports, self.templates, self.renderer, self.docker = engines, ports, templates, renderer, docker + + def render_and_write(self, project: AgentProject) -> None: + with self.ui.status("Rendering configuration..."): + result = self.renderer.render_agent(project) + project.save_state() + report = result.write(project.path) + report_write(self.ui, report) + + +class DbAddCommand(_DbCommand): + name, help = "add", "Add a database to an agent." + + def run( + self, + name: NameArg, + engine: Annotated[str | None, typer.Option("--engine", "-e", help="Database engine")] = None, + mode: Annotated[str | None, typer.Option("--mode", help="new (container) or existing")] = None, + auth: Annotated[bool | None, typer.Option("--auth/--no-auth", help="Auth variant for mongodb/redis/valkey")] = None, + label: Annotated[str | None, typer.Option("--label", help="Display name")] = None, + host: Annotated[str | None, typer.Option("--host")] = None, + port: Annotated[int | None, typer.Option("--port")] = None, + database: Annotated[str | None, typer.Option("--database")] = None, + user: Annotated[str | None, typer.Option("--user")] = None, + password: Annotated[str | None, typer.Option("--password", help="Prefer --password-stdin")] = None, + password_stdin: Annotated[bool, typer.Option("--password-stdin", help="Read password from stdin")] = False, + path: Annotated[str | None, typer.Option("--path", help="SQLite file path (existing)")] = None, + db_name: Annotated[str | None, typer.Option("--name", help="SQLite file name (new)")] = None, + volume: Annotated[str | None, typer.Option("--volume", help="Docker volume name")] = None, + container: Annotated[str | None, typer.Option("--container", help="Container to restart after restore")] = None, + option: Annotated[list[str] | None, typer.Option("--option", "-o", help="Engine option KEY=VALUE (repeatable)")] = None, + ) -> None: + if password_stdin: + import sys + + password = sys.stdin.readline().rstrip("\n") + elif password is not None: + self.ui.warning("--password is visible in shell history; prefer --password-stdin.") + + project_path = self.require_project_dir(name) + self.templates.resolve() + project = AgentProject.load(project_path) + + flow = AddDatabaseFlow(self.ui, self.engines, self.ports) + values = { + "engine": engine, "mode": mode, "auth": auth, "label": label, "host": host, "port": port, + "database": database, "username": user, "password": password, "path": path, "name": db_name, + "volume": volume, "container": container, "options": flow.parse_options(option), + } + spec, eng = flow.collect(values) + flow.apply(project, spec, eng) + self.render_and_write(project) + + self.ui.success(f"Added {eng.display} database '{spec.name}' ({eng.describe(spec)}).") + self.ui.info(f"Restart the agent to apply changes: portabase restart {project_path.name}") + + +class DbRemoveCommand(_DbCommand): + name, help = "remove", "Remove a database from an agent." + + def run( + self, + name: NameArg, + target: Annotated[str | None, typer.Option("--id", "--name", "-i", help="Database id (or prefix) or display name")] = None, + purge_volume: Annotated[bool, typer.Option("--purge-volume", help="Also delete the Docker volume of a managed database")] = False, + yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False, + ) -> None: + project_path = self.require_project_dir(name) + self.templates.resolve() + project = AgentProject.load(project_path) + if not project.databases: + self.ui.warning("No databases to remove.") + return + + if target is None: + choices = [f"{d.name} ({d.engine}) [{d.id[:8]}]" for d in project.databases] + picked = self.ui.form().choice("Which database to remove?", choices, name="id") + spec = project.databases[choices.index(picked)] + else: + spec = project.find(target) + engine = self.engines.get(spec.engine) + + if not yes: + extra = " and its Docker volume" if (purge_volume and spec.managed) else "" + self.confirm_or_abort(f"Remove '{spec.name}' ({engine.describe(spec)}){extra}?", default=False) + + project.remove(spec, engine) + self.render_and_write(project) + self.ui.success(f"Removed {spec.name}") + + if spec.managed: + volume_name = f"{self.docker.project_name(project_path)}_{spec.host}-data" + if purge_volume: + self.require_docker(self.docker) + removed = self.docker.remove_volume(volume_name) + self.ui.success(f"Deleted volume {volume_name}" if removed else f"Volume {volume_name} did not exist") + else: + self.ui.info(f"Data volume kept: {volume_name}. Delete it with: docker volume rm {volume_name}") + self.ui.info(f"Restart the agent to apply changes: portabase restart {project_path.name}") + + +class DbListCommand(_DbCommand): + name, help = "list", "List the databases of an agent." + + def run(self, name: NameArg) -> None: + project = AgentProject.load(self.require_project_dir(name)) + if not project.databases: + self.ui.warning("No databases configured.") + return + rows = [] + for d in project.databases: + engine = self.engines.get(d.engine) + opts = ", ".join(f"{k}={v}" for k, v in engine.non_default_options(d).items()) + rows.append([d.name, d.database or "", d.engine, engine.describe(d), d.username or "" if d.engine not in ("sqlite", "docker-volume") else "N/A", opts, d.id[:8] + "..."]) + self.ui.table(["Display Name", "Database", "Type", "Host:Port", "User", "Options", "ID"], rows, title=f"Databases for {project.path.name}") + + +class DbCommands(CommandGroup): + name, help, panel = "db", "Manage the databases of an agent.", "Configuration" + + def __init__(self, ui: UI, telemetry: Telemetry, engines: EngineRegistry, ports: PortAllocator, templates: TemplateRepository, renderer: ComposeRenderer, docker: DockerRunner) -> None: + super().__init__(ui, telemetry) + self._deps = (ui, telemetry, engines, ports, templates, renderer, docker) + + @property + def commands(self) -> list[Command]: + return [DbAddCommand(*self._deps), DbRemoveCommand(*self._deps), DbListCommand(*self._deps)] +``` + +Note : `sys` importé localement dans `run` pour `--password-stdin` ; déplacer en tête de module si ruff le demande. + +- [ ] **Step 2: Commit** (vérification à Task 9, une fois `main.py` câblé) + +```bash +git add commands/db.py +git commit -m "feat(commands): rewrite db add/remove/list on the declarative renderer" +``` + +--- + +### Task 7 : `commands/agent.py` et `commands/dashboard.py` + +**Files:** +- Modify: `commands/agent.py` (réécriture complète) +- Modify: `commands/dashboard.py` (réécriture complète) + +**Interfaces:** +- Produces: `AgentCommand(ui, telemetry, docker, templates, renderer, engines, ports)` ; `DashboardCommand(ui, telemetry, docker, templates, renderer, ports)`. + +- [ ] **Step 1: `commands/agent.py`** + +```python +"""portabase agent NAME — create an agent folder. Databases are added by db add (or the interactive loop).""" + +from __future__ import annotations + +from pathlib import Path +from typing import Annotated + +import typer + +from commands.base import Command +from commands.db import report_write +from commands.flows.add_database import AddDatabaseFlow +from core.errors import ValidationError +from core.utils import validate_edge_key +from engines.registry import EngineRegistry +from services.docker import DockerRunner +from services.ports import PortAllocator +from services.project import AgentProject +from services.renderer import ComposeRenderer +from services.telemetry import Telemetry +from services.templates import TemplateRepository +from ui import UI + +NETWORK = "portabase_network" + + +def _edge_key(value: str) -> str: + if not validate_edge_key(value): + raise ValidationError("Invalid Edge Key.", hint="Expected Base64 or JSON with serverUrl, agentId, masterKeyB64.") + return value + + +class AgentCommand(Command): + name, help, panel = "agent", "Create a new Portabase Agent instance.", "Creation" + no_args_is_help = True + + def __init__(self, ui: UI, telemetry: Telemetry, docker: DockerRunner, templates: TemplateRepository, renderer: ComposeRenderer, engines: EngineRegistry, ports: PortAllocator) -> None: + super().__init__(ui, telemetry) + self.docker, self.templates, self.renderer, self.engines, self.ports = docker, templates, renderer, engines, ports + + def run( + self, + name: Annotated[str, typer.Argument(help="Agent name (creates a folder)")], + key: Annotated[str | None, typer.Option("--key", "-k", help="Edge Key")] = None, + tz: Annotated[str | None, typer.Option("--tz", help="Timezone")] = None, + polling: Annotated[int | None, typer.Option("--polling", help="Polling frequency in seconds")] = None, + host_gateway: Annotated[bool | None, typer.Option("--host-gateway/--no-host-gateway", help="Map localhost to host-gateway")] = None, + start: Annotated[bool, typer.Option("--start", "-s", help="Start immediately")] = False, + force: Annotated[bool, typer.Option("--force", "-f", help="Overwrite an existing folder")] = False, + ) -> None: + self.ui.banner() + self.require_docker(self.docker) + self.docker.ensure_network(NETWORK) + self.templates.resolve() + + path = Path(name).resolve() + if path.exists() and not force: + self.ui.warning(f"Directory '{name}' already exists.") + self.confirm_or_abort("Overwrite?", default=False) + + form = self.ui.form() + env_vars = { + "EDGE_KEY": form.text("Edge Key", value=key, validator=_edge_key, name="key"), + "TZ": form.text("Timezone", value=tz, default="UTC", name="tz"), + "POLLING": str(form.integer("Polling frequency (seconds)", value=polling, default=5, name="polling")), + "LOG_LEVEL": "info", + } + gateway = form.confirm("Add extra_hosts mapping (localhost -> host-gateway)?", value=host_gateway, default=False, name="host_gateway") + + project = AgentProject.create(path, env_vars, host_gateway=gateway) + self._write(project) + self.ui.success(f"Agent '{name}' created in {path}") + + if not self.ui.non_interactive: + self.ui.section("Database Setup") + flow = AddDatabaseFlow(self.ui, self.engines, self.ports) + while self.ui.confirm("Add a database?", default=True): + spec, engine = flow.collect({}) + flow.apply(project, spec, engine) + self._write(project) + self.ui.success(f"Added {engine.display} '{spec.name}' ({engine.describe(spec)})") + else: + self.ui.hint(f"Add databases with: portabase db add {name} --engine postgresql --mode new") + + if start or (not self.ui.non_interactive and self.ui.confirm("Start agent now?", default=False)): + with self.ui.status("Starting agent..."): + self.docker.compose(path, ["up", "-d"]) + self.ui.success("Agent started.") + else: + self.ui.info(f"Run: portabase start {name}") + + def _write(self, project: AgentProject) -> None: + with self.ui.status("Rendering configuration..."): + result = self.renderer.render_agent(project) + project.save_state() + report = result.write(project.path) + report_write(self.ui, report) +``` + +- [ ] **Step 2: `commands/dashboard.py`** + +```python +"""portabase dashboard NAME — create a dashboard folder.""" + +from __future__ import annotations + +import secrets +from pathlib import Path +from typing import Annotated +from urllib.parse import quote + +import typer + +from commands.base import Command +from commands.db import report_write +from core.utils import generate_password, slugify_project_name +from services.docker import DockerRunner +from services.ports import PortAllocator +from services.project import DashboardProject +from services.renderer import ComposeRenderer +from services.telemetry import Telemetry +from services.templates import TemplateRepository +from ui import UI + +DB_MODES = ("external", "internal", "custom") +MODE_LABELS = { + "external": "Dedicated Docker Container (Recommended)", + "internal": "Embedded Database (In-container)", + "custom": "Custom/Existing Database", +} + + +class DashboardCommand(Command): + name, help, panel = "dashboard", "Create a new Portabase Dashboard instance.", "Creation" + no_args_is_help = True + + def __init__(self, ui: UI, telemetry: Telemetry, docker: DockerRunner, templates: TemplateRepository, renderer: ComposeRenderer, ports: PortAllocator) -> None: + super().__init__(ui, telemetry) + self.docker, self.templates, self.renderer, self.ports = docker, templates, renderer, ports + + def run( + self, + name: Annotated[str, typer.Argument(help="Dashboard name (creates a folder)")], + port: Annotated[int | None, typer.Option("--port", help="Web port")] = None, + db_mode: Annotated[str | None, typer.Option("--db-mode", help="external | internal | custom")] = None, + db_host: Annotated[str | None, typer.Option("--db-host")] = None, + db_port: Annotated[int | None, typer.Option("--db-port")] = None, + db_name: Annotated[str | None, typer.Option("--db-name")] = None, + db_user: Annotated[str | None, typer.Option("--db-user")] = None, + db_password_stdin: Annotated[bool, typer.Option("--db-password-stdin", help="Read the custom DB password from stdin")] = False, + tz: Annotated[str | None, typer.Option("--tz")] = None, + start: Annotated[bool, typer.Option("--start", "-s")] = False, + force: Annotated[bool, typer.Option("--force", "-f")] = False, + yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip the configuration confirmation")] = False, + ) -> None: + self.ui.banner() + self.require_docker(self.docker) + self.templates.resolve() + + path = Path(name).resolve() + if path.exists() and not force: + self.ui.warning(f"Directory '{name}' already exists.") + self.confirm_or_abort("Overwrite?", default=False) + + form = self.ui.form() + web_port = form.integer("Web Port", value=port, default=8887, name="port") + mode = form.choice("Database Setup", list(DB_MODES), value=db_mode, default="external", name="db_mode") + project_name = slugify_project_name(path.name) + + env_vars = { + "HOST_PORT": str(web_port), + "PROJECT_SECRET": secrets.token_hex(32), + "PROJECT_URL": f"http://localhost:{web_port}", + "PROJECT_NAME": project_name, + "TZ": form.text("Timezone", value=tz, default="Europe/Paris", name="tz"), + "LOG_LEVEL": "info", + } + rows = [("Dashboard Name", name), ("Path", str(path)), ("Access URL", env_vars["PROJECT_URL"]), ("Database Setup", MODE_LABELS[mode])] + + if mode == "external": + pg_pass, pg_port = generate_password(16), self.ports.free() + env_vars.update(self._pg_env("portabase", "portabase", pg_pass, "db", 5432, pg_port)) + rows.append(("Internal Port", str(pg_port))) + elif mode == "custom": + self.ui.info("External Database Configuration") + host = form.text("Host", value=db_host, default="localhost", name="db_host") + dport = form.integer("Port", value=db_port, default=5432, name="db_port") + dbname = form.text("Database Name", value=db_name, default="portabase", name="db_name") + user = form.text("Username", value=db_user, name="db_user") + if db_password_stdin: + import sys + + password = sys.stdin.readline().rstrip("\n") + else: + password = form.secret("Password", name="db_password") + env_vars.update(self._pg_env(dbname, user, password, host, dport, dport)) + rows += [("DB Host", host), ("DB Name", dbname), ("Connection URL", env_vars["DATABASE_URL"])] + + rows.append(("Files to Create", "docker-compose.yml, .env")) + self.ui.summary(rows, title="PROPOSED CONFIGURATION") + if not yes: + self.confirm_or_abort("Apply this configuration and generate files?", default=True) + + project = DashboardProject.create(path, env_vars) + with self.ui.status("Rendering configuration..."): + result = self.renderer.render_dashboard(project) + project.save_state() + report = result.write(path) + report_write(self.ui, report) + self.ui.success(f"Dashboard '{name}' created in {path}") + + if start or (not self.ui.non_interactive and self.ui.confirm("Start dashboard now?", default=False)): + with self.ui.status("Starting..."): + self.docker.compose(path, ["up", "-d"]) + self.ui.success(f"Live at: {env_vars['PROJECT_URL']}") + else: + self.ui.info(f"Run: portabase start {name}") + + @staticmethod + def _pg_env(db: str, user: str, password: str, host: str, port: int, host_port: int) -> dict[str, str]: + return { + "POSTGRES_DB": db, + "POSTGRES_USER": user, + "POSTGRES_PASSWORD": password, + "POSTGRES_HOST": host, + "DATABASE_URL": f"postgresql://{quote(user, safe='')}:{quote(password, safe='')}@{host}:{port}/{db}?schema=public", + "PG_PORT": str(host_port), + } +``` + +`--yes` en non-interactif : `confirm_or_abort(default=True)` renvoie `True` sans prompt, donc `--yes` n'est nécessaire que pour sauter l'affichage ; conservé pour la lisibilité des scripts. + +- [ ] **Step 3: Commit** + +```bash +git add commands/agent.py commands/dashboard.py +git commit -m "feat(commands): rewrite agent and dashboard on the declarative renderer" +``` + +--- + +### Task 8 : `commands/build.py` + +**Files:** +- Create: `commands/build.py` + +**Interfaces:** +- Produces: `BuildCommand(ui, telemetry, templates, renderer)`. + +- [ ] **Step 1: Écrire le module** + +```python +"""portabase build PATH — re-render compose from state. Also the legacy migration entry point.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Annotated + +import typer + +from commands.base import Command +from commands.db import report_write +from core.errors import ValidationError +from services.project import COMPOSE_FILE, DATABASES_FILE, ENV_FILE, AgentProject, DashboardProject, detect_kind +from services.renderer import ComposeRenderer, RenderResult +from services.telemetry import Telemetry +from services.templates import TemplateRepository +from ui import UI + + +class BuildCommand(Command): + name, help, panel = "build", "Re-render docker-compose.yml from the component's configuration.", "Configuration" + no_args_is_help = True + + def __init__(self, ui: UI, telemetry: Telemetry, templates: TemplateRepository, renderer: ComposeRenderer) -> None: + super().__init__(ui, telemetry) + self.templates, self.renderer = templates, renderer + + def run( + self, + path: Annotated[Path, typer.Argument(help="Component folder")], + diff: Annotated[bool, typer.Option("--diff", help="Show the diff, write nothing")] = False, + stdout: Annotated[bool, typer.Option("--stdout", help="Print the compose, write nothing")] = False, + inline_env: Annotated[bool, typer.Option("--inline-env", help="Substitute values instead of ${VAR} references")] = False, + output: Annotated[Path | None, typer.Option("--output", "-o", help="Write files to another directory")] = None, + ) -> None: + if sum([diff, stdout, output is not None]) > 1: + raise ValidationError("Use only one of --diff, --stdout, --output.") + path = self.require_project_dir(path) + self.templates.resolve() + kind = detect_kind(path) + + if kind == "agent": + project = AgentProject.load(path) + result: RenderResult = self.renderer.render_agent(project, inline=inline_env) + else: + project = DashboardProject.load(path) + result = self.renderer.render_dashboard(project, inline=inline_env) + result.validate() + + if inline_env and not stdout: + self.ui.warning("--inline-env writes secrets in clear text into the compose file.") + + if stdout: + self.ui.console.print(result.compose, end="", markup=False, highlight=False) + return + if diff: + self.ui.diff(result.diff_against(path)) + return + + target = (output or path).resolve() + if output is not None: + target.mkdir(parents=True, exist_ok=True) + (target / ENV_FILE).write_text((path / ENV_FILE).read_text(encoding="utf-8"), encoding="utf-8") + report = result.write(target) + report_write(self.ui, report) + self.ui.success(f"Rendered {', '.join(p.name for p in report.wrote)} in {target}") + if kind == "agent" and output is None: + self.ui.info(f"Restart to apply: portabase restart {path.name}") +``` + +`--stdout` imprime via `console.print(markup=False)` pour qu'aucun `[x]` du compose ne soit interprété comme balise Rich. Pour un pipe propre, `main.py` doit **ne pas** afficher la notification de mise à jour quand `--stdout` est présent (déjà géré : non-interactif ou stdin non-TTY ; ajouter `"--stdout" in sys.argv` à `_notify_update` par sécurité, Task 9). + +- [ ] **Step 2: Commit** + +```bash +git add commands/build.py +git commit -m "feat(commands): add build command (re-render, --diff, --stdout, --inline-env, --output)" +``` + +--- + +### Task 9 : Câblage final, suppression du legacy + +**Files:** +- Modify: `main.py` +- Modify: `commands/base.py` (retirer `LegacyCommand`) +- Delete: `core/network.py`, `core/docker.py`, `templates/compose.py`, `templates/__init__.py`, `templates/agent.yml`, `templates/dashboard.yml` +- Modify: `core/config.py` (retirer les fonctions legacy, garder `GlobalConfig`, `TEMPLATE_BASE_URL`, `GLOBAL_CONFIG_DIR/FILE`) +- Modify: `core/utils.py` (garder uniquement `generate_password`, `slugify_project_name`, `validate_edge_key`, et l'import `current_version` re-exporté peut disparaître) +- Modify: `pyproject.toml` (retirer `per-file-ignores`) +- Modify: `.github/workflows/templates-upload.yml` (retirer l'étape `latest`) +- Modify: `scripts/render_check.py` + +- [ ] **Step 1: `main.py` — remplacer les `LegacyCommand` et `legacy_db`** + +Imports à remplacer : + +```python +from commands.agent import AgentCommand +from commands.build import BuildCommand +from commands.dashboard import DashboardCommand +from commands.db import DbCommands +from engines import registry as engine_registry +from services.ports import PortAllocator +from services.renderer import ComposeRenderer +from services.templates import TemplateRepository +``` + +(supprimer `from commands import agent as legacy_agent`, `dashboard as legacy_dashboard`, `db as legacy_db`, `from commands.base import LegacyCommand`.) + +Dans `build_app`, après `updater = Updater(http, version)` : + +```python + templates = TemplateRepository.from_environment(http, config) + ports = PortAllocator() + renderer = ComposeRenderer(templates, engine_registry, version) +``` + +Liste `commands` : + +```python + commands = [ + AgentCommand(ui, telemetry, docker, templates, renderer, engine_registry, ports), + DashboardCommand(ui, telemetry, docker, templates, renderer, ports), + StartCommand(ui, telemetry, docker), + StopCommand(ui, telemetry, docker), + RestartCommand(ui, telemetry, docker), + LogsCommand(ui, telemetry, docker), + UninstallCommand(ui, telemetry, docker), + BuildCommand(ui, telemetry, templates, renderer), + UpdateCommand(ui, telemetry, checker, updater), + ] + for cmd in commands: + cmd.register(app) + DbCommands(ui, telemetry, engine_registry, ports, templates, renderer, docker).register(app) + ConfigCommands(ui, telemetry, config).register(app) +``` + +Dans `_notify_update`, condition étendue : `or "--stdout" in sys.argv`. + +Retirer du catcher les branches `click.exceptions.Exit` et `click.exceptions.Abort` ? **Non** : `--help` lève toujours `Exit(0)` et `version_callback` lève `typer.Exit()`. Garder `Exit` ; retirer `Abort` (plus de `typer.confirm`). + +- [ ] **Step 2: Nettoyage** + +```bash +git rm core/network.py core/docker.py templates/compose.py templates/__init__.py templates/agent.yml templates/dashboard.yml +``` + +`commands/base.py` : supprimer la classe `LegacyCommand` et l'import `Callable` s'il devient inutilisé. + +`core/config.py` : ne garder que les constantes (`TEMPLATE_BASE_URL`, `GLOBAL_CONFIG_DIR`, `GLOBAL_CONFIG_FILE`), les imports nécessaires et `GlobalConfig`. Supprimer `write_file`, `write_env_file`, `load_global_config`, `save_global_config`, `get_config_value`, `set_config_value`, `load_db_config`, `save_db_config`, `add_db_to_json`. + +`core/utils.py` : ne garder que `generate_password`, `slugify_project_name`, `validate_edge_key` et leurs imports (`base64`, `binascii`, `json`, `re`, `secrets`, `string`). Supprimer `questionary_style`, `custom_theme`, `HINTS`, `get_random_hint`, `console`, `BANNER`, `print_banner`, `get_free_port`, `start_docker`, `check_system`, `validate_work_dir`, le re-export `current_version`. + +Vérifier qu'aucune référence ne subsiste : +Run: `grep -rn "core.network\|core.docker\|templates.compose\|LegacyCommand\|get_random_hint\|print_banner\|check_system\|validate_work_dir\|get_free_port\|load_db_config\|add_db_to_json\|write_env_file\|get_config_value" --include=*.py . | grep -v ".venv"` +Expected: aucune sortie. + +`pyproject.toml` : supprimer entièrement `[tool.ruff.lint.per-file-ignores]` ; retirer `"templates"` de `known-first-party`. + +`.github/workflows/templates-upload.yml` : supprimer l'étape `Upload latest templates (stable only, legacy fallback)` — **seulement si** plus aucune version legacy n'est supportée. Sinon la garder ; par défaut la garder et ouvrir une issue « retirer latest/ ». Décision utilisateur. + +- [ ] **Step 3: `scripts/render_check.py` via `ComposeRenderer`** + +Remplacer `agent_cases`, `render_agent`, `dashboard_cases` par une construction de projets en mémoire : + +```python +from core.specs import DatabaseSpec # noqa: E402 +from services.envfile import EnvFile # noqa: E402 +from services.project import AgentProject, DashboardProject # noqa: E402 +from services.renderer import ComposeRenderer # noqa: E402 + + +def agent_project(tmp: Path, specs: list, engines_for, host_gateway=False, sqlite=False) -> AgentProject: + env = EnvFile(tmp / ".env") + env.merge({"TZ": "UTC", "EDGE_KEY": "x", "LOG_LEVEL": "info", "POLLING": "5"}) + project = AgentProject(tmp, env, [], host_gateway) + for spec, engine in zip(specs, engines_for): + project.add(spec, engine) + if sqlite: + sq = registry.get("sqlite") + project.add(sq.generate(auth=False, ports=FixedPortAllocator(), answers={"name": "x"}), sq) + return project + + +def agent_cases(renderer: ComposeRenderer) -> list[tuple[str, str, str]]: + ports = FixedPortAllocator() + tmp = Path(tempfile.mkdtemp()) + cases = [] + empty = agent_project(tmp, [], []) + cases.append(("agent/empty", renderer.render_agent(empty).compose, env_text(empty))) + toggles = agent_project(tmp, [], [], host_gateway=True, sqlite=True) + dv = registry.get("docker-volume") + toggles.add(dv.from_existing({"volume": "v"}), dv) + cases.append(("agent/toggles", renderer.render_agent(toggles).compose, env_text(toggles))) + all_specs, all_engines = [], [] + for engine in registry: + if engine.template is None: + continue + for auth in (True, False) if engine.auth_variants else (True,): + spec = engine.generate(auth=auth, ports=ports, answers={}) + one = agent_project(tmp, [spec], [engine]) + label = f"agent/{engine.key}" + ("/auth" if auth else "/noauth" if engine.auth_variants else "") + cases.append((label, renderer.render_agent(one).compose, env_text(one))) + all_specs.append(spec); all_engines.append(engine) + everything = agent_project(tmp, all_specs, all_engines, host_gateway=True, sqlite=True) + cases.append(("agent/all", renderer.render_agent(everything).compose, env_text(everything))) + return cases + + +def env_text(project) -> str: + return "".join(f'{k}="{v}"\n' for k, v in project.env.as_dict().items()) + + +def dashboard_cases(renderer: ComposeRenderer) -> list[tuple[str, str, str]]: + tmp = Path(tempfile.mkdtemp()) + base = {"HOST_PORT": "8887", "PROJECT_SECRET": "s", "PROJECT_URL": "http://localhost:8887", "PROJECT_NAME": "pb", "TZ": "UTC", "LOG_LEVEL": "info"} + pg = {"POSTGRES_DB": "pb", "POSTGRES_USER": "pb", "POSTGRES_PASSWORD": "p", "PG_PORT": "5433", "DATABASE_URL": "postgresql://pb:p@db:5432/pb"} + variants = {"external": {**base, **pg, "POSTGRES_HOST": "db"}, "internal": base, "custom": {**base, **pg, "POSTGRES_HOST": "remote"}} + cases = [] + for mode, vars_ in variants.items(): + env = EnvFile(tmp / f".env.{mode}"); env.merge(vars_) + project = DashboardProject(tmp, env) + assert project.db_mode == mode, (project.db_mode, mode) + cases.append((f"dashboard/{mode}", renderer.render_dashboard(project).compose, env_text(project))) + return cases +``` + +et dans `main()` : + +```python + repo = TemplateRepository(HttpClient(), GlobalConfig().cache_dir, "local", local_dir=Path(args.templates)) + renderer = ComposeRenderer(repo, registry, "render-check") + try: + engines_check(repo) + for label, compose, env_text_ in agent_cases(renderer) + dashboard_cases(renderer): + validate(label, compose, env_text_, use_compose) +``` + +Supprimer `AGENT_GLOBALS`, `AGENT_ENV`, `DASHBOARD_VARS`, `DASHBOARD_ENV`, `render_agent` devenus inutiles. Ajouter `import tempfile` déjà présent. + +Run: `uv run python scripts/render_check.py` +Expected: mêmes cas qu'au Plan 3, tous `ok`, dont `agent/toggles` avec socket + mount + extra_hosts. + +- [ ] **Step 4: Lint** + +Run: `uv run ruff check . && uv run ruff format --check .` +Expected: passe **sans aucune** exception par fichier. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "refactor: remove legacy commands and helpers; wire agent, dashboard, db, build on the renderer" +``` + +--- + +### Task 10 : Vérification de bout en bout + +Docker requis. `K` = edge key de test : `K=$(printf '{"serverUrl":"http://x","agentId":"a","masterKeyB64":"k"}' | base64 -w0)`. + +- [ ] **Step 1: Agent non-interactif + db add** + +```bash +cd /tmp && rm -rf ni-agent +M="uv --directory /home/soluce/Documents/PROJETS/Portabase/cli run python /home/soluce/Documents/PROJETS/Portabase/cli/main.py" +$M --non-interactive agent ni-agent --key "$K" --tz Europe/Paris --polling 7 --host-gateway; echo "exit=$?" +$M --non-interactive db add ni-agent --engine postgresql --mode new -o clean_mode=drop_schemas; echo "exit=$?" +$M --non-interactive db add ni-agent --engine redis --mode new --no-auth; echo "exit=$?" +printf 'secret\n' | $M --non-interactive db add ni-agent --engine mysql --mode existing --label Prod --host db.example --port 3306 --database app --user app --password-stdin; echo "exit=$?" +$M --non-interactive db add ni-agent --engine sqlite --mode new --name local; echo "exit=$?" +$M --non-interactive db add ni-agent --engine docker-volume --volume some_vol; echo "exit=$?" +$M db list ni-agent +cat ni-agent/.env; head -3 ni-agent/docker-compose.yml; python3 -c "import json; print([ (d['type'], d.get('options')) for d in json.load(open('ni-agent/databases.json'))['databases']])" +(cd ni-agent && docker compose config --quiet && echo "compose config OK") +``` +Expected: 6 × `exit=0` ; table à 6 lignes ; `.env` avec `TZ="Europe/Paris"`, `POLLING="7"`, `DB_PG_*` (4 vars), `DB_REDIS_*_PORT` seul ; en-tête `# Generated by Portabase CLI` ; options `{'clean_mode': 'drop_schemas'}` sur postgres, `None` ailleurs ; `compose config OK`. + +- [ ] **Step 2: Erreurs non-interactives** + +```bash +$M --non-interactive db add ni-agent --engine postgresql --mode existing; echo "exit=$?" +$M --non-interactive db add ni-agent --engine redis --mode new --host x; echo "exit=$?" +$M --non-interactive db add ni-agent --engine postgresql --mode new -o nope=1; echo "exit=$?" +$M --non-interactive agent ni-agent --key "$K"; echo "exit=$?" +``` +Expected: `Missing --host` exit 2 ; `not applicable` exit 2 ; `Unknown option(s)` exit 2 ; `Directory 'ni-agent' already exists` → confirm par défaut False → `Cancelled.` exit 130. + +- [ ] **Step 3: db remove, build** + +```bash +ID=$(python3 -c "import json; print([d for d in json.load(open('ni-agent/databases.json'))['databases'] if d['type']=='redis'][0]['generated_id'])") +$M db remove ni-agent --id "$ID" --yes; echo "exit=$?" +grep -c "db-redis" ni-agent/docker-compose.yml ni-agent/.env +$M build ni-agent --diff +$M build ni-agent --stdout --inline-env | head -20 +$M build ni-agent --output /tmp/ni-export && ls /tmp/ni-export +``` +Expected: `Removed`, `0` occurrences de `db-redis` dans les deux fichiers, diff « No changes. », compose inline avec valeurs littérales (pas de `${`), export contenant `docker-compose.yml`, `.env`, `databases.json`. + +- [ ] **Step 4: Lifecycle réel** + +```bash +$M start ni-agent && sleep 5 && $M logs ni-agent --no-follow | tail -5 && $M stop ni-agent && $M uninstall ni-agent --force +``` +Expected: services `agent` et `db-pg-*` démarrent (`docker compose ps` pendant le `sleep` si besoin), puis arrêt et suppression. + +- [ ] **Step 5: Install legacy** + +Réutiliser `/tmp/legacy-agent` (Task 2 step 4 ; le recréer sinon). + +```bash +cp -r /tmp/legacy-agent /tmp/legacy-copy +$M build /tmp/legacy-copy --diff +$M db add /tmp/legacy-copy --engine valkey --mode new --auth --non-interactive; echo "exit=$?" +ls /tmp/legacy-copy; head -1 /tmp/legacy-copy/docker-compose.yml +(cd /tmp/legacy-copy && docker compose config --quiet && echo "compose config OK") +diff <(python3 -c "import json; print(sorted((d['type'], d.get('host')) for d in json.load(open('/tmp/legacy-agent/databases.json'))['databases']))") <(python3 -c "import json; print(sorted((d['type'], d.get('host')) for d in json.load(open('/tmp/legacy-copy/databases.json'))['databases'] if d['type']!='valkey'))") && echo "entries preserved" +$M start /tmp/legacy-copy && $M stop /tmp/legacy-copy && $M uninstall /tmp/legacy-copy --force +``` +Expected: diff montre en-tête + `restart:` sur redis ; `db add` exit 0 avec le warning `Legacy compose backed up to docker-compose.legacy.yml` ; `docker-compose.legacy.yml` présent ; en-tête `# Generated` ; `compose config OK` ; `entries preserved` ; les anciens services démarrent avec leurs volumes existants (noms de service inchangés). + +- [ ] **Step 6: Dashboard** + +```bash +cd /tmp && rm -rf ni-dash +$M --non-interactive dashboard ni-dash --port 8899 --db-mode external --yes; echo "exit=$?" +(cd ni-dash && docker compose config --quiet && echo OK && grep -c "db:" docker-compose.yml) +$M --non-interactive dashboard ni-dash2 --port 8898 --db-mode internal --yes && (cd ni-dash2 && grep -c "postgres" docker-compose.yml) +printf 'pw\n' | $M --non-interactive dashboard ni-dash3 --port 8897 --db-mode custom --db-host pg.example --db-user u --db-password-stdin --yes && grep DATABASE_URL ni-dash3/.env +$M build ni-dash3 --diff +``` +Expected: external `OK 1` ; internal `0` ; custom `.env` avec `DATABASE_URL="postgresql://u:pw@pg.example:5432/portabase?schema=public"` ; diff vide. + +- [ ] **Step 7: Interactif** + +`$M agent int-agent` sans flags : bannière, prompts Edge Key / Timezone / Polling / extra_hosts, création, boucle « Add a database? » → ajouter un postgres (prompts options avec aide affichée) puis un redis (variante), répondre non, ne pas démarrer. Puis `$M db remove int-agent` avec sélection interactive. Ctrl-C au milieu d'un prompt → `Cancelled.` exit 130 sans traceback, fichiers cohérents (`docker compose config` passe). + +- [ ] **Step 8: CI `build-smoke`** + +Dans `ci.yml`, étape `Smoke` du job `build-smoke` : + +```yaml + - name: Smoke + env: + PORTABASE_TEMPLATES_DIR: ${{ github.workspace }}/templates + run: | + ./dist/portabase_smoke --version + cd "$(mktemp -d)" + K=$(printf '{"serverUrl":"http://x","agentId":"a","masterKeyB64":"k"}' | base64 -w0) + "$GITHUB_WORKSPACE/dist/portabase_smoke" --non-interactive agent smoke --key "$K" + "$GITHUB_WORKSPACE/dist/portabase_smoke" --non-interactive db add smoke --engine postgresql --mode new + "$GITHUB_WORKSPACE/dist/portabase_smoke" --non-interactive build smoke --diff + cd smoke && docker compose config --quiet +``` + +Le runner GitHub a Docker ; `agent` appelle `require_docker` + `ensure_network` → fonctionne. Si le daemon n'est pas disponible sur un runner donné, l'étape échoue explicitement (`E_DOCKER`) — c'est voulu. + +- [ ] **Step 9: README** + +Ajouter une section « Upgrading from 26.07 or earlier » : le premier `db add`/`build` re-génère `docker-compose.yml` (sauvegarde `docker-compose.legacy.yml`), les personnalisations vont dans `docker-compose.override.yml`, `portabase build --diff` montre les changements avant. + +- [ ] **Step 10: Commit, PR, rc** + +```bash +git add -A +git commit -m "ci: smoke-test agent creation and rendering with the built binary; document migration" +git checkout -b refactor/render-commands && git push -u origin refactor/render-commands +``` +PR « refactor: declarative rendering, flag-driven commands, build » → merge → Bump `26.09.0rc1` → tester le binaire rc sur une vraie install legacy → Bump `26.09.0` stable. + +--- + +## Self-review + +**Spec coverage :** +- §4.2 inventaire commandes : `agent` (flags, boucle interactive, aucune DB en non-interactif) ✔ ; `dashboard` (flags, modes, `--yes`) ✔ ; `db add` (tous flags + `-o`, rejet des flags non pertinents) ✔ ; `db remove` (`--id/--name`, `--purge-volume`, volume conservé par défaut) ✔ ; `db list` (options non-défaut) ✔ ; `build` (`--diff`, `--stdout`, `--inline-env`, `--output`) ✔ ; détection `kind` ✔ ; `back` supprimé ✔. +- §4.3 `AddDatabaseFlow.collect/apply`, séquence, usage par les deux commandes, rendu après chaque ajout ✔. +- §5.1 `DatabaseSpec.from_json` → `spec_from_entry` ; `AgentProject` (managed, socket, mounts, validate doublons) ✔ ; `DashboardProject.db_mode` ✔ ; `host` managé sans `_PORT` → traité externe (warning non émis : ajouter `ui.warning` dans `DbListCommand` si souhaité — non bloquant). +- §5.2 `EnvFile` ✔ (ordre, commentaires, quotes, merge, remove_prefix, atomique). +- §5.3 `ComposeFacts.host_gateway` liste/dict, jamais d'erreur ✔. +- §5.5 renderer, contexte, `*_var`, validation avant écriture, en-tête, `templates.resolve()` avant mutation ✔. +- §5.7 legacy : backup `.legacy.yml` une fois, `start/stop/logs` sans migration, `build --diff` ✔. +- §6.1 options : `-o`, validation clés, prompts avec `help`, projection non-défaut ✔. +- §7.2 `Summary` (masque), `DataTable`, `Diff` ✔. +- §10 F : `build-smoke` étendu, rc obligatoire ✔. + +**Placeholders :** aucun. + +**Cohérence :** `report_write` défini dans `commands/db.py`, importé par `agent`, `dashboard`, `build` ✔ ; `DbEngine.describe/label_default/non_default_options` utilisés ✔ ; `SqliteEngine.mount_for` (Plan 3) utilisé par `AgentProject.sqlite_mounts` ✔ ; `RenderResult.write/diff_against/validate` utilisés par les 4 commandes ✔ ; `DockerRunner.remove_volume` ajouté et utilisé ✔ ; `UI.summary/table/diff` ajoutés et utilisés ✔ ; `TemplateRepository.engine_template` (Plan 3) utilisé par `_service` ✔. + +**Écarts connus :** +- `templates-upload.yml` `latest/` : décision utilisateur (Task 9 step 2). +- Import local de `sys` dans deux `run` : ruff peut préférer un import de module ; déplacer. diff --git a/docs/superpowers/specs/2026-09-11-cli-refactor-design.md b/docs/superpowers/specs/2026-09-11-cli-refactor-design.md new file mode 100644 index 0000000..3655b72 --- /dev/null +++ b/docs/superpowers/specs/2026-09-11-cli-refactor-design.md @@ -0,0 +1,590 @@ +# Refonte du CLI Portabase — Design + +Date : 2026-09-11 +Statut : validé en brainstorming, en attente de relecture avant plan d'implémentation. + +## 1. Objectifs et périmètre + +Refonte structurelle du CLI sans changement de dépendances majeures (Typer, Rich, questionary, requests, PyYAML conservés ; Jinja2 ajouté). + +Objectifs : + +- Supprimer la duplication entre `commands/agent.py` et `commands/db.py` (~500 lignes du wizard "ajouter une base" copiées). +- Remplacer la génération de `docker-compose.yml` par chirurgie texte (`.replace`, regex, ancres) par un rendu Jinja2 complet et déterministe. +- POO sur toute l'application : commandes, services, moteurs, composants UI en classes. Les utilitaires purs (`slugify`, `generate_password`, `validate_edge_key`) restent des fonctions. +- Chaque commande configurable intégralement par paramètres, sans mode interactif. +- Bibliothèque de composants `ui/` (approche shadcn : tokens, composants stateless, façade unique). +- Catcher d'erreurs unique avec hiérarchie d'exceptions et codes de sortie stables. +- Couche télémétrie prête pour OpenTelemetry, opt-in, sans dépendance immédiate. +- Plus d'auto-update : notification seulement, mise à jour manuelle. +- CI de PR (lint, Gitleaks, Plumber, validation des templates, build smoke), suppression du script `./release`. + +Hors périmètre de cette spec : + +- Tests automatisés (spec suivante ; la structure en tient compte : injection de dépendances, services sans I/O terminal, job `test` vide en CI). +- Sortie machine `--json`. +- Exporter OTel réel (le contrat est posé, l'implémentation viendra avec un endpoint). +- Fichier de spec déclaratif `build -f spec.yml`. +- Support Windows (inchangé : code présent, hors matrice de build). + +## 2. Décisions structurantes + +| Sujet | Décision | +|---|---| +| Layout | Plat, conservé (`main.py` racine, `--paths=.`). Nouveaux dossiers `services/`, `engines/`, `ui/`. | +| État d'une install | Aucun fichier d'état ajouté. Source de vérité = `.env` (variables runtime des conteneurs uniquement) + `databases.json` (contrat agent, inchangé) + lecture structurelle du compose existant pour le seul fait non dérivable (`host_gateway`). | +| Compose | Artefact dérivé, propriété du CLI, re-rendu intégralement à chaque commande mutante. Personnalisations utilisateur via `docker-compose.override.yml` (mécanisme Compose natif). | +| Templates | 100 % remote (S3), versionnés par version CLI exacte, manifest avec sha256, cache disque. Suppression du fallback `latest`. Source dans `templates/` à la racine du dépôt. | +| Création multi-DB | En non-interactif, `portabase agent` crée un agent sans base ; les bases s'ajoutent par `portabase db add` (un appel par base). En interactif, `agent` enchaîne sur une boucle « Add a database? » qui réutilise le même flux que `db add`. Pas de DSL `--db engine:opts`. | +| Options moteur | Flag générique répétable `-o/--option KEY=VALUE`, validé contre `DbEngine.option_fields()`. Pas de flag Typer par option. | +| Moteurs DB | Classes Python (`engines/`), registre à imports explicites. Pas de manifeste data-driven. | +| Input UI | questionary uniquement. `rich.prompt` et `typer.prompt` bannis (ruff). | +| Non-interactif | Flag `--non-interactive`, env `PORTABASE_NON_INTERACTIVE`, ou `stdin` non-TTY. Géré par `ui.Form`, pas par les commandes. | +| Erreurs | `PortabaseError` + sous-classes, codes de sortie distincts, un seul `try` dans `main.py`. `except:` nus interdits. | +| Télémétrie | Interface `Telemetry`, `NoopTelemetry` par défaut, opt-in via config globale, jamais de prompt. | +| Updater | Notification après la commande (cache 24 h, silencieux si hors ligne), `portabase update` manuel avec vérification de checksum. | +| Release | `bump.yml` (`workflow_dispatch`) remplace `./release`. Workflows de release sur tag inchangés. | +| Mot de passe | `generate_password` retire `$` et `` ` `` des symboles (cassent `--requirepass "${PASSWORD}"` via shell). Ne s'applique qu'aux nouvelles bases. | + +## 3. Structure des fichiers + +``` +cli/ +├── main.py # build_app(), catcher d'erreurs, codes de sortie +├── pyproject.toml # + jinja2 ; pyinstaller/ruff/pytest en groupe dev +│ +├── commands/ +│ ├── base.py # Command ABC, CommandGroup +│ ├── agent.py # AgentCommand +│ ├── dashboard.py # DashboardCommand +│ ├── build.py # BuildCommand +│ ├── lifecycle.py # Start/Stop/Restart/Logs/Uninstall (ex-common.py) +│ ├── db.py # DbCommands : add / remove / list +│ ├── config.py # ConfigCommands : get / set +│ ├── update.py # UpdateCommand +│ └── flows/ +│ └── add_database.py # AddDatabaseFlow : collecte + application, partagé par agent et db add +│ +├── services/ +│ ├── project.py # AgentProject, DashboardProject, DatabaseSpec, detect_kind() +│ ├── envfile.py # EnvFile +│ ├── compose_facts.py # ComposeFacts (lecture structurelle, jamais d'écriture) +│ ├── renderer.py # ComposeRenderer, RenderResult +│ ├── templates.py # TemplateRepository, Manifest +│ ├── docker.py # DockerRunner +│ ├── ports.py # PortAllocator +│ ├── http.py # HttpClient +│ ├── updater.py # UpdateChecker, Updater +│ └── telemetry.py # Telemetry ABC, NoopTelemetry, ConsoleTelemetry, TelemetryFactory +│ +├── engines/ +│ ├── __init__.py # registry = EngineRegistry([...]) — imports explicites +│ ├── base.py # DbEngine ABC, Field +│ ├── registry.py # EngineRegistry +│ ├── sql.py # StandardSqlEngine + Postgres/PostgresCluster/MySQL/MariaDB/MSSQL/Firebird +│ ├── redis.py # RedisEngine +│ ├── valkey.py # ValkeyEngine +│ ├── mongo.py # MongoEngine +│ ├── sqlite.py # SqliteEngine +│ └── docker_volume.py # DockerVolumeEngine +│ +├── ui/ +│ ├── __init__.py # façade UI +│ ├── theme.py # PALETTE → RICH_THEME + QUESTIONARY_STYLE +│ ├── form.py # Form (flag → prompt → défaut → erreur) +│ └── components/ +│ ├── base.py # Component(console) +│ ├── banner.py message.py section.py summary.py table.py +│ ├── status.py hints.py diff.py prompt.py +│ +├── core/ +│ ├── errors.py # PortabaseError + sous-classes +│ ├── config.py # GlobalConfig (~/.portabase/config.json) +│ ├── version.py # current_version() +│ └── utils.py # slugify, generate_password, validate_edge_key — fonctions pures +│ +├── templates/ # source des templates remote (assets, pas un package Python) +│ ├── agent.yml.j2 +│ ├── dashboard.yml.j2 +│ ├── engines.map.json # clé moteur → template (pour engines-check et manifest) +│ └── engines/ +│ ├── postgresql.yml.j2 mysql.yml.j2 mariadb.yml.j2 mssql.yml.j2 +│ ├── firebird.yml.j2 mongodb.yml.j2 redis.yml.j2 valkey.yml.j2 +│ +├── scripts/ +│ └── render_check.py # rend tous les templates avec fixtures, valide YAML + compose config +│ +├── .github/workflows/ +│ ├── ci.yml # PR : lint, render-check, engines-check, gitleaks, plumber, build-smoke, test +│ ├── bump.yml # workflow_dispatch : bump version + tag +│ ├── templates-hotfix.yml # workflow_dispatch : re-upload templates vers une version existante +│ ├── release.yml, release-candidate.yml, python.yml, github.yml # inchangés (hors durcissement) +│ └── templates-upload.yml # + génération manifest.json, source templates/ +│ +├── .gitleaks.toml +└── supprimés : release, templates/compose.py, templates/__init__.py, commands/common.py, + core/network.py, core/docker.py, .github/assets/templates/ +``` + +Règle de dépendance, descendante uniquement : + +- `commands` → `services`, `engines`, `ui`, `core` +- `services` → `engines`, `core` (jamais `ui` : un service lève, n'affiche rien) +- `engines` → `core` +- `ui` → `core` + +## 4. Commandes + +### 4.1 `Command` + +```python +class Command(ABC): + name: str + help: str + panel: str = "General" + + def __init__(self, ui: UI, telemetry: Telemetry): ... + def register(self, app: typer.Typer) -> None: + app.command(self.name, help=self.help, rich_help_panel=self.panel)(self.run) + + @abstractmethod + def run(self, *args, **kwargs) -> None: ... +``` + +`base.py` wrappe `run` dans `telemetry.span(f"command.{name}")`. Les dépendances (`DockerRunner`, `TemplateRepository`, `EngineRegistry`, `PortAllocator`) sont injectées par constructeur dans `main.build_app()`. + +Signatures Typer en `Annotated[...]`. Chaque option qui correspond à une question du wizard a une valeur par défaut `None` : présente → utilisée, absente → prompt (interactif) ou défaut/erreur (non-interactif). Une seule méthode `_collect()` par commande, aucun `if non_interactive` dans la logique métier. + +### 4.2 Inventaire + +| Commande | Options notables | Effet | +|---|---|---| +| `agent NAME` | `--key`, `--tz`, `--polling`, `--host-gateway/--no-host-gateway`, `--start`, `--force`, `--non-interactive` | crée le dossier, `.env`, `databases.json` vide, rend le compose. En interactif, enchaîne sur une boucle « Add a database? » (`AddDatabaseFlow`, rendu après chaque ajout). En non-interactif, ne crée aucune base. | +| `dashboard NAME` | `--port`, `--db-mode external\|internal\|custom`, `--db-host/--db-port/--db-name/--db-user/--db-password-stdin`, `--start`, `--force` | crée `.env`, rend le compose. | +| `db add NAME` | `--engine`, `--mode new\|existing`, `--auth/--no-auth`, `--name`, `--host`, `--port`, `--database`, `--user`, `--password`, `--password-stdin`, `--path`, `--volume`, `--container`, `--label`, `-o/--option KEY=VALUE` (répétable) | collecte via `AddDatabaseFlow` selon le moteur, mute `.env` + `databases.json`, re-rend. Flag ou option fourni mais non pertinent pour le moteur/mode → `ValidationError`. | +| `db remove NAME` | `--id` ou `--name`, `--purge-volume` | retire l'entrée, retire les variables `.env` du service, re-rend. Le volume Docker n'est supprimé que sur `--purge-volume`. | +| `db list NAME` | — | lecture seule. | +| `build PATH` | `--diff`, `--stdout`, `--inline-env`, `--output DIR` | re-rend depuis l'état. Sans option : écrit en place (= migration legacy). `--inline-env` substitue les valeurs au lieu de `${VAR}` avec avertissement secrets en clair. | +| `start/stop/restart/logs/uninstall PATH` | inchangées (`uninstall --force`) | n'utilisent pas le renderer, fonctionnent sur toute install. | +| `config get/set` | inchangées + clés `telemetry`, `telemetry_endpoint`, `channel` | config globale. | +| `update` | — | mise à jour manuelle avec vérification checksum. | + +Options globales : `--verbose`, `--debug`, `--no-color`, `--non-interactive`. Détection `kind` d'un dossier : `databases.json` présent → agent ; `PROJECT_SECRET` dans `.env` → dashboard. + +Le choix `back` dans les selects disparaît : interactif = Ctrl-C (`UserAbort`) ou entrée "cancel" en fin de liste. + +### 4.3 `AddDatabaseFlow` (`commands/flows/add_database.py`) + +Le wizard d'ajout de base est un objet réutilisable, pas une commande. C'est la duplication actuelle entre `agent.py` et `db.py` qui disparaît. + +```python +class AddDatabaseFlow: + def __init__(self, ui: UI, engines: EngineRegistry, ports: PortAllocator): ... + + def collect(self, values: dict) -> DatabaseSpec: + """values = flags parsés (engine, mode, auth, host…, options). + Champ manquant → prompt (interactif) / défaut / ValidationError (non-interactif).""" + + def apply(self, project: AgentProject, spec: DatabaseSpec) -> None: + """Mute project.env (variables du service si managed) et project.databases, en mémoire.""" +``` + +Séquence de `collect` : moteur (`--engine` ou select) → affiche `engine.warning` s'il existe → mode (`--mode` ou select ; sqlite et docker-volume n'ont pas de mode `existing`/`new` au sens service : sqlite distingue fichier créé vs chemin existant, docker-volume n'a qu'un mode) → variante auth si `engine.auth_variants` → `Form.collect(engine.fields_new() | fields_existing(), values)` → `Form.collect(engine.option_fields(), values["options"])` → `engine.generate(...)` ou construction depuis les réponses. + +Utilisation : + +- `DbAddCommand.run` : `AgentProject.load` → `templates.ensure()` → `flow.collect(flags)` → `flow.apply` → `renderer.render_agent` → `write`. +- `AgentCommand.run` (interactif seulement) : après le premier rendu, `while ui.confirm("Add a database?", default=True)` : `flow.collect({})` → `flow.apply` → rendu + écriture. Rendu après chaque ajout : un Ctrl-C au milieu laisse un état cohérent sur disque. + +`flows/` vit dans `commands/` parce qu'il prompte via `ui` ; il ne fait aucune I/O fichier (c'est `RenderResult.write` qui écrit). + +## 5. État, templates, rendu + +### 5.1 Modèle de données (`services/project.py`) + +Objets en mémoire construits depuis le disque, jamais persistés tels quels. + +```python +@dataclass(frozen=True) +class DatabaseSpec: + id: str; engine: str; name: str; managed: bool + host: str | None; port: int | None; database: str | None + username: str | None; password: str | None + path: str | None # sqlite + volume: str | None; container: str | None # docker-volume + options: dict + + @classmethod + def from_json(cls, raw: dict, env: EnvFile) -> "DatabaseSpec": ... + def to_json(self) -> dict: ... # format databases.json actuel, inchangé + @property + def env_prefix(self) -> str: ... # "db-pg-a1f2" → "DB_PG_A1F2" + + +@dataclass +class AgentProject: + path: Path; env: EnvFile; databases: list[DatabaseSpec]; host_gateway: bool + + @property + def needs_docker_socket(self) -> bool # une entrée docker-volume + @property + def managed(self) -> list[DatabaseSpec] + @property + def sqlite_mounts(self) -> list[tuple[str, str]] # database commence par /config/ → ./x:/config/x + + +@dataclass +class DashboardProject: + path: Path; env: EnvFile + @property + def db_mode(self) -> Literal["external", "custom", "internal"] + # POSTGRES_HOST absent → internal ; == "db" → external ; sinon custom +``` + +Détection `managed` : `.env` contient `{PREFIX}_PORT` pour ce `host` (toutes les bases `new` l'écrivent, aucune `existing`). Si l'agent tolère les clés inconnues dans `databases.json`, une clé explicite `managed: true` sera ajoutée et la détection deviendra le fallback — à vérifier côté agent. + +Cas limites : + +- `host` managé sans `{PREFIX}_PORT` dans `.env` → `ui.warning`, la base est traitée comme externe. +- Deux entrées avec le même `host` → `ConfigError` avant tout rendu. +- `.env` ou `databases.json` absent → `ConfigError("Not a Portabase agent folder")`. + +### 5.2 `EnvFile` (`services/envfile.py`) + +Remplace `write_env_file`. Parse `KEY="v"`, `KEY='v'`, `KEY=v`, `export KEY=`, commentaires, lignes vides. Conserve l'ordre et les commentaires (liste de lignes typées). `merge()` met à jour en place et ajoute en fin ; `remove(prefix)` retire les `PREFIX_*`. Écriture toujours quotée `"…"`, `"` et `\` échappés. Sauvegarde atomique (tmp + `os.replace`). + +`.env` ne contient que des variables consommées par les conteneurs. Aucune métadonnée CLI. + +### 5.3 `ComposeFacts` (`services/compose_facts.py`) + +`yaml.safe_load` du compose existant, lecture seule, jamais réécrit. Expose `host_gateway` (présence de `extra_hosts` sur `services.agent`, forme liste ou dict tolérée). Compose absent ou invalide → valeurs par défaut + `ui.warning`, jamais d'erreur. + +### 5.4 `TemplateRepository` (`services/templates.py`) + +- URL : `{TEMPLATE_BASE_URL}/{version}/manifest.json` puis fichiers listés. +- Résolution de version : `current_version()` ; sinon `PORTABASE_TEMPLATES_VERSION` ; sinon `PORTABASE_TEMPLATES_DIR` (court-circuite S3) ; sinon `TemplateError`. En dev non-frozen, `./templates` à côté de `main.py` est utilisé automatiquement s'il existe. +- Cache `~/.portabase/cache/templates//`. Séquence `ensure()` : GET manifest (10 s) → pour chaque fichier, sha256 identique en cache → skip, sinon GET + vérification sha256 et taille → écriture. Fichiers en cache absents du manifest supprimés. Manifest injoignable avec cache complet → warning et cache ; sans cache → `TemplateError` avec hint. +- Jinja2 : `Environment(undefined=StrictUndefined, keep_trailing_newline=True, autoescape=False)`. `{{ }}` ne collisionne pas avec `${}` Compose. + +Manifest : + +```json +{ + "schema": 1, + "version": "26.09.0", + "generated_at": "2026-09-11T14:02:17Z", + "commit": "858d4926…", + "files": { + "agent.yml.j2": { "sha256": "…", "size": 612 }, + "engines/postgresql.yml.j2": { "sha256": "…", "size": 498 } + }, + "engines": { + "postgresql": "engines/postgresql.yml.j2", + "postgresql-cluster": "engines/postgresql.yml.j2" + } +} +``` + +`schema` inconnu → `TemplateError`. `version` ≠ version demandée → `TemplateError`. `engines` sert à `engines-check` en CI et à `get_engine(key)`. + +### 5.5 `ComposeRenderer` (`services/renderer.py`) + +```python +class ComposeRenderer: + def __init__(self, templates: TemplateRepository, engines: EngineRegistry): ... + def render_agent(self, project: AgentProject, inline: bool = False) -> RenderResult: ... + def render_dashboard(self, project: DashboardProject, inline: bool = False) -> RenderResult: ... +``` + +Contexte `agent.yml.j2` : `host_gateway`, `docker_socket`, `mounts` (sqlite), `services` (liste de `{name, volume, body}` où `body` est le rendu du template moteur). Le renderer passe aux templates moteurs des variables **déjà formées** (`port_var = "${DB_PG_A1F2_PORT}"` ou valeur littérale si `inline`) : la logique de nommage reste en Python, les templates restent lisibles. + +`RenderResult` : `compose: str`, `databases: list[dict]`. `write(path)` valide d'abord (`yaml.safe_load` du compose → sinon `TemplateError`, un template remote cassé ne corrompt jamais une install), puis écrit `docker-compose.yml` et `databases.json` atomiquement. Le compose porte un en-tête `# Generated by Portabase CLI . Do not edit — use docker-compose.override.yml.` + +Ordre dans une commande mutante : collecte → `templates.ensure()` → mutation `.env`/`databases.json` en mémoire → rendu → validation → écriture. Le manifest est vérifié avant toute mutation. + +### 5.6 Templates + +`agent.yml.j2` : + +```jinja +services: + agent: + restart: unless-stopped + image: portabase/agent:latest + volumes: + - ./databases.json:/config/config.json +{%- for m in mounts %} + - {{ m.host }}:{{ m.container }} +{%- endfor %} +{%- if docker_socket %} + - /var/run/docker.sock:/var/run/docker.sock +{%- endif %} +{%- if host_gateway %} + extra_hosts: + - "localhost:host-gateway" +{%- endif %} + environment: + TZ: "${TZ}" + EDGE_KEY: "${EDGE_KEY}" + LOG_LEVEL: "${LOG_LEVEL}" + POLLING: "${POLLING}" + networks: + - portabase +{% for s in services %} +{{ s.body }} +{%- endfor %} +{% if services %} +volumes: +{%- for s in services %} + {{ s.volume }}: +{%- endfor %} +{% endif %} +networks: + portabase: + name: portabase_network + external: true +``` + +Templates moteurs : un par moteur, variante auth par `{% if auth %}` (10 snippets actuels → 8 templates ; `postgresql-cluster` réutilise `postgresql.yml.j2`). `dashboard.yml.j2` : `{% if db_mode == "external" %}` autour du service `db`, de `depends_on` et du volume — remplace les trois `re.sub` de `dashboard.py`. + +### 5.7 Installs legacy + +Aucun marqueur de version nécessaire. `AgentProject.load()` fonctionne sur toute install (`.env` + `databases.json` existent déjà). Au premier `RenderResult.write()` sur un compose sans l'en-tête `# Generated by Portabase CLI`, le fichier est copié en `docker-compose.legacy.yml` et un avertissement est affiché. `portabase build PATH --diff` permet de voir le diff avant. Les commandes `start/stop/logs` ne déclenchent rien. + +Différences attendues au premier rendu d'une install ancienne : `restart: unless-stopped` ajouté sur redis/valkey (absent des snippets actuels) ; à mentionner dans le changelog rc. + +## 6. Moteurs DB (`engines/`) + +```python +@dataclass(frozen=True) +class Field: + name: str; prompt: str + kind: Literal["text", "int", "secret", "bool", "choice"] + default: Any = None; choices: tuple[str, ...] = (); help: str | None = None + validator: Callable[[Any], Any] | None = None + + +class DbEngine(ABC): + key: str; display: str; default_port: int + template: str | None # None = aucun service Compose (sqlite, docker-volume, existing) + auth_variants: bool = False + warning: str | None = None + + def fields_existing(self) -> list[Field]: ... # défaut : host, port, database, username, password + def fields_new(self) -> list[Field]: ... # défaut : [] (tout généré) + def option_fields(self) -> list[Field]: ... # défaut : [] + def generate(self, service: str, auth: bool, ports: PortAllocator) -> DatabaseSpec: ... + def env_vars(self, spec: DatabaseSpec) -> dict[str, str]: ... + def template_ctx(self, spec: DatabaseSpec, inline: bool) -> dict: ... + def agent_entry(self, spec: DatabaseSpec) -> dict: ... # projection databases.json + def agent_database(self, spec: DatabaseSpec) -> str: ... # défaut : spec.database ; hook pour "0", chemin… +``` + +Hiérarchie : `StandardSqlEngine` (postgresql, postgresql-cluster, mysql, mariadb, mssql, firebird), `RedisEngine`, `ValkeyEngine`, `MongoEngine`, `SqliteEngine`, `DockerVolumeEngine`. Redis et Valkey sont deux classes indépendantes dans deux fichiers, sans base commune (images, commandes et healthchecks divergent ; ce qu'elles partagent — `agent_database = "0"`, `auth_variants` — passe par les hooks de `DbEngine`). Les sous-classes ne surchargent que leurs particularités : + +| Moteur | Particularité | +|---|---| +| postgresql | `option_fields` : `keep_ownership` (bool, défaut False), `clean_mode` (choice clean/none/drop_schemas/drop_database, défaut clean) | +| postgresql-cluster | `warning` superuser ; pas d'options | +| firebird | `agent_entry.name = "mirror.fdb"` ; var `_ROOT_PASS` | +| mssql | `agent_entry.username = "sa"` | +| redis | `agent_database = "0"` ; `auth_variants = True` ; no-auth → `_PORT` seul | +| valkey | idem redis, classe et template distincts | +| mongodb | `auth_variants = True` | +| sqlite | `fields_new` : nom de fichier ; `fields_existing` : chemin ; pas de template ; mount si chemin relatif | +| docker-volume | `fields` : volume, container (optionnel), label ; `warning` socket ; pas de template | + +`EngineRegistry` : dict `key → instance`, imports explicites (compatible PyInstaller). `get(key)` inconnu → `ValidationError` avec la liste des clés. + +### 6.1 Options moteur + +Certains moteurs exposent des options que l'agent lit dans `databases.json` (`options` : aujourd'hui `keep_ownership` et `clean_mode` pour PostgreSQL). Le système doit accepter de nouvelles options sans toucher à la signature Typer. + +- Déclaration : `DbEngine.option_fields() -> list[Field]`. Un `Field` comme les autres : nom, prompt, type, défaut, choix, validateur. +- Saisie non-interactive : flag générique répétable `-o KEY=VALUE` / `--option KEY=VALUE` sur `db add`. Parsé en `dict[str, str]`, converti selon `Field.kind` (`bool` : `true/false/1/0/yes/no`, `int`, `choice` validé contre `choices`). Clé inconnue pour ce moteur → `ValidationError` listant les options valides. +- Saisie interactive : `Form.collect(engine.option_fields(), values["options"])`, un prompt par option non fournie, avec le texte d'aide actuel (par exemple l'explication de `--no-owner` / `pg_restore --clean`) porté par `Field.help`. +- Stockage : `DatabaseSpec.options: dict` (valeurs typées). +- Projection : `agent_entry()` n'écrit dans `options` que les valeurs différentes du défaut. Comportement actuel conservé : `keep_ownership` absent si False, `clean_mode` absent si `clean`. Aucune clé `options` si vide. +- Affichage : `db list` montre les options non-défaut ; `Summary` les inclut lors de l'ajout. + +Ajouter une option = une ligne dans `option_fields()` du moteur concerné. + +## 7. `ui/` + +### 7.1 Principes + +- Tokens uniques (`ui/theme.py`) : `PALETTE` → `RICH_THEME` et `QUESTIONARY_STYLE`. +- Composants stateless, un par fichier, `Component(console)`. +- Façade `UI` : seule chose importée par `commands/`. Rich et questionary ne sont jamais importés hors de `ui/`. +- Le markup Rich est autorisé dans les arguments texte (`ui.success("Added [bold]x[/bold]")`). +- `NO_COLOR` / `--no-color` → `Console(no_color=True)`, style questionary vide. +- Jamais de prompt à l'intérieur d'un `ui.status()` (structurellement garanti : les services ne promptent pas). + +### 7.2 Composants + +| Composant | Remplace | +|---|---| +| `Banner` | `print_banner` | +| `Message` (`success/info/warning/error`) | ~60 `console.print("[success]✔ …")` | +| `Section` | `Panel("[bold]Database Setup[/bold]")` | +| `Summary` (masque auto des clés `password/secret/key`) | `Table(show_header=False)` de dashboard | +| `DataTable` | `Table` de `db list` | +| `Status` (context manager, hint injecté) | `console.status(msg + hint)` | +| `Hint` | `get_random_hint` | +| `Diff` | nouveau, pour `build --diff` | +| `Prompt` (`text/integer/secret/confirm/select/path`) | `rich.prompt.*`, `questionary.*` épars | + +Règle anti-dérive : un composant n'existe que s'il a un appelant. L'inventaire ci-dessus est un plafond. + +### 7.3 `Form` + +```python +class Form: + def ask(self, field: Field, value: Any | None) -> Any: + # 1. valeur du flag → validée + # 2. non-interactif : défaut, sinon ValidationError("Missing --") + # 3. interactif : prompt selon field.kind (dispatch dict), None (Ctrl-C) → UserAbort + # validation en boucle jusqu'à valeur acceptée + def collect(self, fields: list[Field], values: dict) -> dict: ... + def text(...), integer(...), confirm(...), choice(...) # raccourcis +``` + +`non_interactive` résolu une fois dans `main.py`. `ui.confirm()` en non-interactif renvoie le défaut ; les confirmations destructives ont `default=False` et un flag `--force`. + +## 8. Erreurs, télémétrie, updater + +### 8.1 Hiérarchie (`core/errors.py`) + +| Classe | `code` | exit | +|---|---|---| +| `PortabaseError` | `E_GENERIC` | 1 | +| `UserAbort` | `E_ABORT` | 130 | +| `ValidationError` | `E_VALIDATION` | 2 | +| `ConfigError` | `E_CONFIG` | 3 | +| `DockerError` | `E_DOCKER` | 4 | +| `TemplateError` | `E_TEMPLATE` | 5 | +| `NetworkError` | `E_NETWORK` | 6 | +| `UpdateError` | `E_UPDATE` | 7 | + +Constructeur : `(message, *, hint=None, cause=None)`. Les exceptions tierces (`requests`, `subprocess`, `yaml`, `jinja2`) sont wrappées à la frontière du service. `typer.Exit` n'est plus levé hors de `main.py`. Ruff : `E722`, `BLE001`, `S110`, `TID251`. + +### 8.2 Catcher (`main.py`) + +`app(standalone_mode=False)` dans un seul `try` : `UserAbort` → "Cancelled." exit 130 ; `PortabaseError` → `ui.error(e)` (message, hint, code ; `--verbose` ajoute cause et traceback), `telemetry.error(e)`, exit `e.exit_code` ; `click.UsageError` → mappé en `ValidationError` ; `KeyboardInterrupt` → exit 130 ; `Exception` → "Unexpected error", télémétrie `unexpected=True`, exit 1. `finally: telemetry.flush()`. + +### 8.3 Télémétrie (`services/telemetry.py`) + +```python +class Telemetry(ABC): + def session(self, **attrs) -> ContextManager # span racine par invocation + def span(self, name: str, **attrs) -> ContextManager + def event(self, name: str, **attrs) -> None + def error(self, exc: Exception, unexpected: bool = False) -> None + def flush(self) -> None +``` + +Implémentations : `NoopTelemetry` (défaut), `ConsoleTelemetry` (`--debug`, stderr), `OtelTelemetry` (futur, import lazy, construit seulement si `telemetry=true` et `telemetry_endpoint` défini). Spans : `Command.run`, `TemplateRepository.ensure`, `ComposeRenderer.render`, `DockerRunner.compose`. Attributs : commande, moteur, mode, durée, code de sortie, `error.code`, version CLI, OS. Jamais : nom d'agent, chemin, clé, credentials, contenu de fichier. + +Opt-in : `portabase config set telemetry true` ou `PORTABASE_TELEMETRY=1`. Une ligne d'information à la première exécution, aucun prompt. + +### 8.4 Updater (`services/updater.py`) + +`UpdateChecker.notify(ui)` appelé après la commande, cache 24 h (`~/.portabase/cache/release.json`), silencieux si hors ligne, `--stdout` ou non-interactif. `Updater.apply()` vérifie le sha256 via `checksums.txt` de la release avant remplacement du binaire. Canal `beta` conservé. + +## 9. CI, sécurité, release + +### 9.1 `ci.yml` (`pull_request`, `push: main`) + +| Job | Contenu | +|---|---| +| `lint` | `ruff check`, `ruff format --check` | +| `render-check` | `scripts/render_check.py` : rend `agent.yml.j2` (0 base, chaque moteur auth/no-auth, socket, host_gateway, mounts sqlite) et `dashboard.yml.j2` × 3 modes via le vrai `ComposeRenderer` (`PORTABASE_TEMPLATES_DIR=./templates`), puis `yaml.safe_load` et `docker compose config` avec `.env` fixture | +| `engines-check` | chaque `DbEngine.template` existe dans `templates/`, chaque template a un moteur, `engines.map.json` cohérent | +| `gitleaks` | action pinnée ; `.gitleaks.toml` allowlist `templates/**` et `HINTS` | +| `plumber` | action drop-in, `verify-attestation: true` | +| `build-smoke` | PyInstaller linux/amd64, `./dist/portabase --version`, `agent smoke --key --non-interactive` avec templates locaux | +| `test` | `pytest` — vide, réservé à la spec tests | + +### 9.2 Durcissement + +- Toutes les actions pinnées par SHA avec commentaire de version ; Dependabot `github-actions` et `uv` hebdomadaires. +- `permissions: {}` au top de chaque workflow, permissions explicites par job. `packages: write` retiré (inutilisé). +- `templates-upload.yml` : plus de `~/.s3cfg` par heredoc ; credentials par variables d'environnement. +- `actions/attest-build-provenance` sur les binaires. + +### 9.3 Release + +`bump.yml` (`workflow_dispatch`, inputs `version`, `channel: stable|rc`) : validation regex, `stable` uniquement depuis `main`, `sed` `pyproject.toml` + `CITATION.cff`, commit `chore(release): X`, tag, push. Les workflows sur tag restent inchangés. `./release` supprimé. Pas de release-please (historique non conventional). Si `main` exige une PR, le workflow ouvre une PR au lieu de pousser — à régler selon la protection de branche. + +`templates-hotfix.yml` (`workflow_dispatch`, input `version`) : re-sync `templates/` vers `templates//` et régénère le manifest. Réservé aux corrections compatibles avec le code de cette version. + +`templates-upload.yml` : source `templates/`, génération de `manifest.json` (sha256, taille, version, commit, date, mapping moteurs depuis `engines.map.json`) avant `s3cmd sync`. + +### 9.4 `pyproject.toml` + +```toml +dependencies = ["typer", "rich", "questionary", "requests", "pyyaml", "jinja2"] +[dependency-groups] +dev = ["pyinstaller", "ruff", "pytest"] +``` + +## 10. Ordre des chantiers + +Graphe de dépendances, pas un calendrier. + +``` +[A] Hygiène CI ─────────────────────────────────────────────┐ indépendant + ci.yml, pin SHA, permissions, bump.yml, pyproject │ + │ +[B] Fondations │ + core/errors, ui/, services/{envfile,docker,http,ports}, │ + main.py catcher, Command ABC │ + │ │ + ├──► [C] Lifecycle en POO (start/stop/…/config/update) + │ (ancien code agent/db/dashboard via LegacyCommand) + │ + └──► [D] Templates .j2 + TemplateRepository + engines/ + render-check + │ + ▼ + [E] Rendu : project, compose_facts, renderer, build + │ + ▼ + [F] agent / dashboard / db réécrits, ancien code supprimé + │ + ▼ + [G] OTel réel, --json, spec tests +``` + +Contraintes : + +- B avant tout code métier. +- D avant E (le renderer se construit contre des templates réels). +- E avant F. +- C et F ne touchent pas les mêmes fichiers ; C peut aller avant ou après D/E. +- A avant F de préférence : `render-check` et `build-smoke` sont le seul filet avant la spec tests. + +Points de livraison : + +| Après | État | Canal | +|---|---|---| +| A | fonctionnellement identique, CI verte | stable | +| B + C | lifecycle en POO, ui/ et erreurs neuves ; `agent`/`db`/`dashboard` = ancien code via `LegacyCommand` | stable | +| D | templates `.j2` uploadés sous la nouvelle version ; l'ancien code lit `agent.yml`, coexistence sur S3 | rc | +| E + F | bascule complète | rc obligatoire, puis stable | + +Risques et parades : + +| Étape | Risque | Parade | +|---|---|---| +| A | mauvais SHA casse un workflow | tag rc jetable | +| B | sur-conception de `ui/` | un composant = un appelant | +| D | template `.j2` diverge d'un snippet actuel | diff manuel des rendus contre l'ancien CLI, une fois | +| E | `ComposeFacts` lit mal un vieux compose | tolérance, warning, jamais de crash | +| F | install legacy cassée après `db add` | `.legacy.yml`, `build --diff`, changelog rc | +| F | mots de passe existants avec `$` | ne pas régénérer ; correction pour les nouvelles bases seulement | + +## 11. Questions ouvertes + +- L'agent tolère-t-il des clés inconnues dans `databases.json` ? Si oui : clé `managed: true` explicite. +- Protection de la branche `main` : `bump.yml` pousse directement ou ouvre une PR ? +- Garder la clé `engines` dans le manifest (double source de vérité avec le code) ou s'en tenir au registre Python ? diff --git a/engines/__init__.py b/engines/__init__.py new file mode 100644 index 0000000..4422409 --- /dev/null +++ b/engines/__init__.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from collections.abc import Iterable, Iterator + +from core.errors import ValidationError +from engines.base import DbEngine +from engines.docker_volume import DockerVolumeEngine +from engines.firebird import FirebirdEngine +from engines.mariadb import MariaDbEngine +from engines.mongodb import MongoEngine +from engines.mssql import MssqlEngine +from engines.mysql import MySqlEngine +from engines.postgresql import PostgresClusterEngine, PostgresEngine +from engines.redis import RedisEngine +from engines.sqlite import SqliteEngine +from engines.valkey import ValkeyEngine + + +class EngineRegistry: + def __init__(self, engines: Iterable[DbEngine]) -> None: + self._by_key: dict[str, DbEngine] = {} + for engine in engines: + if engine.key in self._by_key: + raise ValueError(f"Duplicate engine key: {engine.key}") + self._by_key[engine.key] = engine + + def get(self, key: str) -> DbEngine: + try: + return self._by_key[key] + except KeyError: + raise ValidationError( + f"Unknown engine '{key}'.", + hint="Available: " + ", ".join(self.keys()), + ) from None + + def keys(self) -> list[str]: + return list(self._by_key) + + def choices(self) -> list[str]: + return self.keys() + + def __iter__(self) -> Iterator[DbEngine]: + return iter(self._by_key.values()) + + def __contains__(self, key: str) -> bool: + return key in self._by_key + + +ALL = ( + PostgresEngine(), + PostgresClusterEngine(), + MySqlEngine(), + MariaDbEngine(), + SqliteEngine(), + FirebirdEngine(), + MongoEngine(), + RedisEngine(), + ValkeyEngine(), + MssqlEngine(), + DockerVolumeEngine(), +) + +registry = EngineRegistry(ALL) + +__all__ = ["ALL", "EngineRegistry", "registry"] diff --git a/engines/base.py b/engines/base.py new file mode 100644 index 0000000..444a8d7 --- /dev/null +++ b/engines/base.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import secrets +import uuid +from abc import ABC, abstractmethod +from typing import Any + +from core.fields import Field +from core.specs import DatabaseSpec +from core.utils import escape_yaml_double_quoted, generate_password +from services.ports import PortAllocator + +STANDARD_EXISTING_FIELDS = ( + Field("host", "Host", "text", default="localhost"), + Field("port", "Port", "int"), + Field("database", "Database Name", "text"), + Field("username", "Username", "text"), + Field("password", "Password", "secret"), +) + + +class DbEngine(ABC): + key: str + display: str + default_port: int | None = None + template: str | None = None + auth_variants: bool = False + warning: str | None = None + has_modes: bool = True + label_default: str = "External DB" + + abstract: bool = False + required: tuple[str, ...] = ("key", "display") + + def __init_subclass__(cls, **kwargs: Any) -> None: + super().__init_subclass__(**kwargs) + cls.abstract = "abstract" in cls.__dict__ and cls.__dict__["abstract"] + if cls.abstract: + return + missing = [name for name in cls.required if not getattr(cls, name, None)] + if missing: + raise TypeError( + f"{cls.__module__}.{cls.__qualname__} is missing required engine " + f"attribute(s): {', '.join(missing)}. Set them as class attributes, " + "or set `abstract = True` if this class is only a base for others." + ) + if cls.template is not None and not cls.template.startswith("engines/"): + raise TypeError( + f"{cls.__qualname__}.template must be a path under 'engines/', " + f"got {cls.template!r} (e.g. 'engines/{cls.key}.yml.j2')." + ) + + def fields_existing(self) -> list[Field]: + return [ + Field("port", "Port", "int", default=self.default_port) + if field.name == "port" + else field + for field in STANDARD_EXISTING_FIELDS + ] + + def fields_new(self) -> list[Field]: + return [] + + def option_fields(self) -> list[Field]: + return [] + + @abstractmethod + def generate( + self, *, auth: bool, ports: PortAllocator, answers: dict[str, Any] + ) -> DatabaseSpec: ... + + def from_existing(self, answers: dict[str, Any]) -> DatabaseSpec: + return DatabaseSpec( + id=self.new_id(), + engine=self.key, + name=answers.get("label") or self.label_default, + managed=False, + host=answers["host"], + port=int(answers["port"]), + database=answers["database"], + username=answers["username"], + password=answers["password"], + ) + + def env_vars(self, spec: DatabaseSpec) -> dict[str, str]: + prefix = spec.env_prefix + return { + f"{prefix}_PORT": str(spec.host_port), + f"{prefix}_DB": spec.database or "", + f"{prefix}_USER": spec.username or "", + f"{prefix}_PASS": spec.password or "", + } + + def template_ctx( + self, spec: DatabaseSpec, *, inline: bool = False + ) -> dict[str, Any]: + return { + "name": spec.host, + "volume": f"{spec.host}-data", + "auth": spec.auth, + "port_var": self.var(spec, "PORT", spec.host_port, inline), + "db_var": self.var(spec, "DB", spec.database, inline), + "user_var": self.var(spec, "USER", spec.username, inline), + "password_var": self.var(spec, "PASS", spec.password, inline), + } + + def agent_entry(self, spec: DatabaseSpec) -> dict[str, Any]: + entry: dict[str, Any] = { + "name": spec.name, + "database": self.agent_database(spec), + "type": self.key, + "username": spec.username or "", + "password": spec.password or "", + "port": spec.port, + "host": spec.host, + "generated_id": spec.id, + } + options = self.non_default_options(spec) + if options: + entry["options"] = options + return entry + + def agent_database(self, spec: DatabaseSpec) -> str: + return spec.database or "" + + def describe(self, spec: DatabaseSpec) -> str: + return f"{spec.host}:{spec.port}" + + def non_default_options(self, spec: DatabaseSpec) -> dict[str, Any]: + defaults = {field.name: field.default for field in self.option_fields()} + return { + key: value + for key, value in spec.options.items() + if key in defaults and value != defaults[key] + } + + @staticmethod + def new_id() -> str: + return str(uuid.uuid4()) + + @staticmethod + def service_name(slug: str, auth: bool = False) -> str: + suffix = "auth-" if auth else "" + return f"db-{slug}-{suffix}{secrets.token_hex(2)}" + + @staticmethod + def var(spec: DatabaseSpec, suffix: str, value: Any, inline: bool) -> str: + if inline: + return escape_yaml_double_quoted(str(value if value is not None else "")) + return f"${{{spec.env_prefix}_{suffix}}}" + + +class StandardSqlEngine(DbEngine): + abstract = True + required = (*DbEngine.required, "template", "default_port", "slug", "db_prefix") + + slug: str + db_prefix: str + default_user = "admin" + + def generate( + self, *, auth: bool, ports: PortAllocator, answers: dict[str, Any] + ) -> DatabaseSpec: + db_name = f"{self.db_prefix}_{secrets.token_hex(4)}" + return DatabaseSpec( + id=self.new_id(), + engine=self.key, + name=db_name, + managed=True, + host=self.service_name(self.slug), + port=self.default_port, + host_port=ports.free(), + database=db_name, + username=self.default_user, + password=generate_password(16), + options=dict(answers.get("options", {})), + ) diff --git a/engines/docker_volume.py b/engines/docker_volume.py new file mode 100644 index 0000000..8e37c97 --- /dev/null +++ b/engines/docker_volume.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from typing import Any + +from core.fields import Field +from core.specs import DatabaseSpec +from engines.base import DbEngine +from services.ports import PortAllocator + + +class DockerVolumeEngine(DbEngine): + key, display = "docker-volume", "Docker Volume" + template = None + has_modes = False + label_default = "Docker Volume" + warning = ( + "Requires the Docker socket. It will be mounted on the agent " + "(/var/run/docker.sock)." + ) + + def fields_existing(self) -> list[Field]: + return [ + Field("volume", "Volume Name (e.g. databases_sqlite-data)", "text"), + Field( + "container", + "Container Name (optional, enables auto-restart after restore)", + "text", + default="", + ), + ] + + def fields_new(self) -> list[Field]: + return self.fields_existing() + + def generate( + self, *, auth: bool, ports: PortAllocator, answers: dict[str, Any] + ) -> DatabaseSpec: + return self.from_existing(answers) + + def from_existing(self, answers: dict[str, Any]) -> DatabaseSpec: + return DatabaseSpec( + id=self.new_id(), + engine=self.key, + name=answers.get("label") or self.label_default, + managed=False, + volume=str(answers["volume"]).strip(), + container=(str(answers.get("container") or "").strip() or None), + ) + + def env_vars(self, spec: DatabaseSpec) -> dict[str, str]: + return {} + + def agent_entry(self, spec: DatabaseSpec) -> dict[str, Any]: + entry = { + "name": spec.name, + "type": self.key, + "volume_name": spec.volume, + "generated_id": spec.id, + } + if spec.container: + entry["container_name"] = spec.container + return entry + + def describe(self, spec: DatabaseSpec) -> str: + return f"volume: {spec.volume}" diff --git a/engines/firebird.py b/engines/firebird.py new file mode 100644 index 0000000..be17def --- /dev/null +++ b/engines/firebird.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from typing import Any + +from core.specs import DatabaseSpec +from core.utils import generate_password +from engines.base import StandardSqlEngine +from services.ports import PortAllocator + + +class FirebirdEngine(StandardSqlEngine): + key, display, default_port = "firebird", "Firebird", 3050 + template, slug, db_prefix = "engines/firebird.yml.j2", "firebird", "fb" + DATA_DIR = "/var/lib/firebird/data" + + def generate( + self, *, auth: bool, ports: PortAllocator, answers: dict[str, Any] + ) -> DatabaseSpec: + db_file = "mirror.fdb" + return DatabaseSpec( + id=self.new_id(), + engine=self.key, + name=db_file, + managed=True, + host=self.service_name(self.slug), + port=self.default_port, + host_port=ports.free(), + database=f"{self.DATA_DIR}/{db_file}", + username="alice", + password=generate_password(16), + root_password=generate_password(16), + ) + + @staticmethod + def _file_name(spec: DatabaseSpec) -> str: + return (spec.database or "").rsplit("/", 1)[-1] + + def env_vars(self, spec: DatabaseSpec) -> dict[str, str]: + base = super().env_vars(spec) + base[f"{spec.env_prefix}_DB"] = self._file_name(spec) + base[f"{spec.env_prefix}_ROOT_PASS"] = spec.root_password or "" + return base + + def template_ctx( + self, spec: DatabaseSpec, *, inline: bool = False + ) -> dict[str, Any]: + ctx = super().template_ctx(spec, inline=inline) + ctx["db_var"] = self.var(spec, "DB", self._file_name(spec), inline) + ctx["root_password_var"] = self.var( + spec, "ROOT_PASS", spec.root_password, inline + ) + return ctx diff --git a/engines/mariadb.py b/engines/mariadb.py new file mode 100644 index 0000000..ea19c40 --- /dev/null +++ b/engines/mariadb.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from engines.base import StandardSqlEngine + + +class MariaDbEngine(StandardSqlEngine): + key, display, default_port = "mariadb", "MariaDB", 3306 + template, slug, db_prefix = "engines/mariadb.yml.j2", "mariadb", "mysql" diff --git a/engines/mongodb.py b/engines/mongodb.py new file mode 100644 index 0000000..09232ba --- /dev/null +++ b/engines/mongodb.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import secrets +from typing import Any + +from core.specs import DatabaseSpec +from core.utils import generate_password +from engines.base import DbEngine +from services.ports import PortAllocator + + +class MongoEngine(DbEngine): + key, display, default_port = "mongodb", "MongoDB", 27017 + template = "engines/mongodb.yml.j2" + auth_variants = True + + def generate( + self, *, auth: bool, ports: PortAllocator, answers: dict[str, Any] + ) -> DatabaseSpec: + db_name = f"mongo_{secrets.token_hex(4)}" + return DatabaseSpec( + id=self.new_id(), + engine=self.key, + name=db_name, + managed=True, + host=self.service_name("mongo", auth), + port=self.default_port, + host_port=ports.free(), + database=db_name, + username="admin" if auth else "", + password=generate_password(16) if auth else None, + ) + + def env_vars(self, spec: DatabaseSpec) -> dict[str, str]: + prefix = spec.env_prefix + out = { + f"{prefix}_PORT": str(spec.host_port), + f"{prefix}_DB": spec.database or "", + } + if spec.auth: + out[f"{prefix}_USER"] = spec.username or "" + out[f"{prefix}_PASS"] = spec.password or "" + return out diff --git a/engines/mssql.py b/engines/mssql.py new file mode 100644 index 0000000..ae56b62 --- /dev/null +++ b/engines/mssql.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import Any + +from core.specs import DatabaseSpec +from core.utils import generate_password +from engines.base import StandardSqlEngine +from services.ports import PortAllocator + + +class MssqlEngine(StandardSqlEngine): + key, display, default_port = "mssql", "Microsoft SQL Server", 1433 + template, slug, db_prefix = "engines/mssql.yml.j2", "mssql", "master" + + def generate( + self, *, auth: bool, ports: PortAllocator, answers: dict[str, Any] + ) -> DatabaseSpec: + return DatabaseSpec( + id=self.new_id(), + engine=self.key, + name="MSSQL", + managed=True, + host=self.service_name(self.slug), + port=self.default_port, + host_port=ports.free(), + database="master", + username="sa", + password=generate_password(16), + ) + + def env_vars(self, spec: DatabaseSpec) -> dict[str, str]: + prefix = spec.env_prefix + return { + f"{prefix}_PORT": str(spec.host_port), + f"{prefix}_PASS": spec.password or "", + } diff --git a/engines/mysql.py b/engines/mysql.py new file mode 100644 index 0000000..8cea92d --- /dev/null +++ b/engines/mysql.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from engines.mariadb import MariaDbEngine + + +class MySqlEngine(MariaDbEngine): + key, display = "mysql", "MySQL" + template = "engines/mysql.yml.j2" diff --git a/engines/postgresql.py b/engines/postgresql.py new file mode 100644 index 0000000..79b74dd --- /dev/null +++ b/engines/postgresql.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from core.fields import Field +from engines.base import StandardSqlEngine + + +class PostgresEngine(StandardSqlEngine): + key, display, default_port = "postgresql", "PostgreSQL", 5432 + template, slug, db_prefix = "engines/postgresql.yml.j2", "pg", "pg" + + def option_fields(self) -> list[Field]: + return [ + Field( + "keep_ownership", + "Keep ownership?", + "bool", + default=False, + help=( + "When enabled, omits --no-owner and --no-privileges from the dump. " + "Ownership and role assignments are preserved. By default these " + "flags are applied to keep restores portable across users and " + "environments." + ), + ), + Field( + "clean_mode", + "Clean mode", + "choice", + default="clean", + choices=("clean", "none", "drop_schemas", "drop_database"), + help=( + "How the target database is cleaned before a restore. clean: " + "pg_restore --clean --if-exists. none: no pre-clean. drop_schemas: " + "drop every non-system schema CASCADE (works on managed Postgres). " + "drop_database: DROP DATABASE + CREATE DATABASE — requires CREATEDB " + "or superuser; most managed providers do not allow it." + ), + ), + ] + + +class PostgresClusterEngine(StandardSqlEngine): + key, display, default_port = "postgresql-cluster", "PostgreSQL Cluster", 5432 + template, slug, db_prefix = "engines/postgresql-cluster.yml.j2", "pg", "pg" + warning = ( + "Postgres Cluster requires a superuser. Cluster backup/restore uses " + "pg_dumpall, which dumps all databases and global objects (roles, " + "tablespaces). The provided user must be a Postgres superuser." + ) diff --git a/engines/redis.py b/engines/redis.py new file mode 100644 index 0000000..45f1eec --- /dev/null +++ b/engines/redis.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import secrets +from typing import Any + +from core.fields import Field +from core.specs import DatabaseSpec +from core.utils import generate_password +from engines.base import DbEngine +from services.ports import PortAllocator + + +class RedisEngine(DbEngine): + key, display, default_port = "redis", "Redis", 6379 + template = "engines/redis.yml.j2" + auth_variants = True + + def fields_existing(self) -> list[Field]: + return [ + Field("host", "Host", "text", default="localhost"), + Field("port", "Port", "int", default=self.default_port), + Field("database", "Database index", "text", default="0"), + Field("username", "Username (empty if none)", "text", default=""), + Field("password", "Password (empty if none)", "text", default=""), + ] + + def generate( + self, *, auth: bool, ports: PortAllocator, answers: dict[str, Any] + ) -> DatabaseSpec: + return DatabaseSpec( + id=self.new_id(), + engine=self.key, + name=f"redis_{secrets.token_hex(4)}", + managed=True, + host=self.service_name("redis", auth), + port=self.default_port, + host_port=ports.free(), + database="0", + username="", + password=generate_password(16) if auth else None, + ) + + def env_vars(self, spec: DatabaseSpec) -> dict[str, str]: + prefix = spec.env_prefix + out = {f"{prefix}_PORT": str(spec.host_port)} + if spec.auth: + out[f"{prefix}_PASS"] = spec.password or "" + return out + + def agent_database(self, spec: DatabaseSpec) -> str: + return spec.database or "0" diff --git a/engines/sqlite.py b/engines/sqlite.py new file mode 100644 index 0000000..232d76d --- /dev/null +++ b/engines/sqlite.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from typing import Any + +from core.fields import Field +from core.specs import DatabaseSpec +from engines.base import DbEngine +from services.ports import PortAllocator + +CONFIG_DIR = "/config" + + +class SqliteEngine(DbEngine): + key, display = "sqlite", "SQLite" + template = None + auth_variants = False + + def fields_existing(self) -> list[Field]: + return [Field("path", "Database Path (relative or absolute)", "text")] + + def fields_new(self) -> list[Field]: + return [Field("name", "Database Name", "text", default="local")] + + def generate( + self, *, auth: bool, ports: PortAllocator, answers: dict[str, Any] + ) -> DatabaseSpec: + name = str(answers.get("name") or "local") + if not name.endswith(".sqlite"): + name += ".sqlite" + return DatabaseSpec( + id=self.new_id(), + engine=self.key, + name=name, + managed=False, + path=name, + database=f"{CONFIG_DIR}/{name}", + ) + + def from_existing(self, answers: dict[str, Any]) -> DatabaseSpec: + raw = str(answers["path"]) + absolute = raw.startswith("/") + return DatabaseSpec( + id=self.new_id(), + engine=self.key, + name=answers.get("label") or self.label_default, + managed=False, + path=raw, + database=raw if absolute else f"{CONFIG_DIR}/{raw}", + ) + + @staticmethod + def mount_for(spec: DatabaseSpec) -> tuple[str, str] | None: + if spec.database and spec.database.startswith(f"{CONFIG_DIR}/"): + rel = spec.database[len(CONFIG_DIR) + 1 :] + return (f"./{rel}", spec.database) + return None + + def env_vars(self, spec: DatabaseSpec) -> dict[str, str]: + return {} + + def agent_entry(self, spec: DatabaseSpec) -> dict[str, Any]: + return { + "name": spec.name, + "database": spec.database, + "type": self.key, + "generated_id": spec.id, + } + + def describe(self, spec: DatabaseSpec) -> str: + return "Local File" diff --git a/engines/valkey.py b/engines/valkey.py new file mode 100644 index 0000000..376c81f --- /dev/null +++ b/engines/valkey.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import secrets +from typing import Any + +from core.fields import Field +from core.specs import DatabaseSpec +from core.utils import generate_password +from engines.base import DbEngine +from services.ports import PortAllocator + + +class ValkeyEngine(DbEngine): + key, display, default_port = "valkey", "Valkey", 6379 + template = "engines/valkey.yml.j2" + auth_variants = True + + def fields_existing(self) -> list[Field]: + return [ + Field("host", "Host", "text", default="localhost"), + Field("port", "Port", "int", default=self.default_port), + Field("database", "Database index", "text", default="0"), + Field("username", "Username (empty if none)", "text", default=""), + Field("password", "Password (empty if none)", "text", default=""), + ] + + def generate( + self, *, auth: bool, ports: PortAllocator, answers: dict[str, Any] + ) -> DatabaseSpec: + return DatabaseSpec( + id=self.new_id(), + engine=self.key, + name=f"valkey_{secrets.token_hex(4)}", + managed=True, + host=self.service_name("valkey", auth), + port=self.default_port, + host_port=ports.free(), + database="0", + username="", + password=generate_password(16) if auth else None, + ) + + def env_vars(self, spec: DatabaseSpec) -> dict[str, str]: + prefix = spec.env_prefix + out = {f"{prefix}_PORT": str(spec.host_port)} + if spec.auth: + out[f"{prefix}_PASS"] = spec.password or "" + return out + + def agent_database(self, spec: DatabaseSpec) -> str: + return spec.database or "0" diff --git a/main.py b/main.py index 320a703..22ce500 100644 --- a/main.py +++ b/main.py @@ -1,91 +1,220 @@ -from typing import Optional +import os +import platform +import re +import sys +from dataclasses import dataclass +from typing import Annotated +import click import typer -from commands import agent, common, config, dashboard, db, decrypt -from core.updater import check_for_updates, update_cli -from core.utils import console, current_version - -app = typer.Typer( - no_args_is_help=True, - add_completion=False, +from commands.agent import AgentCommands +from commands.build import BuildCommand +from commands.config import ConfigCommands +from commands.dashboard import DashboardCommands +from commands.decrypt import DecryptCommand +from commands.lifecycle import ( + LogsCommand, + RestartCommand, + StartCommand, + StopCommand, + UninstallCommand, ) +from commands.update import UpdateCommand +from core.config import GlobalConfig +from core.errors import PortabaseError, UserAbort, ValidationError +from core.version import current_version +from engines import registry as engine_registry +from services.docker import DockerRunner +from services.http import HttpClient +from services.ports import PortAllocator +from services.renderer import ComposeRenderer +from services.telemetry import NoopTelemetry, Telemetry +from services.templates import TemplateRepository +from services.updater import UpdateChecker, Updater, is_frozen +from ui import UI + + +@dataclass +class Settings: + non_interactive: bool = False + verbose: bool = False + no_color: bool = False + + @classmethod + def from_env(cls, argv: list[str]) -> "Settings": + env_flag = os.environ.get("PORTABASE_NON_INTERACTIVE", "").lower() + settings = cls( + non_interactive=env_flag in ("1", "true", "yes") or not sys.stdin.isatty(), + no_color=bool(os.environ.get("NO_COLOR")) or "--no-color" in argv, + ) + if settings.no_color: + os.environ["NO_COLOR"] = "1" + return settings + + +def build_app( + ui: UI, telemetry: Telemetry, config: GlobalConfig, settings: Settings +) -> tuple[typer.Typer, UpdateChecker]: + app = typer.Typer( + no_args_is_help=True, add_completion=False, rich_markup_mode="rich" + ) + http = HttpClient() + docker = DockerRunner() + version = current_version() + checker = UpdateChecker(http, config, version) + updater = Updater(http, version) + templates = TemplateRepository.bundled() + ports = PortAllocator() + renderer = ComposeRenderer(templates, engine_registry, version) + + def version_callback(value: bool) -> None: + if value: + ui.print(f"Portabase CLI version: {version}") + latest = checker.available(force=True) + if latest: + ui.warning(f"A new version is available: [bold]{latest}[/bold]") + raise typer.Exit() + + @app.callback( + help="Portabase CLI to manage agents, dashboards and databases.", + invoke_without_command=True, + ) + def root( + ctx: typer.Context, + _version: Annotated[ + bool | None, + typer.Option( + "--version", + help="Show the version and exit.", + callback=version_callback, + is_eager=True, + ), + ] = None, + verbose: Annotated[ + bool, typer.Option("--verbose", help="Show error causes and tracebacks.") + ] = False, + no_color: Annotated[ + bool, typer.Option("--no-color", help="Disable colors.") + ] = False, + non_interactive: Annotated[ + bool, + typer.Option( + "--non-interactive", + envvar="PORTABASE_NON_INTERACTIVE", + help="Never prompt; fail on missing input.", + ), + ] = False, + ) -> None: + settings.verbose = verbose + settings.no_color = settings.no_color or no_color + settings.non_interactive = settings.non_interactive or non_interactive + ui.configure( + verbose=settings.verbose, + no_color=settings.no_color, + non_interactive=settings.non_interactive, + ) + if ctx.invoked_subcommand is None: + ui.out(ctx.get_help() + "\n") + raise typer.Exit() + + agent = AgentCommands( + ui, telemetry, docker, templates, renderer, engine_registry, ports + ) + commands = [ + StartCommand(ui, telemetry, docker), + StopCommand(ui, telemetry, docker), + RestartCommand(ui, telemetry, docker), + LogsCommand(ui, telemetry, docker), + UninstallCommand(ui, telemetry, docker), + BuildCommand(ui, telemetry, templates, renderer), + DecryptCommand(ui, telemetry), + UpdateCommand(ui, telemetry, checker, updater), + ] + agent.register(app) + DashboardCommands(ui, telemetry, docker, templates, renderer, ports).register(app) + for cmd in commands: + cmd.register(app) + ConfigCommands(ui, telemetry, config).register(app) + return app, checker + + +def _usage_hint(error: click.UsageError) -> str: + group = error.ctx.command.name if error.ctx and error.ctx.command else None + match = re.match(r"No such command '(.+)'", error.format_message()) + if match and match.group(1) == "db": + return "Database commands belong to the agent: portabase agent db ..." + if group in ("agent", "dashboard") and match: + return f"Did you mean: portabase {group} create {match.group(1)}?" + return "Run 'portabase --help' for usage." + + +def _notify_update( + ui: UI, checker: UpdateChecker, settings: Settings, invoked: str | None +) -> None: + if not is_frozen() or settings.non_interactive or invoked in ("update", None): + return + if "--stdout" in sys.argv: + return + latest = checker.available() + if latest: + ui.print("") + ui.warning( + f"A new version of Portabase CLI is available: [bold]{latest}[/bold] " + f"(current: {checker.current})" + ) + ui.info("Run [bold]portabase update[/bold] to update.") + + +def main() -> None: + settings = Settings.from_env(sys.argv[1:]) + config = GlobalConfig() + ui = UI(non_interactive=settings.non_interactive, no_color=settings.no_color) + telemetry = NoopTelemetry() + app, checker = build_app(ui, telemetry, config, settings) + invoked = next( + (argument for argument in sys.argv[1:] if not argument.startswith("-")), None + ) + exit_code = 0 + + try: + with telemetry.session(cli_version=current_version(), os=platform.system()): + result = app(standalone_mode=False) + if isinstance(result, int): + exit_code = result + except UserAbort as error: + ui.warning(error.message) + telemetry.event("abort") + exit_code = error.exit_code + except PortabaseError as error: + ui.error(error) + telemetry.error(error) + exit_code = error.exit_code + except click.exceptions.NoArgsIsHelpError: + exit_code = 0 + except click.exceptions.Exit as error: + exit_code = error.exit_code + except click.UsageError as error: + err = ValidationError(error.format_message(), hint=_usage_hint(error)) + ui.error(err) + telemetry.error(err) + exit_code = err.exit_code + except KeyboardInterrupt: + ui.print("") + ui.warning("Canceled.") + exit_code = 130 + except Exception as error: # noqa: BLE001 — last resort: a bug, not an expected error + wrapped = PortabaseError("Unexpected error: " + str(error), cause=error) + ui.error(wrapped, unexpected=True) + telemetry.error(error, unexpected=True) + exit_code = 1 + finally: + telemetry.flush() + if exit_code == 0: + _notify_update(ui, checker, settings, invoked) + raise SystemExit(exit_code) -def version_callback(value: bool): - if value: - console.print(f"Portabase CLI version: {current_version()}") - check_for_updates(force=True) - raise typer.Exit() - - -@app.callback() -def main( - ctx: typer.Context, - _: Optional[bool] = typer.Option( - None, - "--version", - help="Show the version and exit.", - callback=version_callback, - is_eager=True, - ), -): - """ - Portabase CLI to manage agents, dashboards and databases. - """ - if ctx.invoked_subcommand != "update": - check_for_updates() - - -@app.command(help="Update the CLI to the latest version.", rich_help_panel="System") -def update(): - update_cli() - - -app.command( - help="Create a new Portabase Agent instance.", - rich_help_panel="Creation", - no_args_is_help=True, -)(agent.agent) -app.command( - help="Create a new Portabase Dashboard instance.", - rich_help_panel="Creation", - no_args_is_help=True, -)(dashboard.dashboard) -app.command( - help="Start a Portabase component.", - rich_help_panel="Lifecycle", - no_args_is_help=True, -)(common.start) -app.command( - help="Stop a Portabase component.", - rich_help_panel="Lifecycle", - no_args_is_help=True, -)(common.stop) -app.command( - help="Restart a Portabase component.", - rich_help_panel="Lifecycle", - no_args_is_help=True, -)(common.restart) -app.command( - help="View logs of a Portabase component.", - rich_help_panel="Lifecycle", - no_args_is_help=True, -)(common.logs) -app.command( - help="Uninstall and delete a Portabase component.", - rich_help_panel="Lifecycle", - no_args_is_help=True, -)(common.uninstall) - -app.command( - help="Decrypt Portabase .enc backup files (single file or folder).", - rich_help_panel="Configuration", - no_args_is_help=True, -)(decrypt.decrypt) - -app.add_typer(db.app, name="db", rich_help_panel="Configuration") -app.add_typer(config.app, name="config", rich_help_panel="Configuration") if __name__ == "__main__": - app() + main() diff --git a/portabase.spec b/portabase.spec new file mode 100644 index 0000000..2d67e20 --- /dev/null +++ b/portabase.spec @@ -0,0 +1,49 @@ +# PyInstaller build definition. Declarative, so the workflows do not carry +# build flags and `uv run pyinstaller portabase.spec` reproduces CI locally. +# +# The binary name comes from PORTABASE_BINARY_NAME (default: portabase); the +# release matrix sets it to portabase__. +import os + +from PyInstaller.utils.hooks import collect_all, collect_data_files + +NAME = os.environ.get("PORTABASE_BINARY_NAME", "portabase") + +datas = [ + ("pyproject.toml", "."), + ("templates", "templates"), +] +binaries = [] +hiddenimports = [] + +for package in ("rich", "requests"): + pkg_datas, pkg_binaries, pkg_hidden = collect_all(package) + datas += pkg_datas + binaries += pkg_binaries + hiddenimports += pkg_hidden + +datas += collect_data_files("certifi") + +a = Analysis( + ["main.py"], + pathex=["."], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + noarchive=False, +) + +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name=NAME, + debug=False, + strip=False, + upx=False, + console=True, +) diff --git a/pyproject.toml b/pyproject.toml index 8657581..3c41f35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,12 +5,69 @@ description = "The official command line interface (CLI) for managing and deploy readme = "README.md" requires-python = ">=3.12" dependencies = [ - "rich>=14.2.0", "typer>=0.20.0", - "pyinstaller>=6.17.0", - "requests>=2.32.5", - "pyyaml >=6.0.0", + "rich>=14.2.0", "questionary>=2.1.0", + "requests>=2.32.5", "pyyaml>=6.0.3", - "cryptography>=44.0.0" + "cryptography>=44.0.0", + "jinja2>=3.1", +] + +[dependency-groups] +dev = [ + "pyinstaller>=6.17.0", + "ruff>=0.16.0", + "pytest>=8.3", + "mypy>=1.15", +] + +[tool.ruff] +target-version = "py312" +line-length = 88 +extend-exclude = [".venv", "build", "dist", "docs", "*.md", "*.spec"] + +[tool.ruff.lint] +select = [ + "E", "F", "W", + "I", + "UP", + "B", + "BLE", + "S110", + "E722", + "TID251", + "SIM", + "TRY201", + "PLW1510", +] +ignore = [ + "B008", + "E501", ] + +[tool.ruff.lint.per-file-ignores] +"ui/**" = ["TID251"] + +[tool.ruff.lint.flake8-tidy-imports.banned-api] +"rich.prompt".msg = "Use ui.form() / ui.confirm() instead." +"rich.console".msg = "Only ui/ may build a Console. Use the UI facade." +"typer.prompt".msg = "Use ui.form() instead." +"typer.confirm".msg = "Use ui.confirm() instead." + +[tool.ruff.lint.isort] +known-first-party = ["commands", "core", "engines", "services", "ui"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["*.py"] +python_functions = ["[!_]*"] +pythonpath = ["."] +addopts = "-q --import-mode=importlib" + +[tool.mypy] +python_version = "3.12" +files = ["commands", "core", "engines", "services", "ui", "tests", "main.py"] +ignore_missing_imports = true +warn_unused_ignores = true +warn_redundant_casts = true diff --git a/release b/release deleted file mode 100755 index 09d0903..0000000 --- a/release +++ /dev/null @@ -1,96 +0,0 @@ -#!/bin/bash - -set -e - -if [ -z "$1" ]; then - echo "Usage: ./release " - echo "Example: ./release v1.0.0" - exit 1 -fi - -VERSION=$1 -CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) - -if [ "$CURRENT_BRANCH" = "main" ]; then - if [[ ! "$VERSION" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Error: On 'main' branch, only release tags (vX.Y.Z) are allowed." - exit 1 - fi -else - if [[ ! "$VERSION" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+([-.]?(rc|alpha|beta|a|b)[0-9]*(\.[0-9]+)?)?$ ]]; then - echo "Error: On branch '$CURRENT_BRANCH', only pre-release tags matching vX.Y.Z[-.](rc|a|b|...)W are allowed." - echo "Examples: 1.0.0-rc.1, 26.01.1a, 26.01.1b, 26.01.1rc1" - exit 1 - fi -fi - -CLEAN_VERSION=${VERSION#v} -CURRENT_DATE=$(date +%Y-%m-%d) - -echo "Preparing release $VERSION..." - - -# package.json -if [ -f package.json ]; then - echo "Updating package.json..." - if sed --version >/dev/null 2>&1; then - sed -i "s/\"version\": \".*\"/\"version\": \"$CLEAN_VERSION\"/" package.json - else - sed -i '' "s/\"version\": \".*\"/\"version\": \"$CLEAN_VERSION\"/" package.json - fi -fi - -# pyproject.toml -if [ -f pyproject.toml ]; then - echo "Updating pyproject.toml..." - if sed --version >/dev/null 2>&1; then - sed -i "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" pyproject.toml - else - sed -i '' "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" pyproject.toml - fi -fi - -# Cargo.toml -if [ -f Cargo.toml ]; then - echo "Updating Cargo.toml..." - if sed --version >/dev/null 2>&1; then - sed -i "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" Cargo.toml - else - sed -i '' "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" Cargo.toml - fi -fi - -# CITATION.cff -if [ -f CITATION.cff ]; then - echo "Updating CITATION.cff..." - if sed --version >/dev/null 2>&1; then - sed -i "s/^version: .*/version: $CLEAN_VERSION/" CITATION.cff - sed -i "s/^date-released: .*/date-released: \"$CURRENT_DATE\"/" CITATION.cff - else - sed -i '' "s/^version: .*/version: $CLEAN_VERSION/" CITATION.cff - sed -i '' "s/^date-released: .*/date-released: \"$CURRENT_DATE\"/" CITATION.cff - fi -fi - -git add . - -if ! git diff-index --quiet HEAD --; then - echo "Committing changes..." - git commit -m "chore(release): $VERSION" -else - echo "No changes to commit. Proceeding to tag..." -fi - -if git rev-parse "$VERSION" >/dev/null 2>&1; then - echo "Tag $VERSION already exists. Aborting." - exit 1 -fi - -echo "Creating tag $VERSION..." -git tag -a "$VERSION" -m "Release $VERSION" - -echo "Pushing changes and tags to remote..." -git push -git push origin "$VERSION" - -echo "Successfully released $VERSION!" diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/auth_providers.py b/services/auth_providers.py new file mode 100644 index 0000000..2d9fc17 --- /dev/null +++ b/services/auth_providers.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import re + +from core.errors import ValidationError +from core.fields import Field +from services.settings import public_url + +OAUTH_PROVIDERS: tuple[str, ...] = ( + "google", + "github", + "discord", + "apple", + "linkedin", + "x", + "reddit", +) + +OIDC_FIELDS: tuple[Field, ...] = ( + Field("issuer", "Issuer / discovery URL", "text", validator=public_url), + Field("client", "Client ID", "text"), + Field("secret", "Client secret", "secret"), + Field("title", "Display name", "text", default=""), + Field("scopes", "Scopes", "text", default=""), + Field("pkce", "Use PKCE?", "bool", default=False), + Field("host", "Host override", "text", default=""), +) + +OAUTH_FIELDS: tuple[Field, ...] = ( + Field("client", "Client ID", "text"), + Field("secret", "Client secret", "secret"), + Field("title", "Display name", "text", default=""), +) + +OIDC_ENV: dict[str, str] = { + "issuer": "ISSUER_URL", + "client": "CLIENT", + "secret": "SECRET", + "title": "TITLE", + "scopes": "SCOPES", + "pkce": "PKCE", + "host": "HOST", +} +OAUTH_ENV: dict[str, str] = {"client": "CLIENT", "secret": "SECRET", "title": "TITLE"} + + +def provider_prefix(kind: str, provider_id: str) -> str: + slug = re.sub(r"[^A-Z0-9]", "_", provider_id.upper()) + return f"AUTH_OIDC_{slug}" if kind == "oidc" else f"AUTH_SOCIAL_{slug}" + + +def validate_provider_id(kind: str, provider_id: str) -> str: + pid = provider_id.strip().lower() + if not re.match(r"^[a-z0-9][a-z0-9-]*$", pid): + raise ValidationError( + f"Invalid provider id {provider_id!r}.", + hint="Use lowercase letters, digits and dashes.", + ) + if kind == "oauth" and pid not in OAUTH_PROVIDERS: + raise ValidationError( + f"Unknown OAuth provider '{pid}'.", + hint="Supported: " + ", ".join(OAUTH_PROVIDERS), + ) + return pid diff --git a/services/compose_facts.py b/services/compose_facts.py new file mode 100644 index 0000000..b3cae80 --- /dev/null +++ b/services/compose_facts.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml + +GENERATED_MARKER = "# Generated by Portabase CLI" +CA_BUNDLE_IN_CONTAINER = "/etc/ssl/certs/portabase-ca-bundle.crt" + + +class ComposeFacts: + def __init__(self, path: Path) -> None: + self.path = path + self.raw: dict = {} + self.text = "" + if path.exists(): + try: + self.text = path.read_text(encoding="utf-8") + loaded = yaml.safe_load(self.text) + self.raw = loaded if isinstance(loaded, dict) else {} + except (OSError, yaml.YAMLError): + self.raw = {} + + @property + def exists(self) -> bool: + return self.path.exists() + + @property + def is_generated(self) -> bool: + return self.text.startswith(GENERATED_MARKER) + + def _service(self, name: str) -> dict: + services = self.raw.get("services") or {} + svc = services.get(name) if isinstance(services, dict) else None + return svc if isinstance(svc, dict) else {} + + @property + def host_gateway(self) -> bool: + extra = self._service("agent").get("extra_hosts") + if isinstance(extra, list): + return any("host-gateway" in str(entry) for entry in extra) + if isinstance(extra, dict): + return any("host-gateway" in str(value) for value in extra.values()) + return False + + @property + def ca_bundle(self) -> str | None: + for volume in self._service("agent").get("volumes") or []: + if isinstance(volume, str): + parts = volume.split(":") + if len(parts) >= 2 and parts[1] == CA_BUNDLE_IN_CONTAINER: + return parts[0] + elif ( + isinstance(volume, dict) + and volume.get("target") == CA_BUNDLE_IN_CONTAINER + ): + source = volume.get("source") + return str(source) if source else None + return None diff --git a/services/docker.py b/services/docker.py new file mode 100644 index 0000000..8f60b3d --- /dev/null +++ b/services/docker.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import platform +import shutil +import subprocess +import time +from pathlib import Path + +from core.errors import DockerError +from core.utils import slugify_project_name + +_START_COMMANDS = { + "Linux": ["sudo", "systemctl", "start", "docker"], + "Darwin": ["open", "--background", "-a", "Docker"], + "Windows": ["cmd", "/c", "start", "docker"], +} + + +class DockerRunner: + def __init__(self, docker_bin: str | None = None) -> None: + self._bin = docker_bin + + @property + def binary(self) -> str: + if self._bin is None: + found = shutil.which("docker") + if found is None: + raise DockerError( + "Docker not found (binary missing).", + hint="Install Docker: https://docs.docker.com/get-docker/", + ) + self._bin = found + return self._bin + + def available(self) -> bool: + return shutil.which("docker") is not None + + def daemon_running(self) -> bool: + try: + subprocess.run( + [self.binary, "info"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + ) + return True + except (subprocess.CalledProcessError, OSError): + return False + + def start_daemon(self, *, wait_seconds: int = 20) -> bool: + cmd = _START_COMMANDS.get(platform.system()) + if cmd is None: + return False + try: + subprocess.run(cmd, check=True) + except (subprocess.CalledProcessError, OSError) as error: + raise DockerError( + f"Failed to start Docker: {error}", cause=error + ) from error + deadline = time.monotonic() + wait_seconds + while time.monotonic() < deadline: + if self.daemon_running(): + return True + time.sleep(2) + return False + + def ensure_network(self, name: str) -> None: + inspect = subprocess.run( + [self.binary, "network", "inspect", name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if inspect.returncode == 0: + return + try: + subprocess.run( + [self.binary, "network", "create", name], + stdout=subprocess.DEVNULL, + check=True, + ) + except subprocess.CalledProcessError as error: + raise DockerError( + f"Could not create Docker network '{name}'.", cause=error + ) from error + + def remove_volume(self, name: str) -> bool: + proc = subprocess.run( + [self.binary, "volume", "rm", name], + capture_output=True, + text=True, + check=False, + ) + if proc.returncode == 0: + return True + if "no such volume" in (proc.stderr or "").lower(): + return False + raise DockerError(f"Could not remove volume '{name}': {proc.stderr.strip()}") + + @staticmethod + def project_name(cwd: Path) -> str: + return slugify_project_name(cwd.resolve().name) + + def compose( + self, + cwd: Path, + args: list[str], + *, + check: bool = True, + capture: bool = False, + ) -> subprocess.CompletedProcess: + cmd = [self.binary, "compose", "-p", self.project_name(cwd), *args] + try: + return subprocess.run( + cmd, + cwd=cwd, + check=check, + capture_output=capture, + text=capture, + ) + except subprocess.CalledProcessError as error: + raise DockerError( + f"docker compose {' '.join(args)} failed (exit {error.returncode}).", + hint=f"Run it manually in {cwd} to see the full output.", + cause=error, + ) from error + except OSError as error: + raise DockerError(f"Could not run docker: {error}", cause=error) from error diff --git a/services/envfile.py b/services/envfile.py new file mode 100644 index 0000000..ceb4dfb --- /dev/null +++ b/services/envfile.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import os +import re +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path + +_LINE = re.compile(r"""^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$""") + + +def _unquote(raw: str) -> str: + if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "\"'": + inner = raw[1:-1] + if raw[0] == '"': + return inner.replace('\\"', '"').replace("\\\\", "\\") + return inner + return raw.split(" #", 1)[0].rstrip() + + +def _quote(value: str) -> str: + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +@dataclass +class EnvFile: + path: Path + _lines: list[str] = field(default_factory=list) + _index: dict[str, int] = field(default_factory=dict) + + @classmethod + def load(cls, path: Path) -> EnvFile: + env = cls(path) + if path.exists(): + text = path.read_text(encoding="utf-8") + env._lines = text.splitlines() + for index, line in enumerate(env._lines): + match = _LINE.match(line) + if match and not line.lstrip().startswith("#"): + env._index[match.group(1)] = index + return env + + @property + def exists(self) -> bool: + return self.path.exists() + + def get(self, key: str, default: str | None = None) -> str | None: + index = self._index.get(key) + if index is None: + return default + match = _LINE.match(self._lines[index]) + return _unquote(match.group(2)) if match else default + + def as_dict(self) -> dict[str, str]: + return {key: self.get(key) or "" for key in self._index} + + def set(self, key: str, value: str) -> None: + line = f"{key}={_quote(str(value))}" + index = self._index.get(key) + if index is None: + self._lines.append(line) + self._index[key] = len(self._lines) - 1 + else: + self._lines[index] = line + + def merge(self, mapping: Mapping[str, str]) -> None: + for key, value in mapping.items(): + self.set(key, value) + + def remove(self, key: str) -> None: + index = self._index.pop(key, None) + if index is None: + return + del self._lines[index] + self._index = { + name: (position - 1 if position > index else position) + for name, position in self._index.items() + } + + def remove_prefix(self, prefix: str) -> None: + for key in [name for name in self._index if name.startswith(prefix + "_")]: + self.remove(key) + + def save(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_name(self.path.name + ".tmp") + tmp.write_text("\n".join(self._lines) + "\n", encoding="utf-8") + os.replace(tmp, self.path) diff --git a/services/http.py b/services/http.py new file mode 100644 index 0000000..14d083b --- /dev/null +++ b/services/http.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import requests + +from core.errors import NetworkError + +_HINT = "Check your internet connection or proxy settings." + + +class HttpClient: + def __init__( + self, timeout: float = 10.0, user_agent: str = "portabase-cli" + ) -> None: + self.timeout = timeout + self.session = requests.Session() + self.session.headers["User-Agent"] = user_agent + + def get_json(self, url: str) -> Any: + try: + response = self.session.get(url, timeout=self.timeout) + response.raise_for_status() + return response.json() + except requests.RequestException as error: + raise NetworkError( + f"GET {url} failed: {error}", hint=_HINT, cause=error + ) from error + except ValueError as error: + raise NetworkError( + f"GET {url}: response is not JSON", cause=error + ) from error + + def get_text(self, url: str) -> str: + try: + response = self.session.get(url, timeout=self.timeout) + response.raise_for_status() + return response.text + except requests.RequestException as error: + raise NetworkError( + f"GET {url} failed: {error}", hint=_HINT, cause=error + ) from error + + def status(self, url: str) -> int: + try: + return self.session.get(url, timeout=self.timeout, stream=True).status_code + except requests.RequestException as error: + raise NetworkError( + f"GET {url} failed: {error}", hint=_HINT, cause=error + ) from error + + def download( + self, + url: str, + dest: Path, + on_progress: Callable[[int], None] | None = None, + *, + timeout: float = 30.0, + ) -> int: + written = 0 + try: + with self.session.get(url, stream=True, timeout=timeout) as response: + response.raise_for_status() + with open(dest, "wb") as file: + for chunk in response.iter_content(chunk_size=64 * 1024): + if not chunk: + continue + file.write(chunk) + written += len(chunk) + if on_progress: + on_progress(len(chunk)) + except requests.RequestException as error: + dest.unlink(missing_ok=True) + raise NetworkError( + f"Download of {url} failed: {error}", hint=_HINT, cause=error + ) from error + return written + + def content_length(self, url: str) -> int | None: + try: + response = self.session.head( + url, timeout=self.timeout, allow_redirects=True + ) + value = response.headers.get("content-length") + return int(value) if value else None + except (requests.RequestException, ValueError): + return None diff --git a/services/ports.py b/services/ports.py new file mode 100644 index 0000000..f95ee29 --- /dev/null +++ b/services/ports.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import socket + + +class PortAllocator: + def __init__(self) -> None: + self._given: set[int] = set() + + def free(self) -> int: + for _ in range(50): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("", 0)) + port = sock.getsockname()[1] + if port not in self._given: + self._given.add(port) + return port + raise RuntimeError("Could not allocate a free port") + + +class FixedPortAllocator(PortAllocator): + def __init__(self, start: int = 40000) -> None: + super().__init__() + self._next = start + + def free(self) -> int: + port = self._next + self._next += 1 + return port diff --git a/services/project.py b/services/project.py new file mode 100644 index 0000000..c59989d --- /dev/null +++ b/services/project.py @@ -0,0 +1,374 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal + +from core.errors import ConfigError, ValidationError +from core.specs import DatabaseSpec +from engines.base import DbEngine +from engines.sqlite import SqliteEngine +from services import auth_providers as ap +from services import settings as cfg +from services.compose_facts import CA_BUNDLE_IN_CONTAINER, ComposeFacts +from services.envfile import EnvFile + +ProjectKind = Literal["agent", "dashboard"] +ProviderKind = Literal["oidc", "oauth"] +DATABASES_FILE = "databases.json" +COMPOSE_FILE = "docker-compose.yml" +ENV_FILE = ".env" + + +def detect_kind(path: Path) -> ProjectKind: + if (path / DATABASES_FILE).exists(): + return "agent" + env = EnvFile.load(path / ENV_FILE) + if env.get("PROJECT_SECRET") is not None: + return "dashboard" + raise ConfigError( + f"{path} is not a Portabase agent or dashboard folder.", + hint="Expected databases.json (agent) or a .env with PROJECT_SECRET (dashboard).", + ) + + +def _opt_str(entry: dict[str, Any], key: str) -> str | None: + value = entry.get(key) + return str(value) if value not in (None, "") else None + + +def spec_from_entry(entry: dict[str, Any], env: EnvFile) -> DatabaseSpec: + engine = str(entry.get("type", "")) + host = entry.get("host") + managed, host_port, root_password = False, None, None + if host: + prefix = str(host).upper().replace("-", "_") + raw_port = env.get(f"{prefix}_PORT") + if raw_port and raw_port.isdigit(): + managed, host_port = True, int(raw_port) + root_password = env.get(f"{prefix}_ROOT_PASS") + port = entry.get("port") + return DatabaseSpec( + id=str(entry.get("generated_id") or DbEngine.new_id()), + engine=engine, + name=str(entry.get("name", "")), + managed=managed, + host=str(host) if host else None, + port=int(port) if port not in (None, "") else None, + host_port=host_port, + database=str(entry["database"]) if entry.get("database") is not None else None, + username=str(entry["username"]) if entry.get("username") is not None else None, + password=_opt_str(entry, "password"), + root_password=root_password, + path=_opt_str(entry, "database") if engine == "sqlite" else None, + volume=_opt_str(entry, "volume_name"), + container=_opt_str(entry, "container_name"), + options=dict(entry.get("options") or {}), + ) + + +@dataclass +class AgentProject: + path: Path + env: EnvFile + databases: list[DatabaseSpec] = field(default_factory=list) + host_gateway: bool = False + + @classmethod + def load(cls, path: Path) -> AgentProject: + path = path.resolve() + env_path, db_path = path / ENV_FILE, path / DATABASES_FILE + if not env_path.exists() or not db_path.exists(): + raise ConfigError( + f"Not a Portabase agent folder: {path}", + hint=f"Expected {ENV_FILE} and {DATABASES_FILE}.", + ) + env = EnvFile.load(env_path) + try: + data = json.loads(db_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + raise ConfigError(f"{db_path} is not valid JSON.", cause=error) from error + entries = data.get("databases", []) if isinstance(data, dict) else [] + databases = [ + spec_from_entry(entry, env) for entry in entries if isinstance(entry, dict) + ] + facts = ComposeFacts(path / COMPOSE_FILE) + project = cls(path, env, databases, facts.host_gateway) + project._ca_bundle = facts.ca_bundle + project.validate() + return project + + @classmethod + def create( + cls, path: Path, env_vars: dict[str, str], *, host_gateway: bool + ) -> AgentProject: + path.mkdir(parents=True, exist_ok=True) + env = EnvFile.load(path / ENV_FILE) + env.merge(env_vars) + return cls(path, env, [], host_gateway) + + @property + def managed(self) -> list[DatabaseSpec]: + return [database for database in self.databases if database.managed] + + @property + def needs_docker_socket(self) -> bool: + return any(database.engine == "docker-volume" for database in self.databases) + + @property + def sqlite_mounts(self) -> list[tuple[str, str]]: + mounts: list[tuple[str, str]] = [] + for database in self.databases: + if database.engine == "sqlite": + mount = SqliteEngine.mount_for(database) + if mount and mount not in mounts: + mounts.append(mount) + return mounts + + def validate(self) -> None: + bundle = self.ca_bundle + if bundle and not (self.path / bundle).exists() and not Path(bundle).exists(): + raise ValidationError( + f"CA bundle not found: {bundle}", + hint=f"Path is resolved from {self.path}; use an absolute path otherwise.", + ) + seen: set[str] = set() + for database in self.managed: + if database.host in seen: + raise ConfigError( + f"Two managed databases share the service name '{database.host}'." + ) + seen.add(database.host or "") + + def add(self, spec: DatabaseSpec, engine: DbEngine) -> None: + if spec.managed: + self.env.merge(engine.env_vars(spec)) + self.databases.append(spec) + self.validate() + + def remove(self, spec: DatabaseSpec, engine: DbEngine) -> None: + self.databases = [ + database for database in self.databases if database.id != spec.id + ] + if spec.managed and spec.host: + self.env.remove_prefix(spec.env_prefix) + + registry = cfg.AGENT + + def setting(self, name: str) -> Any: + setting = self.registry.get(name) + if setting.env is None: + return getattr(self, name) + return setting.from_env(self.env.get(setting.env)) + + def settings(self) -> dict[str, Any]: + return {setting.name: self.setting(setting.name) for setting in self.registry} + + def set(self, name: str, value: Any) -> None: + setting = self.registry.get(name) + if setting.env is None: + setattr(self, name, value) + else: + self.env.set(setting.env, setting.to_env(value)) + + def unset(self, name: str) -> None: + setting = self.registry.get(name) + if setting.core: + raise ValidationError(f"'{name}' is required and cannot be unset.") + if setting.env is None: + setattr(self, name, None) + else: + self.env.remove(setting.env) + + @property + def extra_env(self) -> list[str]: + return [ + setting.env + for setting in self.registry + if setting.env + and not setting.core + and self.env.get(setting.env) is not None + ] + + _ca_bundle: str | None = None + + @property + def ca_bundle(self) -> str | None: + return self._ca_bundle + + @ca_bundle.setter + def ca_bundle(self, host_path: str | None) -> None: + self._ca_bundle = host_path or None + if host_path: + self.env.set("SSL_CERT_FILE", CA_BUNDLE_IN_CONTAINER) + else: + self.env.remove("SSL_CERT_FILE") + + def find(self, id_or_name: str) -> DatabaseSpec: + matches = [ + database + for database in self.databases + if database.id == id_or_name + or database.id.startswith(id_or_name) + or database.name == id_or_name + ] + if not matches: + raise ValidationError( + f"No database matching '{id_or_name}'.", + hint="See: portabase agent db list", + ) + if len(matches) > 1: + raise ValidationError( + f"'{id_or_name}' matches several databases; use the id." + ) + return matches[0] + + def save_state(self) -> None: + self.validate() + self.env.save() + + +@dataclass +class DashboardProject: + path: Path + env: EnvFile + + @classmethod + def load(cls, path: Path) -> DashboardProject: + path = path.resolve() + env = EnvFile.load(path / ENV_FILE) + if env.get("PROJECT_SECRET") is None: + raise ConfigError( + f"Not a Portabase dashboard folder: {path}", + hint="Expected a .env with PROJECT_SECRET.", + ) + return cls(path, env) + + @classmethod + def create(cls, path: Path, env_vars: dict[str, str]) -> DashboardProject: + path.mkdir(parents=True, exist_ok=True) + env = EnvFile.load(path / ENV_FILE) + env.merge(env_vars) + return cls(path, env) + + @property + def db_mode(self) -> Literal["external", "internal", "custom"]: + host = self.env.get("POSTGRES_HOST") + if host is None: + return "internal" + return "external" if host == "db" else "custom" + + @property + def project_name(self) -> str: + return self.env.get("PROJECT_NAME") or self.path.name + + registry = cfg.DASHBOARD + + def setting(self, name: str) -> Any: + setting = self.registry.get(name) + return setting.from_env(self.env.get(setting.env or "")) + + def settings(self) -> dict[str, Any]: + return {setting.name: self.setting(setting.name) for setting in self.registry} + + def set(self, name: str, value: Any) -> None: + setting = self.registry.get(name) + self.env.set(setting.env or "", setting.to_env(value)) + + def unset(self, name: str) -> None: + self.env.remove(self.registry.get(name).env or "") + + @property + def providers(self) -> list[AuthProvider]: + found: dict[tuple[ProviderKind, str], dict[str, str]] = {} + kinds: tuple[tuple[ProviderKind, str], ...] = ( + ("oidc", "AUTH_OIDC_"), + ("oauth", "AUTH_SOCIAL_"), + ) + for key, value in self.env.as_dict().items(): + for kind, prefix in kinds: + if not key.startswith(prefix): + continue + rest = key[len(prefix) :] + env_map = ap.OIDC_ENV if kind == "oidc" else ap.OAUTH_ENV + for field_name, suffix in env_map.items(): + if rest.endswith("_" + suffix): + slug = rest[: -len(suffix) - 1] + found.setdefault((kind, slug), {})[field_name] = value + break + else: + if kind == "oidc" and rest.endswith("_ID"): + found.setdefault((kind, rest[:-3]), {})["id"] = value + providers = [] + for (kind, slug), values in sorted(found.items()): + pid = values.pop("id", slug.lower().replace("_", "-")) + providers.append(AuthProvider(kind=kind, id=pid, values=values)) + return providers + + def add_provider(self, provider: AuthProvider) -> None: + if any(existing.id == provider.id for existing in self.providers): + raise ValidationError( + f"A provider named '{provider.id}' already exists.", + hint="Remove it first: portabase dashboard auth remove", + ) + prefix = ap.provider_prefix(provider.kind, provider.id) + env_map = ap.OIDC_ENV if provider.kind == "oidc" else ap.OAUTH_ENV + if provider.kind == "oidc": + self.env.set(f"{prefix}_ID", provider.id) + for field_name, value in provider.values.items(): + if value in ("", None, False): + continue + raw = "true" if value is True else str(value) + self.env.set(f"{prefix}_{env_map[field_name]}", raw) + + def remove_provider(self, provider_id: str) -> AuthProvider: + match = next( + (provider for provider in self.providers if provider.id == provider_id), + None, + ) + if match is None: + raise ValidationError( + f"No provider named '{provider_id}'.", + hint="See: portabase dashboard auth list", + ) + self.env.remove_prefix(ap.provider_prefix(match.kind, match.id)) + return match + + def callback_url(self, provider_id: str) -> str: + return f"{self.setting('url')}/api/auth/sso/callback/{provider_id}" + + def validate(self) -> None: + values = self.settings() + providers = self.providers + if values["skip_onboarding"] and not ( + values["admin_email"] and values["admin_password"] + ): + raise ValidationError( + "Skipping onboarding needs an initial account.", + hint="Set admin_email and admin_password.", + ) + if not values["password_auth"] and not providers: + raise ValidationError( + "Disabling password login with no OIDC or OAuth provider " + "would lock everyone out.", + hint="Add a provider first: portabase dashboard auth add", + ) + url = values["url"] or "" + if providers and ("localhost" in url or "127.0.0.1" in url): + raise ValidationError( + f"Login providers need a public URL for their callback " + f"(currently {url}).", + hint="portabase dashboard set NAME url https://your.domain", + ) + + def save_state(self) -> None: + self.validate() + self.env.save() + + +@dataclass(frozen=True) +class AuthProvider: + kind: ProviderKind + id: str + values: dict[str, Any] diff --git a/services/renderer.py b/services/renderer.py new file mode 100644 index 0000000..8c69ae3 --- /dev/null +++ b/services/renderer.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import contextlib +import difflib +import json +import os +import shutil +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import jinja2 +import yaml + +from core.errors import TemplateError +from core.specs import DatabaseSpec +from core.utils import escape_yaml_double_quoted +from engines import EngineRegistry +from services.compose_facts import ( + CA_BUNDLE_IN_CONTAINER, + GENERATED_MARKER, + ComposeFacts, +) +from services.envfile import EnvFile +from services.project import ( + COMPOSE_FILE, + DATABASES_FILE, + AgentProject, + DashboardProject, +) +from services.templates import TemplateRepository + +LEGACY_BACKUP = "docker-compose.legacy.yml" + + +@dataclass +class WriteReport: + backed_up: Path | None = None + wrote: list[Path] = field(default_factory=list) + + +@dataclass +class RenderResult: + compose: str + databases: list[dict[str, Any]] | None = None + + def validate(self) -> None: + try: + doc = yaml.safe_load(self.compose) + except yaml.YAMLError as error: + raise TemplateError( + "Rendered compose is not valid YAML; templates are broken.", cause=error + ) from error + if not isinstance(doc, dict) or "services" not in doc: + raise TemplateError( + "Rendered compose has no 'services' section; templates are broken." + ) + + def write(self, path: Path) -> WriteReport: + self.validate() + report = WriteReport() + compose_path = path / COMPOSE_FILE + facts = ComposeFacts(compose_path) + if facts.exists and not facts.is_generated: + backup = path / LEGACY_BACKUP + if not backup.exists(): + shutil.copy2(compose_path, backup) + report.backed_up = backup + _atomic_write(compose_path, self.compose) + report.wrote.append(compose_path) + if self.databases is not None: + db_path = path / DATABASES_FILE + _atomic_write( + db_path, json.dumps({"databases": self.databases}, indent=2) + "\n" + ) + with contextlib.suppress(OSError): + os.chmod(db_path, 0o666) + report.wrote.append(db_path) + return report + + def diff_against(self, path: Path) -> str: + compose_path = path / COMPOSE_FILE + current = ( + compose_path.read_text(encoding="utf-8") if compose_path.exists() else "" + ) + return "".join( + difflib.unified_diff( + current.splitlines(keepends=True), + self.compose.splitlines(keepends=True), + fromfile=f"{COMPOSE_FILE} (current)", + tofile=f"{COMPOSE_FILE} (rendered)", + ) + ) + + +def _atomic_write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(path.name + ".tmp") + tmp.write_text(content, encoding="utf-8") + os.replace(tmp, path) + + +def _var(env: EnvFile, key: str, inline: bool) -> str: + if inline: + return escape_yaml_double_quoted(env.get(key) or "") + return f"${{{key}}}" + + +class ComposeRenderer: + def __init__( + self, templates: TemplateRepository, engines: EngineRegistry, cli_version: str + ) -> None: + self.templates = templates + self.engines = engines + self.cli_version = cli_version + + def header(self) -> str: + return f"{GENERATED_MARKER} {self.cli_version}. Do not edit.\n" + + def render_agent( + self, project: AgentProject, *, inline: bool = False + ) -> RenderResult: + env = project.env + ctx = { + "host_gateway": project.host_gateway, + "docker_socket": project.needs_docker_socket, + "mounts": [ + {"host": host, "container": container} + for host, container in project.sqlite_mounts + ], + "services": [self._service(spec, inline) for spec in project.managed], + "tz_var": _var(env, "TZ", inline), + "edge_key_var": _var(env, "EDGE_KEY", inline), + "log_level_var": _var(env, "LOG_LEVEL", inline), + "polling_var": _var(env, "POLLING", inline), + "extra_env": [ + (name, _var(env, name, inline)) for name in project.extra_env + ], + "ca_bundle": project.ca_bundle, + "ca_bundle_in_container": CA_BUNDLE_IN_CONTAINER, + "ssl_cert_file_var": _var(env, "SSL_CERT_FILE", inline), + } + compose = self.header() + self._render("agent.yml.j2", ctx) + databases = [ + self.engines.get(database.engine).agent_entry(database) + for database in project.databases + ] + return RenderResult(compose=compose, databases=databases) + + def _service(self, spec: DatabaseSpec, inline: bool) -> dict[str, str]: + engine = self.engines.get(spec.engine) + template = self.templates.get(engine.template or "") + body = self._render_template(template, engine.template_ctx(spec, inline=inline)) + return {"name": spec.host or "", "volume": f"{spec.host}-data", "body": body} + + def render_dashboard( + self, project: DashboardProject, *, inline: bool = False + ) -> RenderResult: + env = project.env + ctx = { + "db_mode": project.db_mode, + "project_name_var": project.project_name, + "host_port_var": _var(env, "HOST_PORT", inline), + "tz_var": _var(env, "TZ", inline), + "log_level_var": _var(env, "LOG_LEVEL", inline), + "project_secret_var": _var(env, "PROJECT_SECRET", inline), + "project_url_var": _var(env, "PROJECT_URL", inline), + "pg_port_var": _var(env, "PG_PORT", inline), + "postgres_db_var": _var(env, "POSTGRES_DB", inline), + "postgres_user_var": _var(env, "POSTGRES_USER", inline), + "postgres_password_var": _var(env, "POSTGRES_PASSWORD", inline), + } + compose = self.header() + self._render("dashboard.yml.j2", ctx) + return RenderResult(compose=compose, databases=None) + + def _render(self, name: str, ctx: dict[str, Any]) -> str: + return self._render_template(self.templates.get(name), ctx) + + @staticmethod + def _render_template(template: jinja2.Template, ctx: dict[str, Any]) -> str: + try: + return template.render(**ctx) + except jinja2.TemplateError as error: + raise TemplateError( + f"Template rendering failed: {error}", cause=error + ) from error diff --git a/services/settings.py b/services/settings.py new file mode 100644 index 0000000..fba5f65 --- /dev/null +++ b/services/settings.py @@ -0,0 +1,334 @@ +from __future__ import annotations + +import re +from collections.abc import Iterator +from dataclasses import dataclass +from typing import Any + +from core.errors import ValidationError +from core.fields import Field +from core.utils import validate_edge_key + + +def strong_password(value: str) -> str: + checks = ( + (len(value) >= 8, "at least 8 characters"), + (re.search(r"[a-z]", value), "a lowercase letter"), + (re.search(r"[A-Z]", value), "an uppercase letter"), + (re.search(r"\d", value), "a digit"), + (re.search(r"[^A-Za-z0-9]", value), "a special character"), + ) + missing = [label for ok, label in checks if not ok] + if missing: + raise ValidationError( + "Password too weak.", hint="It needs " + ", ".join(missing) + "." + ) + return value + + +def public_url(value: str) -> str: + if not re.match(r"^https?://[^/\s]+", value): + raise ValidationError( + f"Invalid URL: {value!r}", hint="Expected http(s)://host[:port]" + ) + return value.rstrip("/") + + +def edge_key(value: str) -> str: + if not validate_edge_key(value): + raise ValidationError( + "Invalid Edge Key.", + hint="Expected Base64 or JSON with serverUrl, agentId, masterKeyB64.", + ) + return value + + +def positive(value: int) -> int: + if value < 1: + raise ValidationError("Expected a positive number.") + return value + + +@dataclass(frozen=True) +class Setting: + field: Field + env: str | None + section: str + secret: bool = False + core: bool = False + + @property + def name(self) -> str: + return self.field.name + + def to_env(self, value: Any) -> str: + if self.field.kind == "bool": + return "true" if value else "false" + return str(value) + + def from_env(self, raw: str | None) -> Any: + if raw is None: + return self.field.default + if self.field.kind == "bool": + return raw.strip().lower() in ("1", "true", "yes", "on") + if self.field.kind == "int": + return int(raw) if raw.strip().lstrip("-").isdigit() else raw + return raw + + +class Registry: + def __init__(self, settings: tuple[Setting, ...], sections: dict[str, str]) -> None: + self._settings = settings + self._by_name = {setting.name: setting for setting in settings} + self.sections = sections + + def __iter__(self) -> Iterator[Setting]: + return iter(self._settings) + + def names(self) -> list[str]: + return list(self._by_name) + + def get(self, name: str) -> Setting: + try: + return self._by_name[name] + except KeyError: + raise ValidationError( + f"Unknown setting '{name}'.", hint="Known: " + ", ".join(self._by_name) + ) from None + + def in_section(self, section: str) -> list[Setting]: + return [setting for setting in self._settings if setting.section == section] + + +AGENT = Registry( + ( + Setting( + Field("key", "Edge Key", "text", validator=edge_key), + "EDGE_KEY", + "agent", + secret=True, + core=True, + ), + Setting( + Field("tz", "Timezone", "text", default="UTC"), "TZ", "agent", core=True + ), + Setting( + Field( + "polling", + "Polling frequency (seconds)", + "int", + default=5, + validator=positive, + ), + "POLLING", + "agent", + core=True, + ), + Setting( + Field( + "log_level", + "Log level", + "choice", + default="info", + choices=("debug", "info", "warn", "error"), + ), + "LOG_LEVEL", + "agent", + core=True, + ), + Setting( + Field( + "host_gateway", + "Map localhost to the Docker host?", + "bool", + default=False, + help="Adds extra_hosts localhost:host-gateway so the agent reaches services on the host.", + ), + None, + "network", + core=True, + ), + Setting( + Field("data_path", "Data path inside the container", "text"), + "DATA_PATH", + "storage", + ), + Setting( + Field("tmpdir", "Temporary archives path", "text"), "TMPDIR", "storage" + ), + Setting( + Field( + "retry_attempts", + "Retry attempts for database operations", + "int", + validator=positive, + ), + "RETRY_ATTEMPTS", + "resilience", + ), + Setting( + Field( + "retry_backoff_ms", + "Base delay between retries (ms)", + "int", + validator=positive, + ), + "RETRY_BACKOFF_MS", + "resilience", + ), + Setting( + Field( + "ca_bundle", + "CA bundle on this host (for an internal CA)", + "path", + help=( + "The file is mounted read-only and SSL_CERT_FILE points at it. " + "It REPLACES the root store, so concatenate the Mozilla roots " + "with your CA: cat /etc/ssl/certs/ca-certificates.crt my-ca.crt " + "> ca-bundle.crt" + ), + ), + None, + "network", + ), + ), + { + "agent": "Agent", + "network": "Network", + "storage": "Storage", + "resilience": "Resilience", + }, +) + +DASHBOARD = Registry( + ( + Setting( + Field( + "url", + "Public URL", + "text", + validator=public_url, + help="Used for links and OAuth/OIDC callbacks.", + ), + "PROJECT_URL", + "network", + ), + Setting( + Field("behind_proxy", "Behind a reverse proxy?", "bool", default=False), + "TUSD_BEHIND_PROXY", + "network", + ), + Setting( + Field("trusted_domains", "Trusted domains (comma-separated)", "text"), + "TRUSTED_DOMAINS", + "network", + ), + Setting( + Field("api", "Enable the REST API (/api/v1)?", "bool", default=False), + "API_ENABLED", + "api", + ), + Setting( + Field( + "openapi", "Enable OpenAPI spec and Swagger UI?", "bool", default=False + ), + "OPENAPI_ENABLED", + "api", + ), + Setting( + Field("mcp", "Enable the MCP server (/api/v1/mcp)?", "bool", default=False), + "MCP_ENABLED", + "api", + ), + Setting( + Field( + "skip_onboarding", + "Skip the onboarding wizard?", + "bool", + default=False, + help="Requires an initial account: admin_email and admin_password.", + ), + "SKIP_ONBOARDING", + "onboarding", + ), + Setting( + Field("admin_name", "Initial user name", "text"), + "AUTH_DEFAULT_USER_NAME", + "onboarding", + ), + Setting( + Field("admin_email", "Initial user email", "text"), + "AUTH_DEFAULT_USER", + "onboarding", + ), + Setting( + Field( + "admin_password", + "Initial user password", + "secret", + validator=strong_password, + ), + "AUTH_DEFAULT_PASSWORD", + "onboarding", + secret=True, + ), + Setting( + Field( + "password_auth", + "Allow email/password login?", + "bool", + default=True, + help="Disable only with at least one OIDC or OAuth provider configured.", + ), + "AUTH_EMAIL_PASSWORD_ENABLED", + "auth", + ), + Setting( + Field("signup", "Allow self sign-up?", "bool"), + "AUTH_SIGNUP_ENABLED", + "auth", + ), + Setting( + Field("passkey", "Allow passkey login?", "bool"), + "AUTH_PASSKEY_ENABLED", + "auth", + ), + Setting( + Field("account_linking", "Allow linking provider accounts?", "bool"), + "AUTH_ALLOW_LINKING", + "auth", + ), + Setting( + Field("account_unlinking", "Allow unlinking provider accounts?", "bool"), + "AUTH_ALLOW_UNLINKING", + "auth", + ), + Setting( + Field("sync_oidc_roles", "Sync roles from OIDC on login?", "bool"), + "AUTH_SYNC_OIDC_ROLES_ON_LOGIN", + "auth", + ), + Setting( + Field("role_map", "Role map (remote:portabase,...)", "text"), + "AUTH_ROLE_MAP", + "auth", + ), + Setting( + Field("allowed_group", "Restrict access to this group", "text"), + "ALLOWED_GROUP", + "auth", + ), + ), + { + "network": "Network", + "api": "API & MCP", + "onboarding": "Onboarding", + "auth": "Authentication", + }, +) + +DASHBOARD_WIZARD: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("api", ("api", "openapi", "mcp")), + ("onboarding", ("skip_onboarding", "admin_name", "admin_email", "admin_password")), + ("auth", ("password_auth", "signup", "passkey")), +) diff --git a/services/telemetry.py b/services/telemetry.py new file mode 100644 index 0000000..de6184c --- /dev/null +++ b/services/telemetry.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Any + + +class Telemetry(ABC): + @abstractmethod + def session(self, **attrs: Any): ... + + @abstractmethod + def span(self, name: str, **attrs: Any): ... + + @abstractmethod + def event(self, name: str, **attrs: Any) -> None: ... + + @abstractmethod + def error(self, exc: BaseException, *, unexpected: bool = False) -> None: ... + + def flush(self) -> None: + return None + + +class NoopTelemetry(Telemetry): + @contextmanager + def session(self, **attrs: Any) -> Iterator[None]: + yield + + @contextmanager + def span(self, name: str, **attrs: Any) -> Iterator[None]: + yield + + def event(self, name: str, **attrs: Any) -> None: + return None + + def error(self, exc: BaseException, *, unexpected: bool = False) -> None: + return None diff --git a/services/templates.py b/services/templates.py new file mode 100644 index 0000000..8607980 --- /dev/null +++ b/services/templates.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import jinja2 + +from core.errors import TemplateError + +TEMPLATES_DIR = "templates" +ROOT_TEMPLATE = "agent.yml.j2" + + +class TemplateRepository: + def __init__(self, root: Path) -> None: + self.root = root + self._env: jinja2.Environment | None = None + + @classmethod + def bundled(cls) -> TemplateRepository: + override = os.environ.get("PORTABASE_TEMPLATES_DIR") + if override: + return cls(Path(override)) + bundle = getattr(sys, "_MEIPASS", None) + base = Path(bundle) if bundle else Path(__file__).resolve().parent.parent + return cls(base / TEMPLATES_DIR) + + def resolve(self) -> Path: + if not (self.root / ROOT_TEMPLATE).exists(): + raise TemplateError( + f"No templates found in {self.root}.", + hint=( + "The CLI ships its own templates; either the build is broken " + "or PORTABASE_TEMPLATES_DIR points at the wrong folder." + ), + ) + return self.root + + def names(self) -> list[str]: + return sorted( + path.relative_to(self.root).as_posix() for path in self.root.rglob("*.j2") + ) + + def get(self, name: str) -> jinja2.Template: + root = self.resolve() + if self._env is None: + self._env = jinja2.Environment( + loader=jinja2.FileSystemLoader(str(root)), + undefined=jinja2.StrictUndefined, + keep_trailing_newline=True, + autoescape=False, # noqa: S701 — renders YAML, not HTML + ) + try: + return self._env.get_template(name) + except jinja2.TemplateNotFound as error: + raise TemplateError( + f"Template '{name}' is missing from {root}.", cause=error + ) from error + except jinja2.TemplateError as error: + raise TemplateError( + f"Template '{name}' failed to load: {error}", cause=error + ) from error diff --git a/services/updater.py b/services/updater.py new file mode 100644 index 0000000..c18bc4e --- /dev/null +++ b/services/updater.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import hashlib +import json +import os +import platform +import shutil +import subprocess +import sys +import tempfile +import time +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +from core.config import GlobalConfig +from core.errors import NetworkError, UpdateError +from core.version import UNKNOWN, is_prerelease, parse_version +from services.http import HttpClient + +GITHUB_REPO = "Portabase/cli" +RELEASES_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases" +CACHE_TTL = 24 * 3600 + + +@dataclass(frozen=True) +class Release: + tag: str + assets: dict[str, str] + prerelease: bool + + @classmethod + def from_api(cls, data: dict) -> Release: + return cls( + tag=str(data.get("tag_name", "")).lstrip("v"), + assets={ + asset["name"]: asset["browser_download_url"] + for asset in data.get("assets", []) + }, + prerelease=bool(data.get("prerelease", False)), + ) + + +def platform_asset_name() -> str: + system = platform.system().lower() + system = "macos" if system == "darwin" else system + machine = platform.machine().lower() + arch = "arm64" if machine in ("arm64", "aarch64") else "amd64" + name = f"portabase_{system}_{arch}" + return name + ".exe" if system == "windows" else name + + +def is_frozen() -> bool: + return bool(getattr(sys, "frozen", False)) + + +class UpdateChecker: + def __init__(self, http: HttpClient, config: GlobalConfig, current: str) -> None: + self.http = http + self.config = config + self.current = current + self.cache_file = config.cache_dir / "release.json" + + @property + def include_prerelease(self) -> bool: + channel = self.config.update_channel + if channel: + return channel == "beta" + return is_prerelease(self.current) + + def fetch_latest(self) -> Release | None: + if self.include_prerelease: + releases = self.http.get_json(RELEASES_URL) + return Release.from_api(releases[0]) if releases else None + return Release.from_api(self.http.get_json(f"{RELEASES_URL}/latest")) + + def latest(self, *, force: bool = False) -> Release | None: + if not force: + cached = self._read_cache() + if cached is not None: + return cached + try: + release = self.fetch_latest() + except NetworkError: + return None + if release is not None: + self._write_cache(release) + return release + + def available(self, *, force: bool = False) -> str | None: + if self.current == UNKNOWN: + return None + release = self.latest(force=force) + if release is None: + return None + if parse_version(release.tag) > parse_version(self.current): + return release.tag + return None + + def _read_cache(self) -> Release | None: + try: + with open(self.cache_file, encoding="utf-8") as file: + data = json.load(file) + if time.time() - float(data.get("checked_at", 0)) > CACHE_TTL: + return None + if data.get("channel_pre") != self.include_prerelease: + return None + return Release( + tag=data["tag"], + assets=data.get("assets", {}), + prerelease=bool(data.get("prerelease")), + ) + except (OSError, ValueError, KeyError): + return None + + def _write_cache(self, release: Release) -> None: + try: + self.cache_file.parent.mkdir(parents=True, exist_ok=True) + with open(self.cache_file, "w", encoding="utf-8") as file: + json.dump( + { + "checked_at": time.time(), + "channel_pre": self.include_prerelease, + "tag": release.tag, + "assets": release.assets, + "prerelease": release.prerelease, + }, + file, + ) + except OSError: + pass + + +class Updater: + CHECKSUMS_ASSET = "checksums.txt" + + def __init__(self, http: HttpClient, current: str) -> None: + self.http = http + self.current = current + + def target_path(self) -> Path: + if is_frozen(): + return Path(sys.executable).resolve() + if platform.system().lower() == "windows": + return Path(os.environ.get("APPDATA", "")) / "Portabase" / "portabase.exe" + default = Path("/usr/local/bin/portabase") + if default.exists(): + return default + return Path.home() / ".local" / "bin" / "portabase" + + def download( + self, release: Release, on_progress: Callable[[int], None] | None = None + ) -> Path: + name = platform_asset_name() + url = release.assets.get(name) + if url is None: + available = ", ".join(sorted(release.assets)) + raise UpdateError( + f"No binary for this platform ({name}) in release {release.tag}.", + hint=f"Available: {available}" if available else None, + ) + fd, tmp = tempfile.mkstemp(prefix="portabase_update_") + os.close(fd) + tmp_path = Path(tmp) + try: + self.http.download(url, tmp_path, on_progress, timeout=60) + self._verify(release, name, tmp_path) + except BaseException: + tmp_path.unlink(missing_ok=True) + raise + return tmp_path + + def expected_size(self, release: Release) -> int | None: + url = release.assets.get(platform_asset_name()) + return self.http.content_length(url) if url else None + + def _verify(self, release: Release, name: str, path: Path) -> None: + url = release.assets.get(self.CHECKSUMS_ASSET) + if url is None: + raise UpdateError( + f"Release {release.tag} has no {self.CHECKSUMS_ASSET}; " + "refusing to install." + ) + expected = None + for line in self.http.get_text(url).splitlines(): + parts = line.split() + if len(parts) == 2 and parts[1].lstrip("*") == name: + expected = parts[0].lower() + if expected is None: + raise UpdateError( + f"{name} not listed in {self.CHECKSUMS_ASSET}; refusing to install." + ) + digest = hashlib.sha256() + with open(path, "rb") as file: + for chunk in iter(lambda: file.read(1 << 20), b""): + digest.update(chunk) + if digest.hexdigest() != expected: + raise UpdateError( + "Checksum mismatch for downloaded binary; refusing to install." + ) + + def install(self, tmp: Path, target: Path) -> None: + system = platform.system().lower() + if system != "windows": + tmp.chmod(0o755) + target.parent.mkdir(parents=True, exist_ok=True) + backup = target.with_name(target.name + ".old") + writable = os.access(target.parent, os.W_OK) and ( + not target.exists() or os.access(target, os.W_OK) + ) + try: + if writable or system == "windows": + if target.exists(): + backup.unlink(missing_ok=True) + target.rename(backup) + shutil.move(str(tmp), str(target)) + else: + if target.exists(): + subprocess.run(["sudo", "mv", str(target), str(backup)], check=True) + subprocess.run(["sudo", "mv", str(tmp), str(target)], check=True) + subprocess.run(["sudo", "chmod", "+x", str(target)], check=True) + except (OSError, subprocess.CalledProcessError) as error: + raise UpdateError( + f"Could not install to {target}: {error}", cause=error + ) from error diff --git a/templates/agent.yml.j2 b/templates/agent.yml.j2 new file mode 100644 index 0000000..4732866 --- /dev/null +++ b/templates/agent.yml.j2 @@ -0,0 +1,45 @@ +services: + agent: + restart: unless-stopped + image: portabase/agent:latest + volumes: + - ./databases.json:/config/config.json +{%- for m in mounts %} + - {{ m.host }}:{{ m.container }} +{%- endfor %} +{%- if docker_socket %} + - /var/run/docker.sock:/var/run/docker.sock +{%- endif %} +{%- if ca_bundle %} + - {{ ca_bundle }}:{{ ca_bundle_in_container }}:ro +{%- endif %} +{%- if host_gateway %} + extra_hosts: + - "localhost:host-gateway" +{%- endif %} + environment: + TZ: "{{ tz_var }}" + EDGE_KEY: "{{ edge_key_var }}" + LOG_LEVEL: "{{ log_level_var }}" + POLLING: "{{ polling_var }}" +{%- for name, value in extra_env %} + {{ name }}: "{{ value }}" +{%- endfor %} +{%- if ca_bundle %} + SSL_CERT_FILE: "{{ ssl_cert_file_var }}" +{%- endif %} + networks: + - portabase +{% for s in services %} +{{ s.body }} +{%- endfor %} +{% if services %} +volumes: +{%- for s in services %} + {{ s.volume }}: +{%- endfor %} +{% endif %} +networks: + portabase: + name: portabase_network + external: true diff --git a/templates/compose.py b/templates/compose.py deleted file mode 100644 index d2bc011..0000000 --- a/templates/compose.py +++ /dev/null @@ -1,203 +0,0 @@ -AGENT_POSTGRES_SNIPPET = """ - ${SERVICE_NAME}: - image: postgres:17-alpine - restart: unless-stopped - networks: - - portabase - ports: - - "${PORT}:5432" - volumes: - - ${VOL_NAME}:/var/lib/postgresql/data - environment: - - POSTGRES_DB=${DB_NAME} - - POSTGRES_USER=${USER} - - POSTGRES_PASSWORD=${PASSWORD} - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${USER} -d ${DB_NAME}"] - interval: 10s - timeout: 5s - retries: 5 -""" - -AGENT_MARIADB_SNIPPET = """ - ${SERVICE_NAME}: - image: mariadb:latest - restart: unless-stopped - networks: - - portabase - ports: - - "${PORT}:3306" - environment: - - MYSQL_DATABASE=${DB_NAME} - - MYSQL_USER=${USER} - - MYSQL_PASSWORD=${PASSWORD} - - MYSQL_RANDOM_ROOT_PASSWORD=yes - volumes: - - ${VOL_NAME}:/var/lib/mysql - healthcheck: - test: ["CMD-SHELL", "mariadb-admin ping -h localhost -u ${USER} -p${PASSWORD}"] - interval: 10s - timeout: 5s - retries: 5 -""" - -AGENT_MONGODB_AUTH_SNIPPET = """ - ${SERVICE_NAME}: - image: mongo:latest - restart: unless-stopped - networks: - - portabase - ports: - - "${PORT}:27017" - environment: - - MONGO_INITDB_ROOT_USERNAME=${USER} - - MONGO_INITDB_ROOT_PASSWORD=${PASSWORD} - - MONGO_INITDB_DATABASE=${DB_NAME} - command: mongod --auth - volumes: - - ${VOL_NAME}:/data/db - healthcheck: - test: ["CMD-SHELL", "mongosh --eval 'db.runCommand({ping:1})' --quiet"] - interval: 10s - timeout: 5s - retries: 5 -""" - -AGENT_MONGODB_SNIPPET = """ - ${SERVICE_NAME}: - image: mongo:latest - restart: unless-stopped - networks: - - portabase - ports: - - "${PORT}:27017" - environment: - - MONGO_INITDB_DATABASE=${DB_NAME} - volumes: - - ${VOL_NAME}:/data/db - healthcheck: - test: ["CMD-SHELL", "mongosh --eval 'db.runCommand({ping:1})' --quiet"] - interval: 10s - timeout: 5s - retries: 5 -""" - -AGENT_FIREBIRD_SNIPPET = """ - ${SERVICE_NAME}: - image: firebirdsql/firebird - restart: unless-stopped - networks: - - portabase - ports: - - "${PORT}:3050" - volumes: - - ${VOL_NAME}:/var/lib/firebird/data - environment: - - FIREBIRD_DATABASE=${DB_NAME} - - FIREBIRD_USER=${USER} - - FIREBIRD_PASSWORD=${PASSWORD} - - FIREBIRD_ROOT_PASSWORD=${ROOT_PASSWORD} - - FIREBIRD_DATABASE_DEFAULT_CHARSET=UTF8 - healthcheck: - test: ["CMD-SHELL", "nc -z localhost 3050"] - interval: 10s - timeout: 5s - retries: 5 -""" - - -AGENT_REDIS_SNIPPET = """ - ${SERVICE_NAME}: - image: redis:latest - ports: - - "${PORT}:6379" - volumes: - - ${VOL_NAME}:/data - command: [ "redis-server", "--appendonly", "yes" ] - networks: - - portabase - - default - healthcheck: - test: ["CMD-SHELL", "redis-cli ping | grep PONG"] - interval: 10s - timeout: 5s - retries: 5 -""" - -AGENT_REDIS_AUTH_SNIPPET = """ - ${SERVICE_NAME}: - image: redis:latest - ports: - - "${PORT}:6379" - volumes: - - ${VOL_NAME}:/data - environment: - - REDIS_PASSWORD=${PASSWORD} - command: [ "redis-server", "--requirepass", "${PASSWORD}", "--appendonly", "yes" ] - networks: - - portabase - - default - healthcheck: - test: ["CMD-SHELL", "redis-cli -a ${PASSWORD} ping | grep PONG"] - interval: 10s - timeout: 5s - retries: 5 -""" - - -AGENT_VALKEY_SNIPPET = """ - ${SERVICE_NAME}: - image: valkey/valkey:latest - environment: - - ALLOW_EMPTY_PASSWORD=yes - ports: - - "${PORT}:6379" - volumes: - - ${VOL_NAME}:/data - networks: - - portabase - - default - healthcheck: - test: ["CMD-SHELL", "valkey-cli ping | grep PONG"] - interval: 10s - timeout: 5s - retries: 5 -""" - -AGENT_VALKEY_AUTH_SNIPPET = """ - ${SERVICE_NAME}: - image: valkey/valkey:latest - command: --requirepass "${PASSWORD}" - ports: - - "${PORT}:6379" - volumes: - - ${VOL_NAME}:/data - networks: - - portabase - - default - healthcheck: - test: ["CMD-SHELL", "valkey-cli -a ${PASSWORD} ping | grep PONG"] - interval: 10s - timeout: 5s - retries: 5 -""" - -AGENT_MSSQL_SNIPPET = """ - ${SERVICE_NAME}: - image: mcr.microsoft.com/azure-sql-edge:latest - restart: unless-stopped - networks: - - portabase - ports: - - "${PORT}:1433" - environment: - - ACCEPT_EULA=Y - - MSSQL_SA_PASSWORD=${PASSWORD} - volumes: - - ${VOL_NAME}:/var/opt/mssql - healthcheck: - test: ["CMD-SHELL", "cat /proc/net/tcp6 | grep -q '059901' || exit 1"] - interval: 10s - timeout: 5s - retries: 20 -""" diff --git a/templates/dashboard.yml.j2 b/templates/dashboard.yml.j2 new file mode 100644 index 0000000..75efd94 --- /dev/null +++ b/templates/dashboard.yml.j2 @@ -0,0 +1,52 @@ +name: {{ project_name_var }} +services: + portabase: + container_name: {{ project_name_var }}-app + image: portabase/portabase:latest + restart: unless-stopped + env_file: + - .env + ports: + - "{{ host_port_var }}:80" + environment: + - "TZ={{ tz_var }}" + - "LOG_LEVEL={{ log_level_var }}" + - "PROJECT_SECRET={{ project_secret_var }}" + - "PROJECT_URL={{ project_url_var }}" + volumes: + - portabase-data:/data +{%- if db_mode == "external" %} + depends_on: + db: + condition: service_healthy +{%- endif %} + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost/api/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s +{%- if db_mode == "external" %} + db: + container_name: {{ project_name_var }}-pg + image: postgres:17-alpine + restart: unless-stopped + ports: + - "{{ pg_port_var }}:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + - "POSTGRES_DB={{ postgres_db_var }}" + - "POSTGRES_USER={{ postgres_user_var }}" + - "POSTGRES_PASSWORD={{ postgres_password_var }}" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U {{ postgres_user_var }} -d {{ postgres_db_var }}"] + interval: 10s + timeout: 5s + retries: 5 +{%- endif %} +volumes: +{%- if db_mode == "external" %} + postgres-data: +{%- endif %} + portabase-data: diff --git a/templates/engines/firebird.yml.j2 b/templates/engines/firebird.yml.j2 new file mode 100644 index 0000000..cfaac88 --- /dev/null +++ b/templates/engines/firebird.yml.j2 @@ -0,0 +1,20 @@ + {{ name }}: + image: firebirdsql/firebird + restart: unless-stopped + networks: + - portabase + ports: + - "{{ port_var }}:3050" + volumes: + - {{ volume }}:/var/lib/firebird/data + environment: + - "FIREBIRD_DATABASE={{ db_var }}" + - "FIREBIRD_USER={{ user_var }}" + - "FIREBIRD_PASSWORD={{ password_var }}" + - "FIREBIRD_ROOT_PASSWORD={{ root_password_var }}" + - "FIREBIRD_DATABASE_DEFAULT_CHARSET=UTF8" + healthcheck: + test: ["CMD-SHELL", "nc -z localhost 3050"] + interval: 10s + timeout: 5s + retries: 5 diff --git a/templates/engines/mariadb.yml.j2 b/templates/engines/mariadb.yml.j2 new file mode 100644 index 0000000..de59a3c --- /dev/null +++ b/templates/engines/mariadb.yml.j2 @@ -0,0 +1,19 @@ + {{ name }}: + image: mariadb:latest + restart: unless-stopped + networks: + - portabase + ports: + - "{{ port_var }}:3306" + environment: + - "MYSQL_DATABASE={{ db_var }}" + - "MYSQL_USER={{ user_var }}" + - "MYSQL_PASSWORD={{ password_var }}" + - "MYSQL_RANDOM_ROOT_PASSWORD=yes" + volumes: + - {{ volume }}:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "mariadb-admin ping -h localhost -u {{ user_var }} -p{{ password_var }}"] + interval: 10s + timeout: 5s + retries: 5 diff --git a/templates/engines/mongodb.yml.j2 b/templates/engines/mongodb.yml.j2 new file mode 100644 index 0000000..0cd5023 --- /dev/null +++ b/templates/engines/mongodb.yml.j2 @@ -0,0 +1,23 @@ + {{ name }}: + image: mongo:latest + restart: unless-stopped + networks: + - portabase + ports: + - "{{ port_var }}:27017" + environment: +{%- if auth %} + - "MONGO_INITDB_ROOT_USERNAME={{ user_var }}" + - "MONGO_INITDB_ROOT_PASSWORD={{ password_var }}" +{%- endif %} + - "MONGO_INITDB_DATABASE={{ db_var }}" +{%- if auth %} + command: mongod --auth +{%- endif %} + volumes: + - {{ volume }}:/data/db + healthcheck: + test: ["CMD-SHELL", "mongosh --eval 'db.runCommand({ping:1})' --quiet"] + interval: 10s + timeout: 5s + retries: 5 diff --git a/templates/engines/mssql.yml.j2 b/templates/engines/mssql.yml.j2 new file mode 100644 index 0000000..535623e --- /dev/null +++ b/templates/engines/mssql.yml.j2 @@ -0,0 +1,17 @@ + {{ name }}: + image: mcr.microsoft.com/azure-sql-edge:latest + restart: unless-stopped + networks: + - portabase + ports: + - "{{ port_var }}:1433" + environment: + - "ACCEPT_EULA=Y" + - "MSSQL_SA_PASSWORD={{ password_var }}" + volumes: + - {{ volume }}:/var/opt/mssql + healthcheck: + test: ["CMD-SHELL", "cat /proc/net/tcp6 | grep -q '059901' || exit 1"] + interval: 10s + timeout: 5s + retries: 20 diff --git a/templates/engines/mysql.yml.j2 b/templates/engines/mysql.yml.j2 new file mode 100644 index 0000000..de59a3c --- /dev/null +++ b/templates/engines/mysql.yml.j2 @@ -0,0 +1,19 @@ + {{ name }}: + image: mariadb:latest + restart: unless-stopped + networks: + - portabase + ports: + - "{{ port_var }}:3306" + environment: + - "MYSQL_DATABASE={{ db_var }}" + - "MYSQL_USER={{ user_var }}" + - "MYSQL_PASSWORD={{ password_var }}" + - "MYSQL_RANDOM_ROOT_PASSWORD=yes" + volumes: + - {{ volume }}:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "mariadb-admin ping -h localhost -u {{ user_var }} -p{{ password_var }}"] + interval: 10s + timeout: 5s + retries: 5 diff --git a/templates/engines/postgresql-cluster.yml.j2 b/templates/engines/postgresql-cluster.yml.j2 new file mode 100644 index 0000000..7006e9b --- /dev/null +++ b/templates/engines/postgresql-cluster.yml.j2 @@ -0,0 +1,18 @@ + {{ name }}: + image: postgres:17-alpine + restart: unless-stopped + networks: + - portabase + ports: + - "{{ port_var }}:5432" + volumes: + - {{ volume }}:/var/lib/postgresql/data + environment: + - "POSTGRES_DB={{ db_var }}" + - "POSTGRES_USER={{ user_var }}" + - "POSTGRES_PASSWORD={{ password_var }}" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U {{ user_var }} -d {{ db_var }}"] + interval: 10s + timeout: 5s + retries: 5 diff --git a/templates/engines/postgresql.yml.j2 b/templates/engines/postgresql.yml.j2 new file mode 100644 index 0000000..7006e9b --- /dev/null +++ b/templates/engines/postgresql.yml.j2 @@ -0,0 +1,18 @@ + {{ name }}: + image: postgres:17-alpine + restart: unless-stopped + networks: + - portabase + ports: + - "{{ port_var }}:5432" + volumes: + - {{ volume }}:/var/lib/postgresql/data + environment: + - "POSTGRES_DB={{ db_var }}" + - "POSTGRES_USER={{ user_var }}" + - "POSTGRES_PASSWORD={{ password_var }}" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U {{ user_var }} -d {{ db_var }}"] + interval: 10s + timeout: 5s + retries: 5 diff --git a/templates/engines/redis.yml.j2 b/templates/engines/redis.yml.j2 new file mode 100644 index 0000000..d7e55c8 --- /dev/null +++ b/templates/engines/redis.yml.j2 @@ -0,0 +1,22 @@ + {{ name }}: + image: redis:latest + restart: unless-stopped + ports: + - "{{ port_var }}:6379" + volumes: + - {{ volume }}:/data +{%- if auth %} + environment: + - "REDIS_PASSWORD={{ password_var }}" + command: ["redis-server", "--requirepass", "{{ password_var }}", "--appendonly", "yes"] +{%- else %} + command: ["redis-server", "--appendonly", "yes"] +{%- endif %} + networks: + - portabase + - default + healthcheck: + test: ["CMD-SHELL", "redis-cli {% if auth %}-a {{ password_var }} {% endif %}ping | grep PONG"] + interval: 10s + timeout: 5s + retries: 5 diff --git a/templates/engines/valkey.yml.j2 b/templates/engines/valkey.yml.j2 new file mode 100644 index 0000000..3867e35 --- /dev/null +++ b/templates/engines/valkey.yml.j2 @@ -0,0 +1,21 @@ + {{ name }}: + image: valkey/valkey:latest + restart: unless-stopped +{%- if auth %} + command: ["valkey-server", "--requirepass", "{{ password_var }}"] +{%- else %} + environment: + - "ALLOW_EMPTY_PASSWORD=yes" +{%- endif %} + ports: + - "{{ port_var }}:6379" + volumes: + - {{ volume }}:/data + networks: + - portabase + - default + healthcheck: + test: ["CMD-SHELL", "valkey-cli {% if auth %}-a {{ password_var }} {% endif %}ping | grep PONG"] + interval: 10s + timeout: 5s + retries: 5 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f533a18 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest +import yaml + +from engines import registry +from services.ports import FixedPortAllocator +from services.project import AgentProject, DashboardProject +from services.renderer import ComposeRenderer +from services.templates import TemplateRepository +from tests.support import AGENT_ENV, DASHBOARD_MODES, ROOT, Rendered + +collect_ignore = ["conftest.py", "support.py"] + + +def pytest_pycollect_makeitem(collector, name, obj): + # Tests have no test_ prefix (python_functions = "[!_]*"): never collect a + # callable that a test module only imports (functions, lru_cache wrappers, + # pytest.mark decorators kept in constants). + if callable(obj) and getattr(obj, "__module__", None) != collector.module.__name__: + return [] + return None + + +@pytest.fixture +def ports() -> FixedPortAllocator: + return FixedPortAllocator() + + +@pytest.fixture +def templates() -> TemplateRepository: + return TemplateRepository(ROOT / "templates") + + +@pytest.fixture +def renderer(templates: TemplateRepository) -> ComposeRenderer: + return ComposeRenderer(templates, registry, "test") + + +@pytest.fixture +def agent(tmp_path: Path) -> AgentProject: + return AgentProject.create(tmp_path / "agent", dict(AGENT_ENV), host_gateway=False) + + +@pytest.fixture +def dashboard_for(tmp_path: Path) -> Callable[..., DashboardProject]: + def build(mode: str = "internal", **env: str) -> DashboardProject: + folder = tmp_path / f"dashboard-{len(list(tmp_path.iterdir()))}" + return DashboardProject.create(folder, {**DASHBOARD_MODES[mode], **env}) + + return build + + +@pytest.fixture +def dashboard(dashboard_for: Callable[..., DashboardProject]) -> DashboardProject: + return dashboard_for(PROJECT_URL="https://d.example") + + +@pytest.fixture +def render_engine( + tmp_path: Path, renderer: ComposeRenderer, ports: FixedPortAllocator +) -> Callable[..., Rendered]: + """Generate one database of an engine, add it to a fresh agent, render it.""" + + def render( + key: str, + *, + auth: bool = True, + inline: bool = False, + answers: dict[str, Any] | None = None, + ) -> Rendered: + engine = registry.get(key) + spec = engine.generate(auth=auth, ports=ports, answers=answers or {}) + folder = tmp_path / f"{key}-{len(list(tmp_path.iterdir()))}" + project = AgentProject.create(folder, dict(AGENT_ENV), host_gateway=False) + project.add(spec, engine) + result = renderer.render_agent(project, inline=inline) + result.validate() + return Rendered( + spec, + yaml.safe_load(result.compose), + project.env.as_dict(), + result.databases or [], + ) + + return render diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/config.py b/tests/core/config.py new file mode 100644 index 0000000..002d660 --- /dev/null +++ b/tests/core/config.py @@ -0,0 +1,38 @@ +import json + +import pytest + +from core.config import GlobalConfig + + +@pytest.fixture +def config(tmp_path): + return GlobalConfig(tmp_path / "home" / ".portabase" / "config.json") + + +def missing_file_reads_as_empty(config): + assert config.all() == {} + assert config.get("update_channel", "stable") == "stable" + assert config.update_channel is None + + +@pytest.mark.parametrize("content", ["{not json", "[1, 2]", ""]) +def unreadable_file_reads_as_empty(config, content): + config.path.parent.mkdir(parents=True) + config.path.write_text(content, encoding="utf-8") + assert config.all() == {} + + +def set_creates_the_file_and_keeps_other_keys(config): + config.set("update_channel", "beta") + config.set("other", 1) + assert json.loads(config.path.read_text(encoding="utf-8")) == { + "update_channel": "beta", + "other": 1, + } + assert config.update_channel == "beta" + assert not config.path.with_suffix(".json.tmp").exists() + + +def cache_dir_is_next_to_the_file(config): + assert config.cache_dir == config.path.parent / "cache" diff --git a/tests/core/crypto.py b/tests/core/crypto.py new file mode 100644 index 0000000..5e8f8e4 --- /dev/null +++ b/tests/core/crypto.py @@ -0,0 +1,135 @@ +import base64 +import json +import os +import struct + +import pytest + +from core.crypto import ( + DecryptionError, + decrypt_enc_file, + default_output_for, + load_master_key, +) +from tests.support import encrypt + +KEY = bytes(range(32)) +HEADER = {"cipher": "AES-256-GCM", "base_nonce": list(range(8)), "chunk_size": 4} + + +def _header(**overrides) -> bytes: + return json.dumps({**HEADER, **overrides}).encode() + b"\n" + + +@pytest.fixture +def paths(tmp_path): + return tmp_path / "backup.sql.enc", tmp_path / "out" / "backup.sql" + + +def load_master_key_raw(tmp_path): + path = tmp_path / "master_key.bin" + path.write_bytes(KEY) + assert load_master_key(path) == KEY + + +def load_master_key_base64(tmp_path): + path = tmp_path / "master_key.bin" + path.write_bytes(base64.b64encode(KEY) + b"\n") + assert load_master_key(path) == KEY + + +def load_master_key_defaults_to_cwd(tmp_path, monkeypatch): + (tmp_path / "master_key.bin").write_bytes(KEY) + monkeypatch.chdir(tmp_path) + assert load_master_key(None) == KEY + + +@pytest.mark.parametrize( + "content", [b"short", b"not base64 !!", base64.b64encode(b"x" * 16)] +) +def load_master_key_rejects_wrong_length(tmp_path, content): + path = tmp_path / "master_key.bin" + path.write_bytes(content) + with pytest.raises(DecryptionError, match="32-byte"): + load_master_key(path) + + +def load_master_key_missing(tmp_path): + with pytest.raises(DecryptionError, match="not found"): + load_master_key(tmp_path / "nope.bin") + + +def load_master_key_directory(tmp_path): + with pytest.raises(DecryptionError, match="not a file"): + load_master_key(tmp_path) + + +@pytest.mark.parametrize("plain", [b"", b"abc", b"exactly8", os.urandom(1000)]) +def decrypt_round_trip(paths, plain): + enc, out = paths + enc.write_bytes(encrypt(plain, KEY, chunk_size=4)) + decrypt_enc_file(enc, out, KEY) + assert out.read_bytes() == plain + assert not out.with_name(out.name + ".part").exists() + + +def decrypt_wrong_key_keeps_previous_output(paths): + enc, out = paths + enc.write_bytes(encrypt(b"secret data", KEY)) + out.parent.mkdir() + out.write_bytes(b"previous") + with pytest.raises(DecryptionError, match="Authentication failed on chunk 0"): + decrypt_enc_file(enc, out, bytes(32)) + assert out.read_bytes() == b"previous" + assert not out.with_name(out.name + ".part").exists() + + +def decrypt_detects_reordered_chunks(paths): + enc, out = paths + head, body = encrypt(b"aaaabbbb", KEY, chunk_size=4).split(b"\n", 1) + size = 4 + 4 + 16 + enc.write_bytes(head + b"\n" + body[size : 2 * size] + body[:size]) + with pytest.raises(DecryptionError, match="Authentication failed"): + decrypt_enc_file(enc, out, KEY) + assert not out.exists() + + +@pytest.mark.parametrize( + ("content", "message"), + [ + (b"", "missing header"), + (b"nope\n", "Invalid or missing JSON header"), + (_header(cipher="AES-128-CBC"), "Unsupported cipher"), + (_header(base_nonce=[1, 2]), "Invalid base_nonce length"), + (_header() + b"\x00\x00", "Truncated chunk length prefix"), + (_header() + struct.pack(">I", 4) + b"1234", "smaller than the 16-byte tag"), + (_header() + struct.pack(">I", 1000), "exceeds the maximum 20 bytes"), + (_header() + struct.pack(">I", 20) + b"short", "Truncated chunk 0"), + ], +) +def decrypt_rejects_corrupt_files(paths, content, message): + enc, out = paths + enc.write_bytes(content) + with pytest.raises(DecryptionError, match=message): + decrypt_enc_file(enc, out, KEY) + assert not out.exists() + assert not out.with_name(out.name + ".part").exists() + + +def decrypt_truncated_last_chunk(paths): + enc, out = paths + enc.write_bytes(encrypt(b"abcdefgh", KEY)[:-1]) + with pytest.raises(DecryptionError, match="Truncated chunk 1"): + decrypt_enc_file(enc, out, KEY) + + +@pytest.mark.parametrize( + ("name", "expected"), + [("dump.sql.enc", "dump.sql"), ("dump.enc", "dump"), ("dump.sql", "dump.sql.dec")], +) +def default_output_name(tmp_path, name, expected): + assert default_output_for(tmp_path / name) == expected + + +def decryption_error_exit_code(): + assert (DecryptionError.code, DecryptionError.exit_code) == ("E_CRYPTO", 8) diff --git a/tests/core/errors.py b/tests/core/errors.py new file mode 100644 index 0000000..3aee3eb --- /dev/null +++ b/tests/core/errors.py @@ -0,0 +1,53 @@ +import pytest + +from core import errors +from core.crypto import DecryptionError + +CODES = [ + (errors.PortabaseError, "E_GENERIC", 1), + (errors.ValidationError, "E_VALIDATION", 2), + (errors.ConfigError, "E_CONFIG", 3), + (errors.DockerError, "E_DOCKER", 4), + (errors.TemplateError, "E_TEMPLATE", 5), + (errors.NetworkError, "E_NETWORK", 6), + (errors.UpdateError, "E_UPDATE", 7), + (DecryptionError, "E_CRYPTO", 8), + (errors.UserAbort, "E_ABORT", 130), +] + + +@pytest.mark.parametrize( + ("cls", "code", "exit_code"), CODES, ids=[case[0].__name__ for case in CODES] +) +def codes_and_exit_codes(cls, code, exit_code): + assert issubclass(cls, errors.PortabaseError) + assert (cls.code, cls.exit_code) == (code, exit_code) + + +def exit_codes_are_unique(): + exit_codes = [exit_code for _, _, exit_code in CODES] + assert len(exit_codes) == len(set(exit_codes)) + + +def message_hint_and_cause(): + cause = ValueError("boom") + error = errors.ValidationError("Bad input.", hint="Try again.", cause=cause) + assert str(error) == "Bad input." + assert (error.message, error.hint, error.cause) == ( + "Bad input.", + "Try again.", + cause, + ) + assert error.__cause__ is cause + + +def no_hint_and_no_cause_by_default(): + error = errors.ConfigError("Broken.") + assert error.hint is None + assert error.cause is None + assert error.__cause__ is None + + +def user_abort_default_message(): + assert str(errors.UserAbort()) == "Canceled." + assert errors.UserAbort(hint="Run it again.").hint == "Run it again." diff --git a/tests/core/fields.py b/tests/core/fields.py new file mode 100644 index 0000000..2f53258 --- /dev/null +++ b/tests/core/fields.py @@ -0,0 +1,29 @@ +import dataclasses + +import pytest + +from core.fields import Field + + +def defaults(): + field = Field("name", "Prompt") + assert (field.kind, field.default, field.choices, field.help, field.validator) == ( + "text", + None, + (), + None, + None, + ) + + +@pytest.mark.parametrize( + ("name", "flag"), + [("key", "--key"), ("retry_attempts", "--retry-attempts"), ("a_b_c", "--a-b-c")], +) +def flag_uses_dashes(name, flag): + assert Field(name, "Prompt").flag == flag + + +def is_frozen(): + with pytest.raises(dataclasses.FrozenInstanceError): + Field("a", "A").name = "b" diff --git a/tests/core/specs.py b/tests/core/specs.py new file mode 100644 index 0000000..858e9aa --- /dev/null +++ b/tests/core/specs.py @@ -0,0 +1,33 @@ +import pytest + +from core.specs import DatabaseSpec + + +def _spec(**kwargs): + return DatabaseSpec(id="id-1", engine="postgresql", name="db", **kwargs) + + +def env_prefix_uppercases_and_replaces_dashes(): + assert _spec(host="db-pg-ab12").env_prefix == "DB_PG_AB12" + + +def env_prefix_requires_host(): + with pytest.raises(ValueError, match="host"): + _ = _spec().env_prefix + + +@pytest.mark.parametrize( + ("password", "expected"), [("s3cret", True), ("", False), (None, False)] +) +def auth_follows_password(password, expected): + assert _spec(password=password).auth is expected + + +def with_options_returns_a_copy(): + original = _spec(options={"a": 1}) + options = {"b": 2} + updated = original.with_options(options) + options["c"] = 3 + assert updated.options == {"b": 2} + assert original.options == {"a": 1} + assert updated.id == original.id diff --git a/tests/core/utils.py b/tests/core/utils.py new file mode 100644 index 0000000..224b271 --- /dev/null +++ b/tests/core/utils.py @@ -0,0 +1,99 @@ +import base64 +import json +import string + +import pytest + +from core.utils import ( + escape_yaml_double_quoted, + generate_password, + slugify_project_name, + validate_edge_key, +) +from tests.support import EDGE_KEY_PAYLOAD + +SYMBOLS = "!@#%^&*()-_=+[]{}|;:,.<>?" + + +@pytest.mark.parametrize( + ("length", "expected"), [(16, 16), (8, 8), (40, 40), (7, 8), (0, 8)] +) +def generate_password_length(length, expected): + assert len(generate_password(length)) == expected + + +def generate_password_has_every_character_class(): + for _ in range(100): + password = generate_password(8) + assert any(char in string.ascii_lowercase for char in password) + assert any(char in string.ascii_uppercase for char in password) + assert any(char in string.digits for char in password) + assert any(char in SYMBOLS for char in password) + + +def generate_password_is_random(): + assert len({generate_password() for _ in range(20)}) == 20 + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("my-agent", "my-agent"), + ("My Agent", "my-agent"), + ("--Prod_DB--", "prod_db"), + ("_-x", "x"), + ("été 2026", "t-2026"), + ("a b", "a-b"), + ("!!!", "portabase"), + ("", "portabase"), + ], +) +def slugify_project_name_cases(value, expected): + assert slugify_project_name(value) == expected + + +def slugify_project_name_fallback(): + assert slugify_project_name("***", fallback="agent") == "agent" + + +def validate_edge_key_accepts_base64_json(): + key = base64.b64encode(json.dumps(EDGE_KEY_PAYLOAD).encode()).decode() + assert validate_edge_key(key) + + +def validate_edge_key_accepts_raw_json(): + assert validate_edge_key(json.dumps(EDGE_KEY_PAYLOAD)) + + +@pytest.mark.parametrize("missing", ["serverUrl", "agentId", "masterKeyB64"]) +def validate_edge_key_rejects_missing_field(missing): + payload = {key: value for key, value in EDGE_KEY_PAYLOAD.items() if key != missing} + key = base64.b64encode(json.dumps(payload).encode()).decode() + assert not validate_edge_key(key) + + +@pytest.mark.parametrize( + "key", + ["", "not a key", "1234", base64.b64encode(b"hello").decode(), "{broken json"], +) +def validate_edge_key_rejects_garbage(key): + assert not validate_edge_key(key) + + +@pytest.mark.parametrize( + "data", + [list(EDGE_KEY_PAYLOAD), "serverUrl agentId masterKeyB64", None, 42], + ids=["list", "string", "null", "number"], +) +def validate_edge_key_requires_an_object(data): + raw = json.dumps(data) + assert not validate_edge_key(raw) + assert not validate_edge_key(base64.b64encode(raw.encode()).decode()) + + +@pytest.mark.parametrize( + ("value", "expected"), + [("plain", "plain"), ('a"b', 'a\\"b'), ("a\\b", "a\\\\b"), ('\\"', '\\\\\\"')], +) +def escape_yaml_double_quoted_cases(value, expected): + assert escape_yaml_double_quoted(value) == expected diff --git a/tests/core/version.py b/tests/core/version.py new file mode 100644 index 0000000..4e4d019 --- /dev/null +++ b/tests/core/version.py @@ -0,0 +1,99 @@ +import re +import tomllib + +import pytest + +from core.version import current_version, is_prerelease, parse_version +from tests.support import ROOT + +ZERO = (0, 0, 0, 0, 0, 0) + + +def current_version_reads_pyproject(): + with open(ROOT / "pyproject.toml", "rb") as file: + expected = tomllib.load(file)["project"]["version"] + assert current_version() == expected + + +@pytest.mark.parametrize( + ("version", "expected"), + [ + ("26.08.12", (26, 8, 12, 3, 0, 0)), + ("v1.2.3", (1, 2, 3, 3, 0, 0)), + (" 1.2.3 ", (1, 2, 3, 3, 0, 0)), + ("1.2.3rc1", (1, 2, 3, 2, 1, 0)), + ("1.2.3-rc2", (1, 2, 3, 2, 2, 0)), + ("1.2.3.beta4", (1, 2, 3, 1, 4, 0)), + ("1.2.3b", (1, 2, 3, 1, 0, 0)), + ("1.2.3-alpha", (1, 2, 3, 0, 0, 0)), + ("1.2.3a7", (1, 2, 3, 0, 7, 0)), + ("1.2.3RC1", (1, 2, 3, 2, 1, 0)), + ("1.2.3-beta.2", (1, 2, 3, 1, 2, 0)), + ("26.09.0rc1.2", (26, 9, 0, 2, 1, 2)), + ("unknown", ZERO), + ("1.2", ZERO), + ("1.2.3.4", ZERO), + ], +) +def parse_version_cases(version, expected): + assert parse_version(version) == expected + + +@pytest.mark.parametrize( + ("older", "newer"), + [ + ("1.0.0-alpha1", "1.0.0-beta1"), + ("1.0.0-beta9", "1.0.0rc1"), + ("1.0.0-beta.1", "1.0.0-beta.2"), + ("1.0.0-beta.3", "1.0.0rc1"), + ("1.0.0rc1", "1.0.0rc1.1"), + ("1.0.0rc1.9", "1.0.0rc2"), + ("1.0.0rc9", "1.0.0"), + ("1.0.9", "1.0.10"), + ("26.08.12", "26.09.0"), + ], +) +def parse_version_ordering(older, newer): + assert parse_version(older) < parse_version(newer) + + +@pytest.mark.parametrize( + ("version", "expected"), + [ + ("1.0.0", False), + ("v1.0.0", False), + ("1.0.0rc1", True), + ("1.0.0-beta", True), + ("1.0.0-beta.1", True), + ("26.09.0rc1.2", True), + ("garbage", False), + ], +) +def is_prerelease_cases(version, expected): + assert is_prerelease(version) is expected + + +def _bump_patterns(): + workflow = (ROOT / ".github" / "workflows" / "bump.yml").read_text(encoding="utf-8") + patterns = re.findall(r"=~ (\^\S+\$) \]\]", workflow) + assert len(patterns) == 2, "bump.yml version checks changed; update this test" + return patterns + + +@pytest.mark.parametrize( + "version", + [ + "26.09.0", + "26.09.0rc1", + "26.09.0rc1.2", + "26.09.0-beta.1", + "26.09.0.alpha3", + "26.09.0b", + "26.09", + "26.09.0-dev1", + ], +) +def bump_versions_are_understood(version): + if any(re.fullmatch(pattern, version) for pattern in _bump_patterns()): + assert parse_version(version) != ZERO + assert is_prerelease(version) is not re.fullmatch(r"\d+\.\d+\.\d+", version) diff --git a/tests/engines/__init__.py b/tests/engines/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/engines/base.py b/tests/engines/base.py new file mode 100644 index 0000000..83a38bb --- /dev/null +++ b/tests/engines/base.py @@ -0,0 +1,106 @@ +import json +import re + +import pytest + +from core.specs import DatabaseSpec +from engines import registry +from engines.base import DbEngine, StandardSqlEngine +from services.envfile import EnvFile +from services.project import spec_from_entry + +MANAGED = [engine for engine in registry if engine.template is not None] +VARIANTS = [ + pytest.param(engine, auth, id=f"{engine.key}-{'auth' if auth else 'noauth'}") + for engine in MANAGED + for auth in ((True, False) if engine.auth_variants else (True,)) +] + + +@pytest.mark.parametrize(("engine", "auth"), VARIANTS) +def generate_is_unique(engine, auth, ports): + first = engine.generate(auth=auth, ports=ports, answers={}) + second = engine.generate(auth=auth, ports=ports, answers={}) + assert first.id != second.id + assert first.host != second.host + assert first.host_port != second.host_port + + +@pytest.mark.parametrize(("engine", "auth"), VARIANTS) +def env_vars_are_prefixed_strings(engine, auth, ports): + spec = engine.generate(auth=auth, ports=ports, answers={}) + env = engine.env_vars(spec) + assert env[f"{spec.env_prefix}_PORT"] == str(spec.host_port) + assert all(key.startswith(spec.env_prefix + "_") for key in env) + assert all(isinstance(value, str) for value in env.values()) + + +@pytest.mark.parametrize(("engine", "auth"), VARIANTS) +def template_ctx_points_at_env_vars(engine, auth, ports): + spec = engine.generate(auth=auth, ports=ports, answers={}) + ctx = engine.template_ctx(spec) + assert (ctx["name"], ctx["volume"], ctx["auth"]) == ( + spec.host, + f"{spec.host}-data", + auth, + ) + for key in ("port_var", "db_var", "user_var", "password_var"): + assert re.fullmatch(rf"\$\{{{spec.env_prefix}_[A-Z_]+\}}", ctx[key]) + + +@pytest.mark.parametrize(("engine", "auth"), VARIANTS) +def agent_entry_survives_databases_json(engine, auth, ports, tmp_path): + spec = engine.generate(auth=auth, ports=ports, answers={}) + entry = json.loads(json.dumps(engine.agent_entry(spec))) + env = EnvFile(tmp_path / ".env") + env.merge(engine.env_vars(spec)) + loaded = spec_from_entry(entry, env) + assert loaded.managed + assert (loaded.id, loaded.engine, loaded.host, loaded.port, loaded.host_port) == ( + spec.id, + spec.engine, + spec.host, + spec.port, + spec.host_port, + ) + assert (loaded.password, loaded.root_password) == ( + spec.password, + spec.root_password, + ) + + +def service_name_format(): + assert re.fullmatch(r"db-pg-[0-9a-f]{4}", DbEngine.service_name("pg")) + assert re.fullmatch( + r"db-pg-auth-[0-9a-f]{4}", DbEngine.service_name("pg", auth=True) + ) + + +def var_references_or_escapes(): + spec = DatabaseSpec(id="1", engine="postgresql", name="n", host="db-pg-ab12") + assert DbEngine.var(spec, "PASS", "x", inline=False) == "${DB_PG_AB12_PASS}" + assert DbEngine.var(spec, "PASS", 'a"b\\c', inline=True) == 'a\\"b\\\\c' + assert DbEngine.var(spec, "PASS", None, inline=True) == "" + assert DbEngine.var(spec, "PORT", 5432, inline=True) == "5432" + + +def engine_without_required_attributes_is_refused(): + with pytest.raises(TypeError, match="missing required engine attribute"): + + class Broken(StandardSqlEngine): + key, display = "broken", "Broken" + + +def abstract_engine_may_be_incomplete(): + class Base(DbEngine): + abstract = True + + assert Base.abstract + + +def template_must_live_under_engines(): + with pytest.raises(TypeError, match="must be a path under 'engines/'"): + + class Misplaced(StandardSqlEngine): + key, display, default_port = "misplaced", "Misplaced", 1 + template, slug, db_prefix = "misplaced.yml.j2", "m", "m" diff --git a/tests/engines/docker_volume.py b/tests/engines/docker_volume.py new file mode 100644 index 0000000..f2123eb --- /dev/null +++ b/tests/engines/docker_volume.py @@ -0,0 +1,84 @@ +from core.specs import DatabaseSpec +from engines import registry +from engines.docker_volume import DockerVolumeEngine +from tests.support import agent_service, field_specs + +VOLUME = registry.get("docker-volume") +SOCKET = "/var/run/docker.sock:/var/run/docker.sock" + + +def attributes(): + assert type(VOLUME) is DockerVolumeEngine + assert (VOLUME.key, VOLUME.display, VOLUME.default_port) == ( + "docker-volume", + "Docker Volume", + None, + ) + assert VOLUME.template is None + assert (VOLUME.auth_variants, VOLUME.has_modes) == (False, False) + assert "/var/run/docker.sock" in (VOLUME.warning or "") + + +def generate(ports): + answers = {"volume": " data ", "container": "app", "label": "Files"} + spec = VOLUME.generate(auth=False, ports=ports, answers=answers) + assert spec == DatabaseSpec( + id=spec.id, + engine="docker-volume", + name="Files", + volume="data", + container="app", + ) + assert VOLUME.describe(spec) == "volume: data" + + +def env_vars(): + assert VOLUME.env_vars(VOLUME.from_existing({"volume": "v"})) == {} + + +def agent_entry(): + bare = VOLUME.from_existing({"volume": "v"}) + with_container = VOLUME.from_existing({"volume": "v", "container": "app"}) + assert VOLUME.agent_entry(bare) == { + "name": "Docker Volume", + "type": "docker-volume", + "volume_name": "v", + "generated_id": bare.id, + } + assert VOLUME.agent_entry(with_container) == { + "name": "Docker Volume", + "type": "docker-volume", + "volume_name": "v", + "container_name": "app", + "generated_id": with_container.id, + } + + +def fields(): + assert field_specs(VOLUME.fields_existing()) == [ + ("volume", "text", None), + ("container", "text", ""), + ] + assert VOLUME.fields_new() == VOLUME.fields_existing() + assert VOLUME.option_fields() == [] + + +def from_existing(): + spec = VOLUME.from_existing({"volume": " data ", "container": " "}) + assert spec == DatabaseSpec( + id=spec.id, engine="docker-volume", name="Docker Volume", volume="data" + ) + + +def compose_service(render_engine): + rendered = render_engine( + "docker-volume", answers={"volume": "v", "container": "app"} + ) + assert rendered.doc["services"] == {"agent": agent_service(SOCKET)} + assert "volumes" not in rendered.doc + assert rendered.databases == [VOLUME.agent_entry(rendered.spec)] + + +def compose_service_inline(render_engine): + rendered = render_engine("docker-volume", inline=True, answers={"volume": "v"}) + assert rendered.doc["services"] == {"agent": agent_service(SOCKET, inline=True)} diff --git a/tests/engines/firebird.py b/tests/engines/firebird.py new file mode 100644 index 0000000..6431bee --- /dev/null +++ b/tests/engines/firebird.py @@ -0,0 +1,145 @@ +import re + +from core.specs import DatabaseSpec +from engines import registry +from engines.firebird import FirebirdEngine +from tests.support import EXISTING_ANSWERS, field_specs + +FIREBIRD = registry.get("firebird") +HEALTH = {"interval": "10s", "timeout": "5s", "retries": 5} + + +def attributes(): + assert type(FIREBIRD) is FirebirdEngine + assert (FIREBIRD.key, FIREBIRD.display, FIREBIRD.default_port) == ( + "firebird", + "Firebird", + 3050, + ) + assert FIREBIRD.template == "engines/firebird.yml.j2" + assert (FIREBIRD.auth_variants, FIREBIRD.has_modes, FIREBIRD.warning) == ( + False, + True, + None, + ) + + +def generate(ports): + spec = FIREBIRD.generate(auth=True, ports=ports, answers={}) + assert re.fullmatch(r"db-firebird-[0-9a-f]{4}", spec.host or "") + assert len(spec.password or "") == 16 + assert len(spec.root_password or "") == 16 + assert spec.password != spec.root_password + assert spec == DatabaseSpec( + id=spec.id, + engine="firebird", + name="mirror.fdb", + managed=True, + host=spec.host, + port=3050, + host_port=40000, + database="/var/lib/firebird/data/mirror.fdb", + username="alice", + password=spec.password, + root_password=spec.root_password, + ) + assert FIREBIRD.describe(spec) == f"{spec.host}:3050" + + +def env_vars(ports): + spec = FIREBIRD.generate(auth=True, ports=ports, answers={}) + prefix = spec.env_prefix + assert FIREBIRD.env_vars(spec) == { + f"{prefix}_PORT": "40000", + f"{prefix}_DB": "mirror.fdb", + f"{prefix}_USER": "alice", + f"{prefix}_PASS": spec.password, + f"{prefix}_ROOT_PASS": spec.root_password, + } + + +def agent_entry(ports): + spec = FIREBIRD.generate(auth=True, ports=ports, answers={}) + assert FIREBIRD.agent_entry(spec) == { + "name": "mirror.fdb", + "database": "/var/lib/firebird/data/mirror.fdb", + "type": "firebird", + "username": "alice", + "password": spec.password, + "port": 3050, + "host": spec.host, + "generated_id": spec.id, + } + + +def fields(): + assert field_specs(FIREBIRD.fields_existing()) == [ + ("host", "text", "localhost"), + ("port", "int", 3050), + ("database", "text", None), + ("username", "text", None), + ("password", "secret", None), + ] + assert FIREBIRD.fields_new() == [] + assert FIREBIRD.option_fields() == [] + + +def from_existing(): + spec = FIREBIRD.from_existing({**EXISTING_ANSWERS, "label": "Prod"}) + assert spec == DatabaseSpec( + id=spec.id, + engine="firebird", + name="Prod", + host="db.example", + port=1234, + database="app", + username="u", + password="p", + ) + assert FIREBIRD.from_existing(EXISTING_ANSWERS).name == "External DB" + + +def compose_service(render_engine): + rendered = render_engine("firebird") + assert rendered.service == { + "image": "firebirdsql/firebird", + "restart": "unless-stopped", + "networks": ["portabase"], + "ports": [f"{rendered.var('PORT')}:3050"], + "volumes": [f"{rendered.spec.host}-data:/var/lib/firebird/data"], + "environment": [ + f"FIREBIRD_DATABASE={rendered.var('DB')}", + f"FIREBIRD_USER={rendered.var('USER')}", + f"FIREBIRD_PASSWORD={rendered.var('PASS')}", + f"FIREBIRD_ROOT_PASSWORD={rendered.var('ROOT_PASS')}", + "FIREBIRD_DATABASE_DEFAULT_CHARSET=UTF8", + ], + "healthcheck": {"test": ["CMD-SHELL", "nc -z localhost 3050"], **HEALTH}, + } + + +def compose_service_inline(render_engine): + rendered = render_engine("firebird", inline=True) + assert rendered.service["ports"] == ["40000:3050"] + assert rendered.service["environment"] == [ + "FIREBIRD_DATABASE=mirror.fdb", + "FIREBIRD_USER=alice", + f"FIREBIRD_PASSWORD={rendered.spec.password}", + f"FIREBIRD_ROOT_PASSWORD={rendered.spec.root_password}", + "FIREBIRD_DATABASE_DEFAULT_CHARSET=UTF8", + ] + + +def template_ctx_uses_the_file_name(ports): + spec = FIREBIRD.generate(auth=True, ports=ports, answers={}) + prefix = spec.env_prefix + ctx = FIREBIRD.template_ctx(spec) + inline = FIREBIRD.template_ctx(spec, inline=True) + assert (ctx["db_var"], ctx["root_password_var"]) == ( + f"${{{prefix}_DB}}", + f"${{{prefix}_ROOT_PASS}}", + ) + assert (inline["db_var"], inline["root_password_var"]) == ( + "mirror.fdb", + spec.root_password, + ) diff --git a/tests/engines/mariadb.py b/tests/engines/mariadb.py new file mode 100644 index 0000000..a3e53a2 --- /dev/null +++ b/tests/engines/mariadb.py @@ -0,0 +1,131 @@ +import re + +from core.specs import DatabaseSpec +from engines import registry +from engines.mariadb import MariaDbEngine +from tests.support import EXISTING_ANSWERS, field_specs + +MARIADB = registry.get("mariadb") +HEALTH = {"interval": "10s", "timeout": "5s", "retries": 5} + + +def attributes(): + assert type(MARIADB) is MariaDbEngine + assert (MARIADB.key, MARIADB.display, MARIADB.default_port) == ( + "mariadb", + "MariaDB", + 3306, + ) + assert MARIADB.template == "engines/mariadb.yml.j2" + assert (MARIADB.auth_variants, MARIADB.has_modes, MARIADB.warning) == ( + False, + True, + None, + ) + + +def generate(ports): + spec = MARIADB.generate(auth=True, ports=ports, answers={}) + assert re.fullmatch(r"db-mariadb-[0-9a-f]{4}", spec.host or "") + assert re.fullmatch(r"mysql_[0-9a-f]{8}", spec.database or "") + assert len(spec.password or "") == 16 + assert spec == DatabaseSpec( + id=spec.id, + engine="mariadb", + name=spec.database or "", + managed=True, + host=spec.host, + port=3306, + host_port=40000, + database=spec.database, + username="admin", + password=spec.password, + ) + assert MARIADB.describe(spec) == f"{spec.host}:3306" + + +def env_vars(ports): + spec = MARIADB.generate(auth=True, ports=ports, answers={}) + prefix = spec.env_prefix + assert MARIADB.env_vars(spec) == { + f"{prefix}_PORT": "40000", + f"{prefix}_DB": spec.database, + f"{prefix}_USER": "admin", + f"{prefix}_PASS": spec.password, + } + + +def agent_entry(ports): + spec = MARIADB.generate(auth=True, ports=ports, answers={}) + assert MARIADB.agent_entry(spec) == { + "name": spec.name, + "database": spec.database, + "type": "mariadb", + "username": "admin", + "password": spec.password, + "port": 3306, + "host": spec.host, + "generated_id": spec.id, + } + + +def fields(): + assert field_specs(MARIADB.fields_existing()) == [ + ("host", "text", "localhost"), + ("port", "int", 3306), + ("database", "text", None), + ("username", "text", None), + ("password", "secret", None), + ] + assert MARIADB.fields_new() == [] + assert MARIADB.option_fields() == [] + + +def from_existing(): + spec = MARIADB.from_existing({**EXISTING_ANSWERS, "label": "Prod"}) + assert spec == DatabaseSpec( + id=spec.id, + engine="mariadb", + name="Prod", + host="db.example", + port=1234, + database="app", + username="u", + password="p", + ) + assert MARIADB.from_existing(EXISTING_ANSWERS).name == "External DB" + + +def compose_service(render_engine): + rendered = render_engine("mariadb") + assert rendered.service == { + "image": "mariadb:latest", + "restart": "unless-stopped", + "networks": ["portabase"], + "ports": [f"{rendered.var('PORT')}:3306"], + "environment": [ + f"MYSQL_DATABASE={rendered.var('DB')}", + f"MYSQL_USER={rendered.var('USER')}", + f"MYSQL_PASSWORD={rendered.var('PASS')}", + "MYSQL_RANDOM_ROOT_PASSWORD=yes", + ], + "volumes": [f"{rendered.spec.host}-data:/var/lib/mysql"], + "healthcheck": { + "test": [ + "CMD-SHELL", + f"mariadb-admin ping -h localhost -u {rendered.var('USER')} -p{rendered.var('PASS')}", + ], + **HEALTH, + }, + } + + +def compose_service_inline(render_engine): + rendered = render_engine("mariadb", inline=True) + assert rendered.service["ports"] == ["40000:3306"] + assert rendered.service["environment"] == [ + f"MYSQL_DATABASE={rendered.spec.database}", + "MYSQL_USER=admin", + f"MYSQL_PASSWORD={rendered.spec.password}", + "MYSQL_RANDOM_ROOT_PASSWORD=yes", + ] diff --git a/tests/engines/mongodb.py b/tests/engines/mongodb.py new file mode 100644 index 0000000..dce63a4 --- /dev/null +++ b/tests/engines/mongodb.py @@ -0,0 +1,132 @@ +import re + +import pytest + +from core.specs import DatabaseSpec +from engines import registry +from engines.mongodb import MongoEngine +from tests.support import EXISTING_ANSWERS, field_specs + +MONGO = registry.get("mongodb") +HEALTH = {"interval": "10s", "timeout": "5s", "retries": 5} +AUTH = pytest.mark.parametrize("auth", [True, False], ids=["auth", "noauth"]) + + +def attributes(): + assert type(MONGO) is MongoEngine + assert (MONGO.key, MONGO.display, MONGO.default_port) == ( + "mongodb", + "MongoDB", + 27017, + ) + assert MONGO.template == "engines/mongodb.yml.j2" + assert (MONGO.auth_variants, MONGO.has_modes, MONGO.warning) == (True, True, None) + + +@AUTH +def generate(ports, auth): + spec = MONGO.generate(auth=auth, ports=ports, answers={}) + suffix = "auth-" if auth else "" + assert re.fullmatch(rf"db-mongo-{suffix}[0-9a-f]{{4}}", spec.host or "") + assert re.fullmatch(r"mongo_[0-9a-f]{8}", spec.database or "") + assert len(spec.password or "") == (16 if auth else 0) + assert spec == DatabaseSpec( + id=spec.id, + engine="mongodb", + name=spec.database or "", + managed=True, + host=spec.host, + port=27017, + host_port=40000, + database=spec.database, + username="admin" if auth else "", + password=spec.password, + ) + assert MONGO.describe(spec) == f"{spec.host}:27017" + + +@AUTH +def env_vars(ports, auth): + spec = MONGO.generate(auth=auth, ports=ports, answers={}) + prefix = spec.env_prefix + expected = {f"{prefix}_PORT": "40000", f"{prefix}_DB": spec.database} + if auth: + expected |= {f"{prefix}_USER": "admin", f"{prefix}_PASS": spec.password} + assert MONGO.env_vars(spec) == expected + + +@AUTH +def agent_entry(ports, auth): + spec = MONGO.generate(auth=auth, ports=ports, answers={}) + assert MONGO.agent_entry(spec) == { + "name": spec.name, + "database": spec.database, + "type": "mongodb", + "username": "admin" if auth else "", + "password": spec.password or "", + "port": 27017, + "host": spec.host, + "generated_id": spec.id, + } + + +def fields(): + assert field_specs(MONGO.fields_existing()) == [ + ("host", "text", "localhost"), + ("port", "int", 27017), + ("database", "text", None), + ("username", "text", None), + ("password", "secret", None), + ] + assert MONGO.fields_new() == [] + assert MONGO.option_fields() == [] + + +def from_existing(): + spec = MONGO.from_existing({**EXISTING_ANSWERS, "label": "Prod"}) + assert spec == DatabaseSpec( + id=spec.id, + engine="mongodb", + name="Prod", + host="db.example", + port=1234, + database="app", + username="u", + password="p", + ) + assert MONGO.from_existing(EXISTING_ANSWERS).name == "External DB" + + +@AUTH +def compose_service(render_engine, auth): + rendered = render_engine("mongodb", auth=auth) + expected = { + "image": "mongo:latest", + "restart": "unless-stopped", + "networks": ["portabase"], + "ports": [f"{rendered.var('PORT')}:27017"], + "environment": [f"MONGO_INITDB_DATABASE={rendered.var('DB')}"], + "volumes": [f"{rendered.spec.host}-data:/data/db"], + "healthcheck": { + "test": ["CMD-SHELL", "mongosh --eval 'db.runCommand({ping:1})' --quiet"], + **HEALTH, + }, + } + if auth: + expected["environment"] = [ + f"MONGO_INITDB_ROOT_USERNAME={rendered.var('USER')}", + f"MONGO_INITDB_ROOT_PASSWORD={rendered.var('PASS')}", + f"MONGO_INITDB_DATABASE={rendered.var('DB')}", + ] + expected["command"] = "mongod --auth" + assert rendered.service == expected + + +def compose_service_inline(render_engine): + rendered = render_engine("mongodb", auth=True, inline=True) + assert rendered.service["ports"] == ["40000:27017"] + assert rendered.service["environment"] == [ + "MONGO_INITDB_ROOT_USERNAME=admin", + f"MONGO_INITDB_ROOT_PASSWORD={rendered.spec.password}", + f"MONGO_INITDB_DATABASE={rendered.spec.database}", + ] diff --git a/tests/engines/mssql.py b/tests/engines/mssql.py new file mode 100644 index 0000000..f749e82 --- /dev/null +++ b/tests/engines/mssql.py @@ -0,0 +1,120 @@ +import re + +from core.specs import DatabaseSpec +from engines import registry +from engines.mssql import MssqlEngine +from tests.support import EXISTING_ANSWERS, field_specs + +MSSQL = registry.get("mssql") + + +def attributes(): + assert type(MSSQL) is MssqlEngine + assert (MSSQL.key, MSSQL.display, MSSQL.default_port) == ( + "mssql", + "Microsoft SQL Server", + 1433, + ) + assert MSSQL.template == "engines/mssql.yml.j2" + assert (MSSQL.auth_variants, MSSQL.has_modes, MSSQL.warning) == (False, True, None) + + +def generate(ports): + spec = MSSQL.generate(auth=True, ports=ports, answers={}) + assert re.fullmatch(r"db-mssql-[0-9a-f]{4}", spec.host or "") + assert len(spec.password or "") == 16 + assert spec == DatabaseSpec( + id=spec.id, + engine="mssql", + name="MSSQL", + managed=True, + host=spec.host, + port=1433, + host_port=40000, + database="master", + username="sa", + password=spec.password, + ) + assert MSSQL.describe(spec) == f"{spec.host}:1433" + + +def env_vars(ports): + spec = MSSQL.generate(auth=True, ports=ports, answers={}) + prefix = spec.env_prefix + assert MSSQL.env_vars(spec) == { + f"{prefix}_PORT": "40000", + f"{prefix}_PASS": spec.password, + } + + +def agent_entry(ports): + spec = MSSQL.generate(auth=True, ports=ports, answers={}) + assert MSSQL.agent_entry(spec) == { + "name": "MSSQL", + "database": "master", + "type": "mssql", + "username": "sa", + "password": spec.password, + "port": 1433, + "host": spec.host, + "generated_id": spec.id, + } + + +def fields(): + assert field_specs(MSSQL.fields_existing()) == [ + ("host", "text", "localhost"), + ("port", "int", 1433), + ("database", "text", None), + ("username", "text", None), + ("password", "secret", None), + ] + assert MSSQL.fields_new() == [] + assert MSSQL.option_fields() == [] + + +def from_existing(): + spec = MSSQL.from_existing({**EXISTING_ANSWERS, "label": "Prod"}) + assert spec == DatabaseSpec( + id=spec.id, + engine="mssql", + name="Prod", + host="db.example", + port=1234, + database="app", + username="u", + password="p", + ) + assert MSSQL.from_existing(EXISTING_ANSWERS).name == "External DB" + + +def compose_service(render_engine): + rendered = render_engine("mssql") + assert rendered.service == { + "image": "mcr.microsoft.com/azure-sql-edge:latest", + "restart": "unless-stopped", + "networks": ["portabase"], + "ports": [f"{rendered.var('PORT')}:1433"], + "environment": ["ACCEPT_EULA=Y", f"MSSQL_SA_PASSWORD={rendered.var('PASS')}"], + "volumes": [f"{rendered.spec.host}-data:/var/opt/mssql"], + "healthcheck": { + "test": ["CMD-SHELL", "cat /proc/net/tcp6 | grep -q '059901' || exit 1"], + "interval": "10s", + "timeout": "5s", + "retries": 20, + }, + } + + +def compose_service_inline(render_engine): + rendered = render_engine("mssql", inline=True) + assert rendered.service["ports"] == ["40000:1433"] + assert rendered.service["environment"] == [ + "ACCEPT_EULA=Y", + f"MSSQL_SA_PASSWORD={rendered.spec.password}", + ] + + +def options_are_ignored(ports): + spec = MSSQL.generate(auth=True, ports=ports, answers={"options": {"x": 1}}) + assert spec.options == {} diff --git a/tests/engines/mysql.py b/tests/engines/mysql.py new file mode 100644 index 0000000..ace0c9c --- /dev/null +++ b/tests/engines/mysql.py @@ -0,0 +1,126 @@ +import re + +from core.specs import DatabaseSpec +from engines import registry +from engines.mariadb import MariaDbEngine +from engines.mysql import MySqlEngine +from tests.support import EXISTING_ANSWERS, field_specs + +MYSQL = registry.get("mysql") +HEALTH = {"interval": "10s", "timeout": "5s", "retries": 5} + + +def attributes(): + assert type(MYSQL) is MySqlEngine + assert isinstance(MYSQL, MariaDbEngine) + assert (MYSQL.key, MYSQL.display, MYSQL.default_port) == ("mysql", "MySQL", 3306) + assert MYSQL.template == "engines/mysql.yml.j2" + assert (MYSQL.auth_variants, MYSQL.has_modes, MYSQL.warning) == (False, True, None) + + +def generate(ports): + spec = MYSQL.generate(auth=True, ports=ports, answers={}) + assert re.fullmatch(r"db-mariadb-[0-9a-f]{4}", spec.host or "") + assert re.fullmatch(r"mysql_[0-9a-f]{8}", spec.database or "") + assert len(spec.password or "") == 16 + assert spec == DatabaseSpec( + id=spec.id, + engine="mysql", + name=spec.database or "", + managed=True, + host=spec.host, + port=3306, + host_port=40000, + database=spec.database, + username="admin", + password=spec.password, + ) + assert MYSQL.describe(spec) == f"{spec.host}:3306" + + +def env_vars(ports): + spec = MYSQL.generate(auth=True, ports=ports, answers={}) + prefix = spec.env_prefix + assert MYSQL.env_vars(spec) == { + f"{prefix}_PORT": "40000", + f"{prefix}_DB": spec.database, + f"{prefix}_USER": "admin", + f"{prefix}_PASS": spec.password, + } + + +def agent_entry(ports): + spec = MYSQL.generate(auth=True, ports=ports, answers={}) + assert MYSQL.agent_entry(spec) == { + "name": spec.name, + "database": spec.database, + "type": "mysql", + "username": "admin", + "password": spec.password, + "port": 3306, + "host": spec.host, + "generated_id": spec.id, + } + + +def fields(): + assert field_specs(MYSQL.fields_existing()) == [ + ("host", "text", "localhost"), + ("port", "int", 3306), + ("database", "text", None), + ("username", "text", None), + ("password", "secret", None), + ] + assert MYSQL.fields_new() == [] + assert MYSQL.option_fields() == [] + + +def from_existing(): + spec = MYSQL.from_existing({**EXISTING_ANSWERS, "label": "Prod"}) + assert spec == DatabaseSpec( + id=spec.id, + engine="mysql", + name="Prod", + host="db.example", + port=1234, + database="app", + username="u", + password="p", + ) + assert MYSQL.from_existing(EXISTING_ANSWERS).name == "External DB" + + +def compose_service(render_engine): + rendered = render_engine("mysql") + assert rendered.service == { + # MySQL databases have always run on the MariaDB image (wire compatible). + "image": "mariadb:latest", + "restart": "unless-stopped", + "networks": ["portabase"], + "ports": [f"{rendered.var('PORT')}:3306"], + "environment": [ + f"MYSQL_DATABASE={rendered.var('DB')}", + f"MYSQL_USER={rendered.var('USER')}", + f"MYSQL_PASSWORD={rendered.var('PASS')}", + "MYSQL_RANDOM_ROOT_PASSWORD=yes", + ], + "volumes": [f"{rendered.spec.host}-data:/var/lib/mysql"], + "healthcheck": { + "test": [ + "CMD-SHELL", + f"mariadb-admin ping -h localhost -u {rendered.var('USER')} -p{rendered.var('PASS')}", + ], + **HEALTH, + }, + } + + +def compose_service_inline(render_engine): + rendered = render_engine("mysql", inline=True) + assert rendered.service["ports"] == ["40000:3306"] + assert rendered.service["environment"] == [ + f"MYSQL_DATABASE={rendered.spec.database}", + "MYSQL_USER=admin", + f"MYSQL_PASSWORD={rendered.spec.password}", + "MYSQL_RANDOM_ROOT_PASSWORD=yes", + ] diff --git a/tests/engines/postgresql.py b/tests/engines/postgresql.py new file mode 100644 index 0000000..40900b0 --- /dev/null +++ b/tests/engines/postgresql.py @@ -0,0 +1,139 @@ +import re + +from core.specs import DatabaseSpec +from engines import registry +from engines.postgresql import PostgresEngine +from tests.support import EXISTING_ANSWERS, field_specs + +PG = registry.get("postgresql") +HEALTH = {"interval": "10s", "timeout": "5s", "retries": 5} + + +def attributes(): + assert type(PG) is PostgresEngine + assert (PG.key, PG.display, PG.default_port) == ("postgresql", "PostgreSQL", 5432) + assert PG.template == "engines/postgresql.yml.j2" + assert (PG.auth_variants, PG.has_modes, PG.warning) == (False, True, None) + + +def generate(ports): + spec = PG.generate(auth=True, ports=ports, answers={}) + assert re.fullmatch(r"db-pg-[0-9a-f]{4}", spec.host or "") + assert re.fullmatch(r"pg_[0-9a-f]{8}", spec.database or "") + assert len(spec.password or "") == 16 + assert spec == DatabaseSpec( + id=spec.id, + engine="postgresql", + name=spec.database or "", + managed=True, + host=spec.host, + port=5432, + host_port=40000, + database=spec.database, + username="admin", + password=spec.password, + ) + assert PG.describe(spec) == f"{spec.host}:5432" + + +def env_vars(ports): + spec = PG.generate(auth=True, ports=ports, answers={}) + prefix = spec.env_prefix + assert PG.env_vars(spec) == { + f"{prefix}_PORT": "40000", + f"{prefix}_DB": spec.database, + f"{prefix}_USER": "admin", + f"{prefix}_PASS": spec.password, + } + + +def agent_entry(ports): + spec = PG.generate(auth=True, ports=ports, answers={}) + assert PG.agent_entry(spec) == { + "name": spec.name, + "database": spec.database, + "type": "postgresql", + "username": "admin", + "password": spec.password, + "port": 5432, + "host": spec.host, + "generated_id": spec.id, + } + + +def fields(): + assert field_specs(PG.fields_existing()) == [ + ("host", "text", "localhost"), + ("port", "int", 5432), + ("database", "text", None), + ("username", "text", None), + ("password", "secret", None), + ] + assert PG.fields_new() == [] + assert field_specs(PG.option_fields()) == [ + ("keep_ownership", "bool", False), + ("clean_mode", "choice", "clean"), + ] + assert PG.option_fields()[1].choices == ( + "clean", + "none", + "drop_schemas", + "drop_database", + ) + + +def from_existing(): + spec = PG.from_existing({**EXISTING_ANSWERS, "label": "Prod"}) + assert spec == DatabaseSpec( + id=spec.id, + engine="postgresql", + name="Prod", + host="db.example", + port=1234, + database="app", + username="u", + password="p", + ) + assert PG.from_existing(EXISTING_ANSWERS).name == "External DB" + + +def compose_service(render_engine): + rendered = render_engine("postgresql") + assert rendered.service == { + "image": "postgres:17-alpine", + "restart": "unless-stopped", + "networks": ["portabase"], + "ports": [f"{rendered.var('PORT')}:5432"], + "volumes": [f"{rendered.spec.host}-data:/var/lib/postgresql/data"], + "environment": [ + f"POSTGRES_DB={rendered.var('DB')}", + f"POSTGRES_USER={rendered.var('USER')}", + f"POSTGRES_PASSWORD={rendered.var('PASS')}", + ], + "healthcheck": { + "test": [ + "CMD-SHELL", + f"pg_isready -U {rendered.var('USER')} -d {rendered.var('DB')}", + ], + **HEALTH, + }, + } + + +def compose_service_inline(render_engine): + rendered = render_engine("postgresql", inline=True) + assert rendered.service["ports"] == ["40000:5432"] + assert rendered.service["environment"] == [ + f"POSTGRES_DB={rendered.spec.database}", + "POSTGRES_USER=admin", + f"POSTGRES_PASSWORD={rendered.spec.password}", + ] + + +def only_non_default_options_reach_the_agent(ports): + options = {"keep_ownership": True, "clean_mode": "clean", "unknown": 1} + spec = PG.generate(auth=True, ports=ports, answers={"options": options}) + assert spec.options == options + assert PG.non_default_options(spec) == {"keep_ownership": True} + assert PG.agent_entry(spec)["options"] == {"keep_ownership": True} + assert "options" not in PG.agent_entry(spec.with_options({"clean_mode": "clean"})) diff --git a/tests/engines/postgresql_cluster.py b/tests/engines/postgresql_cluster.py new file mode 100644 index 0000000..930616f --- /dev/null +++ b/tests/engines/postgresql_cluster.py @@ -0,0 +1,135 @@ +import re + +from core.specs import DatabaseSpec +from engines import registry +from engines.postgresql import PostgresClusterEngine +from tests.support import EXISTING_ANSWERS, field_specs + +CLUSTER = registry.get("postgresql-cluster") +HEALTH = {"interval": "10s", "timeout": "5s", "retries": 5} + + +def attributes(): + assert type(CLUSTER) is PostgresClusterEngine + assert (CLUSTER.key, CLUSTER.display, CLUSTER.default_port) == ( + "postgresql-cluster", + "PostgreSQL Cluster", + 5432, + ) + assert CLUSTER.template == "engines/postgresql-cluster.yml.j2" + assert (CLUSTER.auth_variants, CLUSTER.has_modes) == (False, True) + assert "superuser" in (CLUSTER.warning or "") + assert "pg_dumpall" in (CLUSTER.warning or "") + + +def generate(ports): + spec = CLUSTER.generate(auth=True, ports=ports, answers={}) + assert re.fullmatch(r"db-pg-[0-9a-f]{4}", spec.host or "") + assert re.fullmatch(r"pg_[0-9a-f]{8}", spec.database or "") + assert len(spec.password or "") == 16 + assert spec == DatabaseSpec( + id=spec.id, + engine="postgresql-cluster", + name=spec.database or "", + managed=True, + host=spec.host, + port=5432, + host_port=40000, + database=spec.database, + username="admin", + password=spec.password, + ) + assert CLUSTER.describe(spec) == f"{spec.host}:5432" + + +def env_vars(ports): + spec = CLUSTER.generate(auth=True, ports=ports, answers={}) + prefix = spec.env_prefix + assert CLUSTER.env_vars(spec) == { + f"{prefix}_PORT": "40000", + f"{prefix}_DB": spec.database, + f"{prefix}_USER": "admin", + f"{prefix}_PASS": spec.password, + } + + +def agent_entry(ports): + spec = CLUSTER.generate(auth=True, ports=ports, answers={}) + assert CLUSTER.agent_entry(spec) == { + "name": spec.name, + "database": spec.database, + "type": "postgresql-cluster", + "username": "admin", + "password": spec.password, + "port": 5432, + "host": spec.host, + "generated_id": spec.id, + } + + +def fields(): + assert field_specs(CLUSTER.fields_existing()) == [ + ("host", "text", "localhost"), + ("port", "int", 5432), + ("database", "text", None), + ("username", "text", None), + ("password", "secret", None), + ] + assert CLUSTER.fields_new() == [] + assert CLUSTER.option_fields() == [] + + +def from_existing(): + spec = CLUSTER.from_existing({**EXISTING_ANSWERS, "label": "Prod"}) + assert spec == DatabaseSpec( + id=spec.id, + engine="postgresql-cluster", + name="Prod", + host="db.example", + port=1234, + database="app", + username="u", + password="p", + ) + assert CLUSTER.from_existing(EXISTING_ANSWERS).name == "External DB" + + +def compose_service(render_engine): + rendered = render_engine("postgresql-cluster") + assert rendered.service == { + "image": "postgres:17-alpine", + "restart": "unless-stopped", + "networks": ["portabase"], + "ports": [f"{rendered.var('PORT')}:5432"], + "volumes": [f"{rendered.spec.host}-data:/var/lib/postgresql/data"], + "environment": [ + f"POSTGRES_DB={rendered.var('DB')}", + f"POSTGRES_USER={rendered.var('USER')}", + f"POSTGRES_PASSWORD={rendered.var('PASS')}", + ], + "healthcheck": { + "test": [ + "CMD-SHELL", + f"pg_isready -U {rendered.var('USER')} -d {rendered.var('DB')}", + ], + **HEALTH, + }, + } + + +def compose_service_inline(render_engine): + rendered = render_engine("postgresql-cluster", inline=True) + assert rendered.service["ports"] == ["40000:5432"] + assert rendered.service["environment"] == [ + f"POSTGRES_DB={rendered.spec.database}", + "POSTGRES_USER=admin", + f"POSTGRES_PASSWORD={rendered.spec.password}", + ] + + +def options_are_dropped(ports): + spec = CLUSTER.generate( + auth=True, ports=ports, answers={"options": {"keep_ownership": True}} + ) + assert CLUSTER.non_default_options(spec) == {} + assert "options" not in CLUSTER.agent_entry(spec) diff --git a/tests/engines/redis.py b/tests/engines/redis.py new file mode 100644 index 0000000..a8a053a --- /dev/null +++ b/tests/engines/redis.py @@ -0,0 +1,143 @@ +import dataclasses +import re + +import pytest + +from core.specs import DatabaseSpec +from engines import registry +from engines.redis import RedisEngine +from tests.support import EXISTING_ANSWERS, field_specs + +REDIS = registry.get("redis") +HEALTH = {"interval": "10s", "timeout": "5s", "retries": 5} +AUTH = pytest.mark.parametrize("auth", [True, False], ids=["auth", "noauth"]) + + +def attributes(): + assert type(REDIS) is RedisEngine + assert (REDIS.key, REDIS.display, REDIS.default_port) == ("redis", "Redis", 6379) + assert REDIS.template == "engines/redis.yml.j2" + assert (REDIS.auth_variants, REDIS.has_modes, REDIS.warning) == (True, True, None) + + +@AUTH +def generate(ports, auth): + spec = REDIS.generate(auth=auth, ports=ports, answers={}) + suffix = "auth-" if auth else "" + assert re.fullmatch(rf"db-redis-{suffix}[0-9a-f]{{4}}", spec.host or "") + assert re.fullmatch(r"redis_[0-9a-f]{8}", spec.name) + assert len(spec.password or "") == (16 if auth else 0) + assert spec == DatabaseSpec( + id=spec.id, + engine="redis", + name=spec.name, + managed=True, + host=spec.host, + port=6379, + host_port=40000, + database="0", + username="", + password=spec.password, + ) + assert REDIS.describe(spec) == f"{spec.host}:6379" + + +@AUTH +def env_vars(ports, auth): + spec = REDIS.generate(auth=auth, ports=ports, answers={}) + prefix = spec.env_prefix + expected = {f"{prefix}_PORT": "40000"} + if auth: + expected[f"{prefix}_PASS"] = spec.password or "" + assert REDIS.env_vars(spec) == expected + + +@AUTH +def agent_entry(ports, auth): + spec = REDIS.generate(auth=auth, ports=ports, answers={}) + assert REDIS.agent_entry(spec) == { + "name": spec.name, + "database": "0", + "type": "redis", + "username": "", + "password": spec.password or "", + "port": 6379, + "host": spec.host, + "generated_id": spec.id, + } + + +def fields(): + assert field_specs(REDIS.fields_existing()) == [ + ("host", "text", "localhost"), + ("port", "int", 6379), + ("database", "text", "0"), + ("username", "text", ""), + ("password", "text", ""), + ] + assert REDIS.fields_new() == [] + assert REDIS.option_fields() == [] + + +def from_existing(): + spec = REDIS.from_existing({**EXISTING_ANSWERS, "label": "Prod"}) + assert spec == DatabaseSpec( + id=spec.id, + engine="redis", + name="Prod", + host="db.example", + port=1234, + database="app", + username="u", + password="p", + ) + assert REDIS.from_existing(EXISTING_ANSWERS).name == "External DB" + + +@AUTH +def compose_service(render_engine, auth): + rendered = render_engine("redis", auth=auth) + password = rendered.var("PASS") + expected = { + "image": "redis:latest", + "restart": "unless-stopped", + "ports": [f"{rendered.var('PORT')}:6379"], + "volumes": [f"{rendered.spec.host}-data:/data"], + "command": ["redis-server", "--appendonly", "yes"], + "networks": ["portabase", "default"], + "healthcheck": {"test": ["CMD-SHELL", "redis-cli ping | grep PONG"], **HEALTH}, + } + if auth: + expected["environment"] = [f"REDIS_PASSWORD={password}"] + expected["command"] = [ + "redis-server", + "--requirepass", + password, + "--appendonly", + "yes", + ] + expected["healthcheck"] = { + "test": ["CMD-SHELL", f"redis-cli -a {password} ping | grep PONG"], + **HEALTH, + } + assert rendered.service == expected + + +def compose_service_inline(render_engine): + rendered = render_engine("redis", auth=True, inline=True) + password = rendered.spec.password + assert rendered.service["ports"] == ["40000:6379"] + assert rendered.service["environment"] == [f"REDIS_PASSWORD={password}"] + assert rendered.service["command"] == [ + "redis-server", + "--requirepass", + password, + "--appendonly", + "yes", + ] + + +def agent_database_defaults_to_index_zero(ports): + spec = REDIS.generate(auth=False, ports=ports, answers={}) + assert REDIS.agent_database(dataclasses.replace(spec, database=None)) == "0" + assert REDIS.agent_database(dataclasses.replace(spec, database="3")) == "3" diff --git a/tests/engines/registry.py b/tests/engines/registry.py new file mode 100644 index 0000000..0a27e17 --- /dev/null +++ b/tests/engines/registry.py @@ -0,0 +1,47 @@ +import pytest + +from core.errors import ValidationError +from engines import ALL, EngineRegistry, registry +from engines.postgresql import PostgresEngine + +EXPECTED = [ + "postgresql", + "postgresql-cluster", + "mysql", + "mariadb", + "sqlite", + "firebird", + "mongodb", + "redis", + "valkey", + "mssql", + "docker-volume", +] + + +def keys_in_order(): + assert registry.keys() == EXPECTED + assert registry.choices() == EXPECTED + assert [engine.key for engine in registry] == EXPECTED + assert "redis" in registry + assert "nope" not in registry + + +def get_engine(): + assert isinstance(registry.get("postgresql"), PostgresEngine) + with pytest.raises(ValidationError, match="Unknown engine 'nope'") as exc: + registry.get("nope") + assert "postgresql" in (exc.value.hint or "") + + +def duplicate_keys_are_refused(): + with pytest.raises(ValueError, match="Duplicate engine key: postgresql"): + EngineRegistry([*ALL, PostgresEngine()]) + + +def templates_match_engines(templates): + shipped = {name for name in templates.names() if name.startswith("engines/")} + used = {engine.template for engine in registry if engine.template is not None} + assert used == shipped + for name in used: + templates.get(name) diff --git a/tests/engines/sqlite.py b/tests/engines/sqlite.py new file mode 100644 index 0000000..4abc8aa --- /dev/null +++ b/tests/engines/sqlite.py @@ -0,0 +1,108 @@ +import pytest + +from core.specs import DatabaseSpec +from engines import registry +from engines.sqlite import SqliteEngine +from tests.support import agent_service, field_specs + +SQLITE = registry.get("sqlite") + + +def attributes(): + assert type(SQLITE) is SqliteEngine + assert (SQLITE.key, SQLITE.display, SQLITE.default_port) == ( + "sqlite", + "SQLite", + None, + ) + assert SQLITE.template is None + assert (SQLITE.auth_variants, SQLITE.has_modes, SQLITE.warning) == ( + False, + True, + None, + ) + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + (None, "local.sqlite"), + ("", "local.sqlite"), + ("app", "app.sqlite"), + ("app.sqlite", "app.sqlite"), + ], +) +def generate(ports, name, expected): + spec = SQLITE.generate(auth=False, ports=ports, answers={"name": name}) + assert spec == DatabaseSpec( + id=spec.id, + engine="sqlite", + name=expected, + path=expected, + database=f"/config/{expected}", + ) + assert SQLITE.describe(spec) == "Local File" + + +def env_vars(ports): + spec = SQLITE.generate(auth=False, ports=ports, answers={"name": "app"}) + assert SQLITE.env_vars(spec) == {} + + +def agent_entry(ports): + spec = SQLITE.generate(auth=False, ports=ports, answers={"name": "app"}) + assert SQLITE.agent_entry(spec) == { + "name": "app.sqlite", + "database": "/config/app.sqlite", + "type": "sqlite", + "generated_id": spec.id, + } + + +def fields(): + assert field_specs(SQLITE.fields_existing()) == [("path", "text", None)] + assert field_specs(SQLITE.fields_new()) == [("name", "text", "local")] + assert SQLITE.option_fields() == [] + + +@pytest.mark.parametrize( + ("path", "database"), + [("data/app.db", "/config/data/app.db"), ("/srv/app.db", "/srv/app.db")], + ids=["relative", "absolute"], +) +def from_existing(path, database): + spec = SQLITE.from_existing({"path": path, "label": "Prod"}) + assert spec == DatabaseSpec( + id=spec.id, engine="sqlite", name="Prod", path=path, database=database + ) + assert SQLITE.from_existing({"path": path}).name == "External DB" + + +def compose_service(render_engine): + rendered = render_engine("sqlite", answers={"name": "app"}) + assert rendered.doc["services"] == { + "agent": agent_service("./app.sqlite:/config/app.sqlite") + } + assert "volumes" not in rendered.doc + assert rendered.databases == [SQLITE.agent_entry(rendered.spec)] + + +def compose_service_inline(render_engine): + rendered = render_engine("sqlite", inline=True, answers={"name": "app"}) + assert rendered.doc["services"] == { + "agent": agent_service("./app.sqlite:/config/app.sqlite", inline=True) + } + + +@pytest.mark.parametrize( + ("database", "mount"), + [ + ("/config/app.sqlite", ("./app.sqlite", "/config/app.sqlite")), + ("/config/data/app.db", ("./data/app.db", "/config/data/app.db")), + ("/srv/app.db", None), + (None, None), + ], +) +def mount_for(database, mount): + spec = DatabaseSpec(id="1", engine="sqlite", name="n", database=database) + assert SqliteEngine.mount_for(spec) == mount diff --git a/tests/engines/valkey.py b/tests/engines/valkey.py new file mode 100644 index 0000000..cbdec5c --- /dev/null +++ b/tests/engines/valkey.py @@ -0,0 +1,141 @@ +import dataclasses +import re + +import pytest + +from core.specs import DatabaseSpec +from engines import registry +from engines.valkey import ValkeyEngine +from tests.support import EXISTING_ANSWERS, field_specs + +VALKEY = registry.get("valkey") +HEALTH = {"interval": "10s", "timeout": "5s", "retries": 5} +AUTH = pytest.mark.parametrize("auth", [True, False], ids=["auth", "noauth"]) + + +def attributes(): + assert type(VALKEY) is ValkeyEngine + assert (VALKEY.key, VALKEY.display, VALKEY.default_port) == ( + "valkey", + "Valkey", + 6379, + ) + assert VALKEY.template == "engines/valkey.yml.j2" + assert (VALKEY.auth_variants, VALKEY.has_modes, VALKEY.warning) == ( + True, + True, + None, + ) + + +@AUTH +def generate(ports, auth): + spec = VALKEY.generate(auth=auth, ports=ports, answers={}) + suffix = "auth-" if auth else "" + assert re.fullmatch(rf"db-valkey-{suffix}[0-9a-f]{{4}}", spec.host or "") + assert re.fullmatch(r"valkey_[0-9a-f]{8}", spec.name) + assert len(spec.password or "") == (16 if auth else 0) + assert spec == DatabaseSpec( + id=spec.id, + engine="valkey", + name=spec.name, + managed=True, + host=spec.host, + port=6379, + host_port=40000, + database="0", + username="", + password=spec.password, + ) + assert VALKEY.describe(spec) == f"{spec.host}:6379" + + +@AUTH +def env_vars(ports, auth): + spec = VALKEY.generate(auth=auth, ports=ports, answers={}) + prefix = spec.env_prefix + expected = {f"{prefix}_PORT": "40000"} + if auth: + expected[f"{prefix}_PASS"] = spec.password or "" + assert VALKEY.env_vars(spec) == expected + + +@AUTH +def agent_entry(ports, auth): + spec = VALKEY.generate(auth=auth, ports=ports, answers={}) + assert VALKEY.agent_entry(spec) == { + "name": spec.name, + "database": "0", + "type": "valkey", + "username": "", + "password": spec.password or "", + "port": 6379, + "host": spec.host, + "generated_id": spec.id, + } + + +def fields(): + assert field_specs(VALKEY.fields_existing()) == [ + ("host", "text", "localhost"), + ("port", "int", 6379), + ("database", "text", "0"), + ("username", "text", ""), + ("password", "text", ""), + ] + assert VALKEY.fields_new() == [] + assert VALKEY.option_fields() == [] + + +def from_existing(): + spec = VALKEY.from_existing({**EXISTING_ANSWERS, "label": "Prod"}) + assert spec == DatabaseSpec( + id=spec.id, + engine="valkey", + name="Prod", + host="db.example", + port=1234, + database="app", + username="u", + password="p", + ) + assert VALKEY.from_existing(EXISTING_ANSWERS).name == "External DB" + + +@AUTH +def compose_service(render_engine, auth): + rendered = render_engine("valkey", auth=auth) + password = rendered.var("PASS") + expected = { + "image": "valkey/valkey:latest", + "restart": "unless-stopped", + "environment": ["ALLOW_EMPTY_PASSWORD=yes"], + "ports": [f"{rendered.var('PORT')}:6379"], + "volumes": [f"{rendered.spec.host}-data:/data"], + "networks": ["portabase", "default"], + "healthcheck": {"test": ["CMD-SHELL", "valkey-cli ping | grep PONG"], **HEALTH}, + } + if auth: + del expected["environment"] + expected["command"] = ["valkey-server", "--requirepass", password] + expected["healthcheck"] = { + "test": ["CMD-SHELL", f"valkey-cli -a {password} ping | grep PONG"], + **HEALTH, + } + assert rendered.service == expected + + +def compose_service_inline(render_engine): + rendered = render_engine("valkey", auth=True, inline=True) + assert rendered.service["ports"] == ["40000:6379"] + assert rendered.service["command"] == [ + "valkey-server", + "--requirepass", + rendered.spec.password, + ] + + +def agent_database_defaults_to_index_zero(ports): + spec = VALKEY.generate(auth=False, ports=ports, answers={}) + assert VALKEY.agent_database(dataclasses.replace(spec, database=None)) == "0" + assert VALKEY.agent_database(dataclasses.replace(spec, database="3")) == "3" diff --git a/tests/services/__init__.py b/tests/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/services/auth_providers.py b/tests/services/auth_providers.py new file mode 100644 index 0000000..8e0d8ed --- /dev/null +++ b/tests/services/auth_providers.py @@ -0,0 +1,49 @@ +import pytest + +from core.errors import ValidationError +from services import auth_providers as ap + + +@pytest.mark.parametrize( + ("kind", "provider_id", "expected"), + [ + ("oidc", "keycloak", "AUTH_OIDC_KEYCLOAK"), + ("oidc", "my-kc", "AUTH_OIDC_MY_KC"), + ("oauth", "github", "AUTH_SOCIAL_GITHUB"), + ], +) +def provider_prefix_cases(kind, provider_id, expected): + assert ap.provider_prefix(kind, provider_id) == expected + + +@pytest.mark.parametrize( + ("kind", "provider_id", "expected"), + [ + ("oidc", " KeyCloak ", "keycloak"), + ("oidc", "my-kc-2", "my-kc-2"), + ("oauth", "GitHub", "github"), + ], +) +def validate_provider_id_accepts(kind, provider_id, expected): + assert ap.validate_provider_id(kind, provider_id) == expected + + +@pytest.mark.parametrize("provider_id", ["", "-kc", "k_c", "k c", "kc!"]) +def validate_provider_id_rejects_bad_ids(provider_id): + with pytest.raises(ValidationError, match="Invalid provider id"): + ap.validate_provider_id("oidc", provider_id) + + +def validate_provider_id_rejects_unknown_oauth(): + with pytest.raises(ValidationError, match="Unknown OAuth provider 'gitlab'") as exc: + ap.validate_provider_id("oauth", "gitlab") + assert "github" in (exc.value.hint or "") + + +@pytest.mark.parametrize( + ("fields", "env"), + [(ap.OIDC_FIELDS, ap.OIDC_ENV), (ap.OAUTH_FIELDS, ap.OAUTH_ENV)], + ids=["oidc", "oauth"], +) +def every_field_has_an_env_suffix(fields, env): + assert [field.name for field in fields] == list(env) diff --git a/tests/services/compose_facts.py b/tests/services/compose_facts.py new file mode 100644 index 0000000..c1d23a7 --- /dev/null +++ b/tests/services/compose_facts.py @@ -0,0 +1,76 @@ +import pytest + +from services.compose_facts import ( + CA_BUNDLE_IN_CONTAINER, + GENERATED_MARKER, + ComposeFacts, +) + + +def _facts(tmp_path, text): + path = tmp_path / "docker-compose.yml" + path.write_text(text, encoding="utf-8") + return ComposeFacts(path) + + +def missing_file(tmp_path): + facts = ComposeFacts(tmp_path / "docker-compose.yml") + assert not facts.exists + assert not facts.is_generated + assert not facts.host_gateway + assert facts.ca_bundle is None + + +def generated_marker(tmp_path): + header = f"{GENERATED_MARKER} 1.0. Do not edit.\nservices: {{}}\n" + assert _facts(tmp_path, header).is_generated + assert not _facts(tmp_path, "services: {}\n").is_generated + + +@pytest.mark.parametrize( + "text", + [ + "services:\n agent:\n extra_hosts:\n - localhost:host-gateway\n", + "services:\n agent:\n extra_hosts:\n localhost: host-gateway\n", + ], + ids=["list", "dict"], +) +def host_gateway_detected(tmp_path, text): + assert _facts(tmp_path, text).host_gateway + + +@pytest.mark.parametrize( + "text", + [ + "services:\n agent:\n image: x\n", + "services:\n other:\n extra_hosts: ['localhost:host-gateway']\n", + "services: []\n", + "- not a mapping\n", + "services: [unclosed\n", + ], +) +def host_gateway_absent(tmp_path, text): + assert not _facts(tmp_path, text).host_gateway + + +def ca_bundle_short_syntax(tmp_path): + text = ( + "services:\n agent:\n volumes:\n" + " - ./databases.json:/config/config.json\n" + f" - ./ca.crt:{CA_BUNDLE_IN_CONTAINER}:ro\n" + ) + assert _facts(tmp_path, text).ca_bundle == "./ca.crt" + + +def ca_bundle_long_syntax(tmp_path): + text = ( + "services:\n agent:\n volumes:\n" + " - type: bind\n source: /etc/ca.crt\n" + f" target: {CA_BUNDLE_IN_CONTAINER}\n" + ) + assert _facts(tmp_path, text).ca_bundle == "/etc/ca.crt" + + +def ca_bundle_absent(tmp_path): + text = "services:\n agent:\n volumes:\n - ./db.json:/config/config.json\n" + assert _facts(tmp_path, text).ca_bundle is None diff --git a/tests/services/docker.py b/tests/services/docker.py new file mode 100644 index 0000000..183aa03 --- /dev/null +++ b/tests/services/docker.py @@ -0,0 +1,23 @@ +import pytest + +from core.errors import DockerError +from services import docker +from services.docker import DockerRunner + + +def project_name_is_the_slugified_folder(tmp_path): + folder = tmp_path / "My Agent" + folder.mkdir() + assert DockerRunner.project_name(folder) == "my-agent" + + +def binary_missing(monkeypatch): + monkeypatch.setattr(docker.shutil, "which", lambda _: None) + runner = DockerRunner() + assert not runner.available() + with pytest.raises(DockerError, match="Docker not found"): + _ = runner.binary + + +def binary_explicit(): + assert DockerRunner("/opt/docker").binary == "/opt/docker" diff --git a/tests/services/envfile.py b/tests/services/envfile.py new file mode 100644 index 0000000..aa9fa0c --- /dev/null +++ b/tests/services/envfile.py @@ -0,0 +1,125 @@ +import pytest + +from services.envfile import EnvFile + +SAMPLE = r"""# comment +export A=1 +B = "two words" +C='single # kept' +D=plain # trailing comment +E="esc \"q\" back\\slash" +#F=commented + +G= +""" + + +@pytest.fixture +def env(tmp_path): + path = tmp_path / ".env" + path.write_text(SAMPLE, encoding="utf-8") + return EnvFile.load(path) + + +def missing_file(tmp_path): + env = EnvFile.load(tmp_path / ".env") + assert not env.exists + assert env.as_dict() == {} + assert env.get("A", "default") == "default" + + +def parse(env): + assert env.as_dict() == { + "A": "1", + "B": "two words", + "C": "single # kept", + "D": "plain", + "E": 'esc "q" back\\slash', + "G": "", + } + assert env.get("F") is None + + +def last_duplicate_wins(tmp_path): + path = tmp_path / ".env" + path.write_text("A=1\nA=2\n", encoding="utf-8") + assert EnvFile.load(path).get("A") == "2" + + +@pytest.mark.parametrize( + "value", + [ + "", + "simple", + "with space", + 'quote"inside', + "back\\slash", + "ends\\", + '\\"', + "hash # x", + "a=b", + "'single'", + ], +) +def set_save_load_round_trip(tmp_path, value): + env = EnvFile.load(tmp_path / ".env") + env.set("KEY", value) + env.save() + assert EnvFile.load(tmp_path / ".env").get("KEY") == value + + +def set_existing_key_keeps_position_and_comments(env): + env.set("B", "new") + env.set("Z", "added") + env.save() + lines = env.path.read_text(encoding="utf-8").splitlines() + assert lines[0] == "# comment" + assert lines[2] == 'B="new"' + assert lines[-1] == 'Z="added"' + + +def merge_overrides_and_adds(env): + env.merge({"A": "10", "NEW": "x"}) + assert env.get("A") == "10" + assert env.get("NEW") == "x" + + +def remove_keeps_index_consistent(env): + env.remove("B") + env.remove("missing") + env.set("D", "changed") + assert env.get("B") is None + assert env.get("A") == "1" + assert env.get("C") == "single # kept" + assert env.get("D") == "changed" + env.save() + assert "B =" not in env.path.read_text(encoding="utf-8") + assert EnvFile.load(env.path).get("D") == "changed" + + +def remove_prefix_only_removes_that_prefix(tmp_path): + env = EnvFile.load(tmp_path / ".env") + env.merge( + { + "AUTH_OIDC_KC": "kept", + "AUTH_OIDC_KC_ID": "kc", + "AUTH_OIDC_KC_SECRET": "s", + "AUTH_OIDC_KCX_ID": "kept", + "OTHER": "kept", + } + ) + env.remove_prefix("AUTH_OIDC_KC") + assert env.as_dict() == { + "AUTH_OIDC_KC": "kept", + "AUTH_OIDC_KCX_ID": "kept", + "OTHER": "kept", + } + + +def save_creates_parent_and_ends_with_newline(tmp_path): + env = EnvFile.load(tmp_path / "nested" / ".env") + env.set("A", "1") + env.save() + assert env.exists + assert env.path.read_text(encoding="utf-8") == 'A="1"\n' + assert not (tmp_path / "nested" / ".env.tmp").exists() diff --git a/tests/services/http.py b/tests/services/http.py new file mode 100644 index 0000000..c001919 --- /dev/null +++ b/tests/services/http.py @@ -0,0 +1,147 @@ +import pytest +import requests + +from core.errors import NetworkError +from services.http import HttpClient + + +class _Response: + def __init__(self, *, status=200, text="", json=None, chunks=(), headers=None): + self.status_code = status + self.text = text + self.headers = headers or {} + self._json = json + self._chunks = chunks + + def raise_for_status(self): + if self.status_code >= 400: + raise requests.HTTPError(f"{self.status_code} Error") + + def json(self): + if self._json is None: + raise ValueError("Expecting value") + return self._json + + def iter_content(self, chunk_size): + for chunk in self._chunks: + if isinstance(chunk, Exception): + raise chunk + yield chunk + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +class _Session: + def __init__(self, response=None, error=None): + self.response = response + self.error = error + self.calls = [] + + def _call(self, method, url, kwargs): + self.calls.append((method, url, kwargs)) + if self.error is not None: + raise self.error + return self.response + + def get(self, url, **kwargs): + return self._call("GET", url, kwargs) + + def head(self, url, **kwargs): + return self._call("HEAD", url, kwargs) + + +def _client(**session): + client = HttpClient(timeout=3) + client.session = _Session(**session) + return client + + +def user_agent_header(): + assert HttpClient(user_agent="ua/1").session.headers["User-Agent"] == "ua/1" + + +def get_json_returns_the_payload(): + client = _client(response=_Response(json={"a": 1})) + assert client.get_json("https://x") == {"a": 1} + assert client.session.calls == [("GET", "https://x", {"timeout": 3})] + + +@pytest.mark.parametrize( + ("session", "message"), + [ + ({"response": _Response(status=404)}, "GET https://x failed: 404"), + ({"error": requests.ConnectionError("down")}, "GET https://x failed: down"), + ({"response": _Response(json=None)}, "response is not JSON"), + ], + ids=["http-error", "connection-error", "not-json"], +) +def get_json_failures(session, message): + with pytest.raises(NetworkError, match=message): + _client(**session).get_json("https://x") + + +def get_text_returns_the_body(): + assert _client(response=_Response(text="hello")).get_text("https://x") == "hello" + + +def get_text_failure_has_a_hint(): + with pytest.raises(NetworkError) as exc: + _client(response=_Response(status=500)).get_text("https://x") + assert "internet connection" in (exc.value.hint or "") + + +def status_returns_the_code_without_raising(): + client = _client(response=_Response(status=503)) + assert client.status("https://x") == 503 + assert client.session.calls[0][2] == {"timeout": 3, "stream": True} + + +def status_connection_error(): + with pytest.raises(NetworkError): + _client(error=requests.Timeout("slow")).status("https://x") + + +def download_writes_every_chunk(tmp_path): + client = _client(response=_Response(chunks=[b"ab", b"", b"cde"])) + dest = tmp_path / "file" + progress = [] + assert client.download("https://x", dest, progress.append) == 5 + assert dest.read_bytes() == b"abcde" + assert progress == [2, 3] + assert client.session.calls[0][2] == {"stream": True, "timeout": 30.0} + + +def download_failure_removes_the_partial_file(tmp_path): + chunks = [b"ab", requests.ConnectionError("cut")] + dest = tmp_path / "file" + with pytest.raises(NetworkError, match="Download of https://x failed: cut"): + _client(response=_Response(chunks=chunks)).download("https://x", dest) + assert not dest.exists() + + +def download_http_error(tmp_path): + dest = tmp_path / "file" + with pytest.raises(NetworkError): + _client(response=_Response(status=404)).download("https://x", dest, timeout=5) + assert not dest.exists() + + +@pytest.mark.parametrize( + ("session", "expected"), + [ + ({"response": _Response(headers={"content-length": "42"})}, 42), + ({"response": _Response()}, None), + ({"response": _Response(headers={"content-length": "abc"})}, None), + ({"error": requests.ConnectionError("down")}, None), + ], + ids=["header", "no-header", "bad-header", "error"], +) +def content_length_cases(session, expected): + client = _client(**session) + assert client.content_length("https://x") == expected + assert client.session.calls[0][:2] == ("HEAD", "https://x") + assert client.session.calls[0][2]["allow_redirects"] is True diff --git a/tests/services/ports.py b/tests/services/ports.py new file mode 100644 index 0000000..018835b --- /dev/null +++ b/tests/services/ports.py @@ -0,0 +1,41 @@ +import pytest + +from services import ports +from services.ports import FixedPortAllocator, PortAllocator + + +def port_allocator_returns_distinct_free_ports(): + allocator = PortAllocator() + given = [allocator.free() for _ in range(5)] + assert len(set(given)) == 5 + assert all(1024 <= port <= 65535 for port in given) + + +def port_allocator_gives_up_when_the_os_repeats_a_port(monkeypatch): + class _Socket: + def __init__(self, *args): + pass + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def bind(self, address): + pass + + def getsockname(self): + return ("0.0.0.0", 45678) + + monkeypatch.setattr(ports.socket, "socket", _Socket) + allocator = PortAllocator() + assert allocator.free() == 45678 + with pytest.raises(RuntimeError, match="Could not allocate a free port"): + allocator.free() + + +def fixed_port_allocator_counts_up(): + allocator = FixedPortAllocator() + assert [allocator.free() for _ in range(3)] == [40000, 40001, 40002] + assert FixedPortAllocator(start=5000).free() == 5000 diff --git a/tests/services/project.py b/tests/services/project.py new file mode 100644 index 0000000..5a7c063 --- /dev/null +++ b/tests/services/project.py @@ -0,0 +1,382 @@ +import dataclasses +import json + +import pytest + +from core.errors import ConfigError, ValidationError +from engines import registry +from services.compose_facts import CA_BUNDLE_IN_CONTAINER +from services.envfile import EnvFile +from services.project import ( + AgentProject, + AuthProvider, + DashboardProject, + detect_kind, + spec_from_entry, +) +from tests.support import EXISTING_ANSWERS + +PG = registry.get("postgresql") +FIREBIRD = registry.get("firebird") +SQLITE = registry.get("sqlite") +VOLUME = registry.get("docker-volume") +KEYCLOAK = AuthProvider( + kind="oidc", + id="my-kc", + values={ + "issuer": "https://kc.example", + "client": "c", + "secret": "s", + "title": "", + "pkce": True, + "host": "", + }, +) +GITHUB = AuthProvider( + kind="oauth", + id="github", + values={"client": "gc", "secret": "gs", "title": "GitHub"}, +) + + +def detect_kind_cases(tmp_path): + agent, dashboard, empty = (tmp_path / name for name in ("a", "d", "e")) + for folder in (agent, dashboard, empty): + folder.mkdir() + (agent / "databases.json").write_text("{}", encoding="utf-8") + (dashboard / ".env").write_text("PROJECT_SECRET=s\n", encoding="utf-8") + assert detect_kind(agent) == "agent" + assert detect_kind(dashboard) == "dashboard" + with pytest.raises(ConfigError, match="not a Portabase agent or dashboard"): + detect_kind(empty) + + +def spec_from_entry_managed(tmp_path): + env = EnvFile(tmp_path / ".env") + env.merge({"DB_FB_AB12_PORT": "40001", "DB_FB_AB12_ROOT_PASS": "root"}) + entry = { + "name": "fb", + "database": "/data/mirror.fdb", + "type": "firebird", + "username": "alice", + "password": "pw", + "port": 3050, + "host": "db-fb-ab12", + "generated_id": "id-1", + "options": {"x": 1}, + } + spec = spec_from_entry(entry, env) + assert spec.managed + assert (spec.id, spec.engine, spec.host, spec.port, spec.host_port) == ( + "id-1", + "firebird", + "db-fb-ab12", + 3050, + 40001, + ) + assert (spec.password, spec.root_password) == ("pw", "root") + assert spec.options == {"x": 1} + assert spec.path is None + + +def spec_from_entry_external(tmp_path): + entry = {**EXISTING_ANSWERS, "type": "postgresql", "port": "5432", "password": ""} + spec = spec_from_entry(entry, EnvFile(tmp_path / ".env")) + assert not spec.managed + assert spec.host_port is None + assert spec.port == 5432 + assert spec.password is None + assert spec.id + + +def spec_from_entry_sqlite_and_volume(tmp_path): + env = EnvFile(tmp_path / ".env") + sqlite = spec_from_entry({"type": "sqlite", "database": "/config/app.sqlite"}, env) + volume = spec_from_entry( + {"type": "docker-volume", "volume_name": "v", "container_name": ""}, env + ) + assert (sqlite.path, sqlite.host) == ("/config/app.sqlite", None) + assert (volume.volume, volume.container) == ("v", None) + + +def agent_create_does_not_write_env(agent): + assert agent.path.is_dir() + assert not agent.env.exists + assert agent.env.get("POLLING") == "5" + + +def agent_add_managed_merges_env(agent, ports): + spec = PG.generate(auth=True, ports=ports, answers={}) + agent.add(spec, PG) + assert agent.managed == [spec] + assert agent.env.get(f"{spec.env_prefix}_PORT") == "40000" + assert agent.env.get(f"{spec.env_prefix}_PASS") == spec.password + + +def agent_add_external_leaves_env(agent): + before = agent.env.as_dict() + agent.add(PG.from_existing(EXISTING_ANSWERS), PG) + assert agent.env.as_dict() == before + assert agent.managed == [] + + +def agent_add_refuses_a_duplicate_service_name(agent, ports): + first = PG.generate(auth=True, ports=ports, answers={}) + agent.add(first, PG) + clash = dataclasses.replace( + PG.generate(auth=True, ports=ports, answers={}), host=first.host + ) + with pytest.raises(ConfigError, match="share the service name"): + agent.add(clash, PG) + + +def agent_remove_purges_its_env_prefix(agent, ports): + keep = PG.generate(auth=True, ports=ports, answers={}) + gone = PG.generate(auth=True, ports=ports, answers={}) + agent.add(keep, PG) + agent.add(gone, PG) + agent.remove(gone, PG) + assert agent.databases == [keep] + assert not any(key.startswith(gone.env_prefix) for key in agent.env.as_dict()) + assert agent.env.get(f"{keep.env_prefix}_PORT") == "40000" + assert agent.env.get("EDGE_KEY") == "x" + + +def agent_find(agent, ports): + first = dataclasses.replace( + PG.generate(auth=True, ports=ports, answers={}), id="aaaa-1111", name="main" + ) + second = dataclasses.replace( + PG.generate(auth=True, ports=ports, answers={}), id="aaab-2222", name="other" + ) + agent.add(first, PG) + agent.add(second, PG) + assert agent.find("aaaa-1111") is first + assert agent.find("aaab") is second + assert agent.find("main") is first + with pytest.raises(ValidationError, match="No database matching"): + agent.find("zzz") + with pytest.raises(ValidationError, match="matches several"): + agent.find("aaa") + + +def agent_settings(agent): + assert agent.setting("polling") == 5 + assert agent.setting("host_gateway") is False + assert agent.setting("retry_attempts") is None + agent.set("polling", 30) + agent.set("host_gateway", True) + agent.set("retry_attempts", 3) + assert agent.env.get("POLLING") == "30" + assert agent.host_gateway is True + assert agent.extra_env == ["RETRY_ATTEMPTS"] + assert set(agent.settings()) == set(agent.registry.names()) + agent.unset("retry_attempts") + assert agent.env.get("RETRY_ATTEMPTS") is None + assert agent.extra_env == [] + + +@pytest.mark.parametrize("name", ["key", "tz", "polling", "log_level", "host_gateway"]) +def agent_core_settings_cannot_be_unset(agent, name): + with pytest.raises(ValidationError, match="cannot be unset"): + agent.unset(name) + + +def agent_ca_bundle(agent): + (agent.path / "ca.crt").write_text("pem", encoding="utf-8") + agent.ca_bundle = "ca.crt" + agent.validate() + assert agent.env.get("SSL_CERT_FILE") == CA_BUNDLE_IN_CONTAINER + agent.ca_bundle = None + assert agent.ca_bundle is None + assert agent.env.get("SSL_CERT_FILE") is None + + +def agent_missing_ca_bundle(agent): + agent.ca_bundle = "missing.crt" + with pytest.raises(ValidationError, match="CA bundle not found"): + agent.save_state() + assert not agent.env.exists + + +def agent_docker_socket_and_sqlite_mounts(agent, ports): + assert not agent.needs_docker_socket + agent.add(VOLUME.from_existing({"volume": "v"}), VOLUME) + for _ in range(2): + agent.add( + SQLITE.generate(auth=False, ports=ports, answers={"name": "app"}), SQLITE + ) + agent.add(SQLITE.from_existing({"path": "/abs/app.db"}), SQLITE) + assert agent.needs_docker_socket + assert agent.sqlite_mounts == [("./app.sqlite", "/config/app.sqlite")] + + +def agent_load_round_trip(agent, ports, renderer): + spec = FIREBIRD.generate(auth=True, ports=ports, answers={}) + agent.add(spec, FIREBIRD) + agent.add(VOLUME.from_existing({"volume": "v", "container": "c"}), VOLUME) + agent.host_gateway = True + (agent.path / "ca.crt").write_text("pem", encoding="utf-8") + agent.ca_bundle = "./ca.crt" + renderer.render_agent(agent).write(agent.path) + agent.save_state() + + loaded = AgentProject.load(agent.path) + assert loaded.host_gateway + assert loaded.ca_bundle == "./ca.crt" + assert loaded.env.as_dict() == agent.env.as_dict() + firebird, volume = loaded.databases + assert firebird.managed + assert (firebird.id, firebird.host, firebird.host_port) == ( + spec.id, + spec.host, + spec.host_port, + ) + assert (firebird.password, firebird.root_password) == ( + spec.password, + spec.root_password, + ) + assert (volume.volume, volume.container) == ("v", "c") + + +def agent_load_rejects_non_agent_folders(tmp_path): + with pytest.raises(ConfigError, match="Not a Portabase agent folder"): + AgentProject.load(tmp_path) + (tmp_path / ".env").write_text("", encoding="utf-8") + (tmp_path / "databases.json").write_text("{oops", encoding="utf-8") + with pytest.raises(ConfigError, match="not valid JSON"): + AgentProject.load(tmp_path) + + +def agent_load_skips_odd_entries(tmp_path): + (tmp_path / ".env").write_text("", encoding="utf-8") + entries = {"databases": ["junk", {"type": "sqlite", "database": "/config/a"}]} + (tmp_path / "databases.json").write_text(json.dumps(entries), encoding="utf-8") + assert [database.engine for database in AgentProject.load(tmp_path).databases] == [ + "sqlite" + ] + + +def dashboard_load(dashboard, tmp_path): + dashboard.env.save() + loaded = DashboardProject.load(dashboard.path) + assert loaded.env.as_dict() == dashboard.env.as_dict() + with pytest.raises(ConfigError, match="Not a Portabase dashboard folder"): + DashboardProject.load(tmp_path) + + +@pytest.mark.parametrize( + ("host", "mode"), [(None, "internal"), ("db", "external"), ("pg.example", "custom")] +) +def dashboard_db_mode(dashboard, host, mode): + if host: + dashboard.env.set("POSTGRES_HOST", host) + assert dashboard.db_mode == mode + + +def dashboard_project_name(dashboard): + assert dashboard.project_name == "pb" + dashboard.env.remove("PROJECT_NAME") + assert dashboard.project_name == dashboard.path.name + + +def dashboard_settings(dashboard): + assert dashboard.setting("password_auth") is True + assert dashboard.setting("api") is False + assert dashboard.setting("url") == "https://d.example" + dashboard.set("api", True) + assert dashboard.env.get("API_ENABLED") == "true" + dashboard.unset("api") + assert dashboard.env.get("API_ENABLED") is None + + +def dashboard_add_providers(dashboard): + dashboard.add_provider(KEYCLOAK) + dashboard.add_provider(GITHUB) + env = dashboard.env.as_dict() + assert {key: value for key, value in env.items() if key.startswith("AUTH_")} == { + "AUTH_OIDC_MY_KC_ID": "my-kc", + "AUTH_OIDC_MY_KC_ISSUER_URL": "https://kc.example", + "AUTH_OIDC_MY_KC_CLIENT": "c", + "AUTH_OIDC_MY_KC_SECRET": "s", + "AUTH_OIDC_MY_KC_PKCE": "true", + "AUTH_SOCIAL_GITHUB_CLIENT": "gc", + "AUTH_SOCIAL_GITHUB_SECRET": "gs", + "AUTH_SOCIAL_GITHUB_TITLE": "GitHub", + } + assert dashboard.providers == [ + AuthProvider( + "oauth", "github", {"client": "gc", "secret": "gs", "title": "GitHub"} + ), + AuthProvider( + "oidc", + "my-kc", + { + "issuer": "https://kc.example", + "client": "c", + "secret": "s", + "pkce": "true", + }, + ), + ] + + +def dashboard_recovers_oidc_id_without_id_variable(dashboard): + dashboard.env.merge( + {"AUTH_OIDC_AZURE_AD_CLIENT": "c", "AUTH_OIDC_AZURE_AD_SECRET": "s"} + ) + assert [provider.id for provider in dashboard.providers] == ["azure-ad"] + + +def dashboard_add_duplicate_provider(dashboard): + dashboard.add_provider(GITHUB) + with pytest.raises(ValidationError, match="already exists"): + dashboard.add_provider(GITHUB) + + +def dashboard_remove_provider(dashboard): + dashboard.add_provider(KEYCLOAK) + dashboard.add_provider(GITHUB) + assert dashboard.remove_provider("my-kc").kind == "oidc" + assert [provider.id for provider in dashboard.providers] == ["github"] + assert not any(key.startswith("AUTH_OIDC_") for key in dashboard.env.as_dict()) + with pytest.raises(ValidationError, match="No provider named"): + dashboard.remove_provider("my-kc") + + +def dashboard_callback_url(dashboard): + assert ( + dashboard.callback_url("my-kc") + == "https://d.example/api/auth/sso/callback/my-kc" + ) + + +def dashboard_defaults_are_valid(dashboard): + dashboard.validate() + + +def dashboard_skip_onboarding_needs_an_account(dashboard): + dashboard.set("skip_onboarding", True) + dashboard.set("admin_email", "a@b.c") + with pytest.raises(ValidationError, match="needs an initial account"): + dashboard.validate() + dashboard.set("admin_password", "Abcdef1!") + dashboard.validate() + + +def dashboard_password_auth_off_needs_a_provider(dashboard): + dashboard.set("password_auth", False) + with pytest.raises(ValidationError, match="lock everyone out"): + dashboard.save_state() + assert not dashboard.env.exists + dashboard.add_provider(GITHUB) + dashboard.save_state() + assert dashboard.env.exists + + +@pytest.mark.parametrize("url", ["http://localhost:8887", "http://127.0.0.1"]) +def dashboard_providers_need_a_public_url(dashboard, url): + dashboard.add_provider(GITHUB) + dashboard.set("url", url) + with pytest.raises(ValidationError, match="need a public URL"): + dashboard.validate() diff --git a/tests/services/renderer.py b/tests/services/renderer.py new file mode 100644 index 0000000..8ebc363 --- /dev/null +++ b/tests/services/renderer.py @@ -0,0 +1,220 @@ +import dataclasses +import json +import re + +import pytest +import yaml + +from core.errors import TemplateError +from engines import registry +from services.compose_facts import CA_BUNDLE_IN_CONTAINER, GENERATED_MARKER +from services.renderer import LEGACY_BACKUP, RenderResult +from tests.support import agent_service + +TEMPLATED = [engine for engine in registry if engine.template is not None] +VARIANTS = [ + (engine, auth) + for engine in TEMPLATED + for auth in ((True, False) if engine.auth_variants else (True,)) +] +VAR = re.compile(r"\$\{([A-Za-z0-9_]+)\}") +# Values that break an unquoted "- KEY=value" item or a naive double-quoted one. +NASTY = ["abc:", "a: b", "#start", 'q"uote', "back\\slash", "ends\\", "{x}", "[y]"] + + +def _parse(result): + result.validate() + return yaml.safe_load(result.compose) + + +def _assert_vars_defined(compose, env): + missing = set(VAR.findall(compose)) - set(env.as_dict()) + assert not missing, f"compose references undefined variables: {missing}" + + +def _password_values(service): + values = [] + for item in service.get("environment") or []: + assert isinstance(item, str), f"environment item parsed as {item!r}" + key, _, value = item.partition("=") + if key.endswith("PASSWORD") and key != "MYSQL_RANDOM_ROOT_PASSWORD": + values.append(value) + command = service.get("command") + if isinstance(command, list) and "--requirepass" in command: + values.append(command[command.index("--requirepass") + 1]) + return values + + +def header_marks_the_file_as_generated(renderer): + assert renderer.header() == f"{GENERATED_MARKER} test. Do not edit.\n" + + +def agent_without_databases(agent, renderer): + result = renderer.render_agent(agent) + assert _parse(result) == { + "services": {"agent": agent_service()}, + "networks": {"portabase": {"name": "portabase_network", "external": True}}, + } + assert result.databases == [] + + +def agent_inline(agent, renderer): + result = renderer.render_agent(agent, inline=True) + assert _parse(result)["services"] == {"agent": agent_service(inline=True)} + assert "${" not in result.compose + + +def agent_with_every_option(agent, renderer, ports): + sqlite, volume = registry.get("sqlite"), registry.get("docker-volume") + agent.host_gateway = True + agent.add(sqlite.generate(auth=False, ports=ports, answers={"name": "x"}), sqlite) + agent.add(volume.from_existing({"volume": "v"}), volume) + agent.ca_bundle = "./ca.crt" + agent.set("retry_attempts", 3) + result = renderer.render_agent(agent) + expected = agent_service( + "./x.sqlite:/config/x.sqlite", + "/var/run/docker.sock:/var/run/docker.sock", + f"./ca.crt:{CA_BUNDLE_IN_CONTAINER}:ro", + ) + expected["extra_hosts"] = ["localhost:host-gateway"] + expected["environment"]["RETRY_ATTEMPTS"] = "${RETRY_ATTEMPTS}" + expected["environment"]["SSL_CERT_FILE"] = "${SSL_CERT_FILE}" + assert _parse(result)["services"] == {"agent": expected} + assert [entry["type"] for entry in result.databases or []] == [ + "sqlite", + "docker-volume", + ] + _assert_vars_defined(result.compose, agent.env) + + +def agent_with_every_engine(agent, renderer, ports): + specs = [] + for engine, auth in VARIANTS: + spec = engine.generate(auth=auth, ports=ports, answers={}) + agent.add(spec, engine) + specs.append(spec) + result = renderer.render_agent(agent) + doc = _parse(result) + assert set(doc["services"]) == {"agent", *(spec.host for spec in specs)} + assert set(doc["volumes"]) == {f"{spec.host}-data" for spec in specs} + assert result.databases == [ + registry.get(spec.engine).agent_entry(spec) for spec in specs + ] + _assert_vars_defined(result.compose, agent.env) + + +@pytest.mark.parametrize("password", NASTY) +@pytest.mark.parametrize("engine", TEMPLATED, ids=lambda engine: engine.key) +def agent_inline_keeps_passwords_intact(engine, password, agent, renderer, ports): + spec = dataclasses.replace( + engine.generate(auth=True, ports=ports, answers={}), + password=password, + root_password=password if engine.key == "firebird" else None, + ) + agent.add(spec, engine) + doc = _parse(renderer.render_agent(agent, inline=True)) + values = _password_values(doc["services"][spec.host]) + assert values + assert all(value == password for value in values) + + +@pytest.mark.parametrize( + ("mode", "services", "volumes"), + [ + ("external", {"portabase", "db"}, {"postgres-data", "portabase-data"}), + ("internal", {"portabase"}, {"portabase-data"}), + ("custom", {"portabase"}, {"portabase-data"}), + ], +) +def dashboard_modes(dashboard_for, renderer, mode, services, volumes): + project = dashboard_for(mode) + result = renderer.render_dashboard(project) + doc = _parse(result) + assert doc["name"] == "pb" + assert set(doc["services"]) == services + assert set(doc["volumes"]) == volumes + assert ("depends_on" in doc["services"]["portabase"]) is (mode == "external") + assert result.databases is None + _assert_vars_defined(result.compose, project.env) + + +def dashboard_inline(dashboard_for, renderer): + result = renderer.render_dashboard(dashboard_for("external"), inline=True) + doc = _parse(result) + assert "${" not in result.compose + assert doc["services"]["portabase"]["ports"] == ["8887:80"] + assert "POSTGRES_PASSWORD=p" in doc["services"]["db"]["environment"] + + +@pytest.mark.parametrize("password", NASTY) +def dashboard_inline_keeps_passwords_intact(password, dashboard_for, renderer): + project = dashboard_for( + "external", POSTGRES_PASSWORD=password, PROJECT_SECRET=password + ) + doc = _parse(renderer.render_dashboard(project, inline=True)) + assert _password_values(doc["services"]["db"]) == [password] + assert f"PROJECT_SECRET={password}" in doc["services"]["portabase"]["environment"] + + +@pytest.mark.parametrize("compose", ["services: [unclosed", "name: x\n", "- a\n"]) +def validate_rejects_broken_compose(compose): + with pytest.raises(TemplateError): + RenderResult(compose).validate() + + +def write_new_folder(tmp_path): + folder = tmp_path / "new" + result = RenderResult("services: {}\n", databases=[{"type": "sqlite"}]) + report = result.write(folder) + compose, databases = folder / "docker-compose.yml", folder / "databases.json" + assert report.wrote == [compose, databases] + assert report.backed_up is None + assert compose.read_text(encoding="utf-8") == "services: {}\n" + assert json.loads(databases.read_text(encoding="utf-8")) == { + "databases": [{"type": "sqlite"}] + } + assert not list(folder.glob("*.tmp")) + + +def write_skips_databases_for_dashboards(tmp_path): + RenderResult("services: {}\n").write(tmp_path) + assert not (tmp_path / "databases.json").exists() + + +def write_backs_up_a_hand_written_compose_once(tmp_path, renderer): + compose = tmp_path / "docker-compose.yml" + compose.write_text("services: {legacy: {}}\n", encoding="utf-8") + generated = RenderResult(renderer.header() + "services: {}\n") + + report = generated.write(tmp_path) + assert report.backed_up == tmp_path / LEGACY_BACKUP + assert report.backed_up.read_text(encoding="utf-8") == "services: {legacy: {}}\n" + + compose.write_text("services: {edited: {}}\n", encoding="utf-8") + assert generated.write(tmp_path).backed_up is None + backup = (tmp_path / LEGACY_BACKUP).read_text(encoding="utf-8") + assert backup == "services: {legacy: {}}\n" + + +def write_leaves_a_generated_compose_alone(tmp_path, renderer): + generated = RenderResult(renderer.header() + "services: {}\n") + generated.write(tmp_path) + assert generated.write(tmp_path).backed_up is None + assert not (tmp_path / LEGACY_BACKUP).exists() + + +def write_refuses_an_invalid_compose(tmp_path): + with pytest.raises(TemplateError): + RenderResult("nope: 1\n").write(tmp_path) + assert not (tmp_path / "docker-compose.yml").exists() + + +def diff_against_the_current_file(tmp_path): + result = RenderResult("services:\n a: {}\n") + assert "+services:" in result.diff_against(tmp_path) + result.write(tmp_path) + assert result.diff_against(tmp_path) == "" + changed = RenderResult("services:\n b: {}\n").diff_against(tmp_path) + assert "- a: {}" in changed + assert "+ b: {}" in changed diff --git a/tests/services/settings.py b/tests/services/settings.py new file mode 100644 index 0000000..3c5c802 --- /dev/null +++ b/tests/services/settings.py @@ -0,0 +1,144 @@ +import base64 +import json + +import pytest + +from core.errors import ValidationError +from core.fields import Field +from services import settings as cfg +from tests.support import EDGE_KEY_PAYLOAD + + +def _setting(kind, default=None): + return cfg.Setting(Field("x", "X", kind, default=default), "X", "s") + + +def strong_password_accepts(): + assert cfg.strong_password("Abcdef1!") == "Abcdef1!" + + +@pytest.mark.parametrize( + ("value", "missing"), + [ + ("Ab1!", "at least 8 characters"), + ("ABCDEFG1!", "a lowercase letter"), + ("abcdefg1!", "an uppercase letter"), + ("Abcdefgh!", "a digit"), + ("Abcdefgh1", "a special character"), + ], +) +def strong_password_rejects(value, missing): + with pytest.raises(ValidationError) as exc: + cfg.strong_password(value) + assert missing in (exc.value.hint or "") + + +def strong_password_lists_every_missing_rule(): + with pytest.raises(ValidationError) as exc: + cfg.strong_password("") + assert exc.value.hint == ( + "It needs at least 8 characters, a lowercase letter, an uppercase letter, " + "a digit, a special character." + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("https://portabase.example", "https://portabase.example"), + ("https://portabase.example/", "https://portabase.example"), + ("http://localhost:8887", "http://localhost:8887"), + ("https://x.example/sub/", "https://x.example/sub"), + ], +) +def public_url_accepts(value, expected): + assert cfg.public_url(value) == expected + + +@pytest.mark.parametrize( + "value", ["", "portabase.example", "ftp://x", "https://", "https:// x"] +) +def public_url_rejects(value): + with pytest.raises(ValidationError, match="Invalid URL"): + cfg.public_url(value) + + +def edge_key_validator(): + key = base64.b64encode(json.dumps(EDGE_KEY_PAYLOAD).encode()).decode() + assert cfg.edge_key(key) == key + with pytest.raises(ValidationError, match="Invalid Edge Key"): + cfg.edge_key("bad") + + +def positive_validator(): + assert cfg.positive(1) == 1 + for value in (0, -5): + with pytest.raises(ValidationError): + cfg.positive(value) + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("true", True), + ("TRUE", True), + (" yes ", True), + ("1", True), + ("on", True), + ("false", False), + ("0", False), + ("", False), + ], +) +def bool_from_env(raw, expected): + assert _setting("bool").from_env(raw) is expected + + +def bool_to_env(): + assert _setting("bool").to_env(True) == "true" + assert _setting("bool").to_env(False) == "false" + + +@pytest.mark.parametrize( + ("raw", "expected"), [("42", 42), (" 7 ", 7), ("-3", -3), ("abc", "abc")] +) +def int_from_env(raw, expected): + assert _setting("int").from_env(raw) == expected + + +@pytest.mark.parametrize("kind", ["bool", "int", "text"]) +def from_env_none_is_the_default(kind): + assert _setting(kind, default="d").from_env(None) == "d" + + +def text_round_trip(): + assert _setting("text").to_env(5) == "5" + assert _setting("text").from_env(" raw ") == " raw " + + +@pytest.mark.parametrize( + "registry", [cfg.AGENT, cfg.DASHBOARD], ids=["agent", "dashboard"] +) +def registry_is_consistent(registry): + names = [setting.name for setting in registry] + envs = [setting.env for setting in registry if setting.env] + assert len(names) == len(set(names)) + assert len(envs) == len(set(envs)) + assert registry.names() == names + for setting in registry: + assert setting.section in registry.sections + assert registry.get(setting.name) is setting + assert sum(len(registry.in_section(key)) for key in registry.sections) == len(names) + + +def registry_unknown_setting(): + with pytest.raises(ValidationError, match="Unknown setting 'nope'") as exc: + cfg.AGENT.get("nope") + assert "polling" in (exc.value.hint or "") + + +def dashboard_wizard_names_exist(): + for section, names in cfg.DASHBOARD_WIZARD: + assert section in cfg.DASHBOARD.sections + for name in names: + assert cfg.DASHBOARD.get(name).section == section diff --git a/tests/services/telemetry.py b/tests/services/telemetry.py new file mode 100644 index 0000000..ae0a69c --- /dev/null +++ b/tests/services/telemetry.py @@ -0,0 +1,29 @@ +import pytest + +from services.telemetry import NoopTelemetry, Telemetry + + +def telemetry_is_abstract(): + with pytest.raises(TypeError): + Telemetry() + + +def noop_session_and_span_are_context_managers(): + telemetry = NoopTelemetry() + with ( + telemetry.session(command="agent create") as session, + telemetry.span("render", engine="postgresql") as span, + ): + assert (session, span) == (None, None) + + +def noop_span_lets_exceptions_through(): + with pytest.raises(ValueError, match="boom"), NoopTelemetry().span("render"): + raise ValueError("boom") + + +def noop_records_nothing(): + telemetry = NoopTelemetry() + assert telemetry.event("created", engine="redis") is None + assert telemetry.error(ValueError("x"), unexpected=True) is None + assert telemetry.flush() is None diff --git a/tests/services/templates.py b/tests/services/templates.py new file mode 100644 index 0000000..1ba6fd7 --- /dev/null +++ b/tests/services/templates.py @@ -0,0 +1,46 @@ +import pytest + +from core.errors import TemplateError +from services.renderer import ComposeRenderer +from services.templates import TemplateRepository +from tests.support import ROOT + + +def names_are_sorted_and_complete(templates): + names = templates.names() + assert "agent.yml.j2" in names + assert "dashboard.yml.j2" in names + assert "engines/postgresql.yml.j2" in names + assert names == sorted(names) + + +def bundled_defaults_to_repo_templates(monkeypatch): + monkeypatch.delenv("PORTABASE_TEMPLATES_DIR", raising=False) + assert TemplateRepository.bundled().root == ROOT / "templates" + + +def bundled_honours_override(monkeypatch, tmp_path): + monkeypatch.setenv("PORTABASE_TEMPLATES_DIR", str(tmp_path)) + assert TemplateRepository.bundled().root == tmp_path + + +def empty_folder(tmp_path): + with pytest.raises(TemplateError, match="No templates found"): + TemplateRepository(tmp_path).get("agent.yml.j2") + + +def missing_template(templates): + with pytest.raises(TemplateError, match="'engines/nope.yml.j2' is missing"): + templates.get("engines/nope.yml.j2") + + +def broken_template(tmp_path): + (tmp_path / "agent.yml.j2").write_text("services: {}\n", encoding="utf-8") + (tmp_path / "bad.j2").write_text("{% if %}", encoding="utf-8") + with pytest.raises(TemplateError, match="failed to load"): + TemplateRepository(tmp_path).get("bad.j2") + + +def undefined_variable_is_an_error(templates): + with pytest.raises(TemplateError, match="rendering failed"): + ComposeRenderer._render_template(templates.get("agent.yml.j2"), {}) diff --git a/tests/services/updater.py b/tests/services/updater.py new file mode 100644 index 0000000..dc6fc0e --- /dev/null +++ b/tests/services/updater.py @@ -0,0 +1,271 @@ +import hashlib +import json +import sys +import tempfile +import time +from pathlib import Path + +import pytest + +from core.config import GlobalConfig +from core.errors import UpdateError +from core.version import UNKNOWN +from services import updater +from services.updater import ( + RELEASES_URL, + Release, + UpdateChecker, + Updater, + platform_asset_name, +) +from tests.support import FakeHttp + +LATEST = f"{RELEASES_URL}/latest" +PAYLOAD = b"new binary" + + +def _api_release(tag, prerelease=False, assets=()): + return { + "tag_name": tag, + "prerelease": prerelease, + "assets": [ + {"name": name, "browser_download_url": f"https://dl/{name}"} + for name in assets + ], + } + + +def _sha(data=PAYLOAD): + return hashlib.sha256(data).hexdigest() + + +def _published(checksums=None, *, binary=True): + name = platform_asset_name() + assets = {} + http = FakeHttp() + if binary: + assets[name] = f"https://dl/{name}" + http.files[assets[name]] = PAYLOAD + if checksums is not None: + assets["checksums.txt"] = "https://dl/checksums.txt" + http.text["https://dl/checksums.txt"] = checksums + return Updater(http, "1.0.0"), Release("2.0.0", assets, False) + + +@pytest.fixture +def config(tmp_path): + return GlobalConfig(tmp_path / "config.json") + + +@pytest.fixture +def downloads(monkeypatch, tmp_path): + folder = tmp_path / "downloads" + folder.mkdir() + monkeypatch.setattr(tempfile, "tempdir", str(folder)) + return folder + + +def release_from_api(): + data = _api_release("v1.2.3", prerelease=True, assets=["a", "b"]) + assert Release.from_api(data) == Release( + "1.2.3", {"a": "https://dl/a", "b": "https://dl/b"}, True + ) + assert Release.from_api({}) == Release("", {}, False) + + +@pytest.mark.parametrize( + ("system", "machine", "expected"), + [ + ("Linux", "x86_64", "portabase_linux_amd64"), + ("Linux", "aarch64", "portabase_linux_arm64"), + ("Darwin", "arm64", "portabase_macos_arm64"), + ("Darwin", "x86_64", "portabase_macos_amd64"), + ("Windows", "AMD64", "portabase_windows_amd64.exe"), + ], +) +def asset_name_per_platform(monkeypatch, system, machine, expected): + monkeypatch.setattr(updater.platform, "system", lambda: system) + monkeypatch.setattr(updater.platform, "machine", lambda: machine) + assert platform_asset_name() == expected + + +def frozen_flag(monkeypatch): + assert not updater.is_frozen() + monkeypatch.setattr(sys, "frozen", True, raising=False) + assert updater.is_frozen() + + +@pytest.mark.parametrize( + ("channel", "current", "expected"), + [ + (None, "1.0.0", False), + (None, "1.0.0rc1", True), + ("beta", "1.0.0", True), + ("stable", "1.0.0rc1", False), + ], +) +def checker_include_prerelease(config, channel, current, expected): + if channel: + config.set("update_channel", channel) + assert UpdateChecker(FakeHttp(), config, current).include_prerelease is expected + + +def checker_stable_uses_latest(config): + http = FakeHttp(json={LATEST: _api_release("1.1.0")}) + assert UpdateChecker(http, config, "1.0.0").available() == "1.1.0" + assert http.calls == [LATEST] + + +def checker_beta_uses_the_first_release(config): + config.set("update_channel", "beta") + releases = [_api_release("1.1.0rc1", True), _api_release("1.0.0")] + http = FakeHttp(json={RELEASES_URL: releases}) + assert UpdateChecker(http, config, "1.0.0").available() == "1.1.0rc1" + + +def checker_beta_without_releases(config): + config.set("update_channel", "beta") + http = FakeHttp(json={RELEASES_URL: []}) + assert UpdateChecker(http, config, "1.0.0").available() is None + + +@pytest.mark.parametrize("tag", ["1.0.0", "0.9.0", "1.0.0rc1"]) +def checker_nothing_newer(config, tag): + http = FakeHttp(json={LATEST: _api_release(tag)}) + assert UpdateChecker(http, config, "1.0.0").available() is None + + +def checker_unknown_version_never_checks(config): + http = FakeHttp() + assert UpdateChecker(http, config, UNKNOWN).available() is None + assert http.calls == [] + + +def checker_network_error_is_silent(config): + assert UpdateChecker(FakeHttp(), config, "1.0.0").available() is None + assert not (config.cache_dir / "release.json").exists() + + +def checker_caches_the_release(config): + http = FakeHttp(json={LATEST: _api_release("1.1.0")}) + checker = UpdateChecker(http, config, "1.0.0") + assert checker.latest() == Release("1.1.0", {}, False) + assert checker.latest() == Release("1.1.0", {}, False) + assert http.calls == [LATEST] + checker.latest(force=True) + assert http.calls == [LATEST, LATEST] + + +def checker_refetches_an_expired_cache(config): + http = FakeHttp(json={LATEST: _api_release("1.1.0")}) + checker = UpdateChecker(http, config, "1.0.0") + checker.latest() + data = json.loads(checker.cache_file.read_text(encoding="utf-8")) + data["checked_at"] = time.time() - updater.CACHE_TTL - 1 + checker.cache_file.write_text(json.dumps(data), encoding="utf-8") + checker.latest() + assert http.calls == [LATEST, LATEST] + + +def checker_channel_change_invalidates_the_cache(config): + http = FakeHttp( + json={ + LATEST: _api_release("1.1.0"), + RELEASES_URL: [_api_release("1.2.0rc1", True)], + } + ) + checker = UpdateChecker(http, config, "1.0.0") + assert checker.available() == "1.1.0" + config.set("update_channel", "beta") + assert checker.available() == "1.2.0rc1" + + +def checker_survives_a_corrupt_cache(config): + http = FakeHttp(json={LATEST: _api_release("1.1.0")}) + checker = UpdateChecker(http, config, "1.0.0") + checker.cache_file.parent.mkdir(parents=True) + checker.cache_file.write_text("{oops", encoding="utf-8") + assert checker.available() == "1.1.0" + + +@pytest.mark.parametrize("line", ["{sha} {name}", "{sha} *{name}", "{SHA} {name}"]) +def updater_download_verified(downloads, line): + name = platform_asset_name() + listed = line.format(sha=_sha(), SHA=_sha().upper(), name=name) + updater, release = _published(f"deadbeef other\n{listed}\n") + progress = [] + path = updater.download(release, progress.append) + assert path.read_bytes() == PAYLOAD + assert path.parent == downloads + assert progress == [len(PAYLOAD)] + + +@pytest.mark.parametrize( + ("checksums", "message"), + [ + (None, "has no checksums.txt"), + ("abc other_asset\n", "not listed in checksums.txt"), + ("{bad} {name}\n", "Checksum mismatch"), + ], +) +def updater_download_refused(downloads, checksums, message): + if checksums is not None: + checksums = checksums.format(bad=_sha(b"tampered"), name=platform_asset_name()) + updater, release = _published(checksums) + with pytest.raises(UpdateError, match=message): + updater.download(release) + assert list(downloads.iterdir()) == [] + + +def updater_no_binary_for_this_platform(): + updater, release = _published(f"{_sha()} x\n", binary=False) + with pytest.raises(UpdateError, match="No binary for this platform") as exc: + updater.download(release) + assert exc.value.hint == "Available: checksums.txt" + updater, empty = _published(None, binary=False) + with pytest.raises(UpdateError) as exc: + updater.download(empty) + assert exc.value.hint is None + + +def updater_expected_size(): + updater, release = _published(None) + assert updater.expected_size(release) == len(PAYLOAD) + assert updater.expected_size(Release("2.0.0", {}, False)) is None + + +def updater_install_replaces_and_keeps_a_backup(tmp_path): + target = tmp_path / "bin" / "portabase" + target.parent.mkdir() + target.write_bytes(b"old") + tmp = tmp_path / "download" + tmp.write_bytes(PAYLOAD) + Updater(FakeHttp(), "1.0.0").install(tmp, target) + assert target.read_bytes() == PAYLOAD + assert (target.stat().st_mode & 0o777) == 0o755 + assert target.with_name("portabase.old").read_bytes() == b"old" + assert not tmp.exists() + + +def updater_install_fresh(tmp_path): + target = tmp_path / "new" / "bin" / "portabase" + tmp = tmp_path / "download" + tmp.write_bytes(PAYLOAD) + Updater(FakeHttp(), "1.0.0").install(tmp, target) + assert target.read_bytes() == PAYLOAD + assert not target.with_name("portabase.old").exists() + + +def updater_target_path_frozen(monkeypatch, tmp_path): + exe = tmp_path / "portabase" + exe.write_bytes(b"") + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "executable", str(exe)) + assert Updater(FakeHttp(), "1.0.0").target_path() == exe.resolve() + + +def updater_target_path_windows(monkeypatch, tmp_path): + monkeypatch.setattr(updater.platform, "system", lambda: "Windows") + monkeypatch.setenv("APPDATA", str(tmp_path)) + expected = Path(tmp_path) / "Portabase" / "portabase.exe" + assert Updater(FakeHttp(), "1.0.0").target_path() == expected diff --git a/tests/structure.py b/tests/structure.py new file mode 100644 index 0000000..c3c95fd --- /dev/null +++ b/tests/structure.py @@ -0,0 +1,89 @@ +import ast + +from engines import registry +from tests.support import ROOT + +TESTS = ROOT / "tests" +PACKAGES = ("core", "services", "engines") +# Modules whose tests live under another name. +ALIASES = {"engines/__init__.py": "engines/registry.py"} +SHARED = {"conftest.py", "support.py", "structure.py"} +ENGINE_SKELETON = [ + "attributes", + "generate", + "env_vars", + "agent_entry", + "fields", + "from_existing", + "compose_service", + "compose_service_inline", +] + + +def _functions(path): + tree = ast.parse(path.read_text(encoding="utf-8")) + return [ + node.name + for node in tree.body + if isinstance(node, ast.FunctionDef) and not node.name.startswith("_") + ] + + +def every_module_has_a_test_file(): + missing = [] + for package in PACKAGES: + for module in sorted((ROOT / package).glob("*.py")): + rel = f"{package}/{module.name}" + if module.name == "__init__.py" and rel not in ALIASES: + continue + if not (TESTS / ALIASES.get(rel, rel)).exists(): + missing.append(rel) + assert not missing, f"modules without tests: {missing}" + + +def every_engine_has_a_test_file(): + missing = [ + engine.key + for engine in registry + if not (TESTS / "engines" / f"{engine.key.replace('-', '_')}.py").exists() + ] + assert not missing, f"engines without tests/engines/.py: {missing}" + + +def engine_files_follow_the_same_skeleton(): + wrong = {} + for engine in registry: + path = TESTS / "engines" / f"{engine.key.replace('-', '_')}.py" + if not path.exists(): + continue + head = _functions(path)[: len(ENGINE_SKELETON)] + if head != ENGINE_SKELETON: + wrong[path.name] = head + assert not wrong, f"expected {ENGINE_SKELETON} first, got {wrong}" + + +def every_test_file_mirrors_a_module_or_an_engine(): + engines = {engine.key.replace("-", "_") for engine in registry} + stray = [] + for path in sorted(TESTS.rglob("*.py")): + rel = path.relative_to(TESTS).as_posix() + if path.name == "__init__.py" or rel in SHARED: + continue + if (ROOT / rel).exists() or rel in ALIASES.values(): + continue + if path.parent.name == "engines" and path.stem in engines: + continue + stray.append(rel) + assert not stray, f"test files matching no module: {stray}" + + +def tests_have_no_prefix_and_no_classes(): + offenders = [] + for path in sorted(TESTS.rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in tree.body: + if isinstance(node, ast.FunctionDef) and node.name.startswith("test_"): + offenders.append(f"{path.name}::{node.name}") + if isinstance(node, ast.ClassDef) and node.name.startswith("Test"): + offenders.append(f"{path.name}::{node.name}") + assert not offenders, f"use plain names, no test_/Test prefix: {offenders}" diff --git a/tests/support.py b/tests/support.py new file mode 100644 index 0000000..b8df74c --- /dev/null +++ b/tests/support.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import json +import os +import struct +from collections.abc import Callable, Iterable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from core.errors import NetworkError +from core.fields import Field +from core.specs import DatabaseSpec + +ROOT = Path(__file__).resolve().parent.parent + +EDGE_KEY_PAYLOAD = {"serverUrl": "http://x", "agentId": "a", "masterKeyB64": "k"} +AGENT_ENV = {"TZ": "UTC", "EDGE_KEY": "x", "LOG_LEVEL": "info", "POLLING": "5"} +DASHBOARD_BASE = { + "HOST_PORT": "8887", + "PROJECT_SECRET": "s", + "PROJECT_URL": "http://localhost:8887", + "PROJECT_NAME": "pb", + "TZ": "UTC", + "LOG_LEVEL": "info", +} +DASHBOARD_PG = { + "POSTGRES_DB": "pb", + "POSTGRES_USER": "pb", + "POSTGRES_PASSWORD": "p", + "PG_PORT": "5433", + "DATABASE_URL": "postgresql://pb:p@db:5432/pb", +} +DASHBOARD_MODES = { + "external": {**DASHBOARD_BASE, **DASHBOARD_PG, "POSTGRES_HOST": "db"}, + "internal": DASHBOARD_BASE, + "custom": {**DASHBOARD_BASE, **DASHBOARD_PG, "POSTGRES_HOST": "remote"}, +} +EXISTING_ANSWERS = { + "host": "db.example", + "port": "1234", + "database": "app", + "username": "u", + "password": "p", +} + + +@dataclass +class FakeHttp: + json: dict[str, Any] = field(default_factory=dict) + text: dict[str, str] = field(default_factory=dict) + files: dict[str, bytes] = field(default_factory=dict) + calls: list[str] = field(default_factory=list) + + def _lookup(self, table: dict[str, Any], url: str) -> Any: + self.calls.append(url) + if url not in table: + raise NetworkError(f"GET {url} failed: 404") + return table[url] + + def get_json(self, url: str) -> Any: + return self._lookup(self.json, url) + + def get_text(self, url: str) -> str: + return self._lookup(self.text, url) + + def download( + self, + url: str, + dest: Path, + on_progress: Callable[[int], None] | None = None, + *, + timeout: float = 30.0, + ) -> int: + data = self._lookup(self.files, url) + dest.write_bytes(data) + if on_progress: + on_progress(len(data)) + return len(data) + + def content_length(self, url: str) -> int | None: + data = self.files.get(url) + return len(data) if data is not None else None + + +@dataclass +class Rendered: + spec: DatabaseSpec + doc: dict[str, Any] + env: dict[str, str] + databases: list[dict[str, Any]] + + @property + def agent(self) -> dict[str, Any]: + return self.doc["services"]["agent"] + + @property + def service(self) -> dict[str, Any]: + return self.doc["services"][self.spec.host] + + def var(self, suffix: str) -> str: + return f"${{{self.spec.env_prefix}_{suffix}}}" + + +def encrypt(plain: bytes, key: bytes, chunk_size: int = 4) -> bytes: + """Build a .enc file the way the Portabase agent writes one.""" + base_nonce = os.urandom(8) + header = { + "cipher": "AES-256-GCM", + "base_nonce": list(base_nonce), + "chunk_size": chunk_size, + } + out = json.dumps(header).encode() + b"\n" + aes = AESGCM(key) + for index, start in enumerate(range(0, len(plain), chunk_size)): + nonce = base_nonce + struct.pack(">I", index) + ciphertext = aes.encrypt(nonce, plain[start : start + chunk_size], None) + out += struct.pack(">I", len(ciphertext)) + ciphertext + return out + + +def field_specs(fields: Iterable[Field]) -> list[tuple[str, str, Any]]: + return [(field.name, field.kind, field.default) for field in fields] + + +def agent_service(*volumes: str, inline: bool = False) -> dict[str, Any]: + """The agent service as rendered with no option turned on.""" + environment = ( + dict(AGENT_ENV) if inline else {key: f"${{{key}}}" for key in AGENT_ENV} + ) + return { + "restart": "unless-stopped", + "image": "portabase/agent:latest", + "volumes": ["./databases.json:/config/config.json", *volumes], + "environment": environment, + "networks": ["portabase"], + } diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..d91ffcd --- /dev/null +++ b/ui/__init__.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import sys + +from rich.console import Console + +from core.errors import PortabaseError +from ui.components.banner import Banner +from ui.components.diff import Diff +from ui.components.hints import Hint +from ui.components.message import Message +from ui.components.progress import Progress +from ui.components.prompt import Prompt +from ui.components.section import Section +from ui.components.status import Status +from ui.components.summary import Summary +from ui.components.table import DataTable +from ui.form import Form +from ui.theme import QUESTIONARY_STYLE, QUESTIONARY_STYLE_PLAIN, RICH_THEME + + +class UI: + def __init__( + self, + console: Console | None = None, + *, + non_interactive: bool = False, + verbose: bool = False, + no_color: bool = False, + ) -> None: + self.non_interactive = non_interactive + self.verbose = verbose + self.no_color = no_color + self.console = console or self._make_console() + + def configure( + self, + *, + non_interactive: bool | None = None, + verbose: bool | None = None, + no_color: bool | None = None, + ) -> None: + if non_interactive is not None: + self.non_interactive = non_interactive + if verbose is not None: + self.verbose = verbose + if no_color is not None and no_color != self.no_color: + self.no_color = no_color + self.console = self._make_console() + + def _make_console(self) -> Console: + return Console(theme=RICH_THEME, no_color=self.no_color) + + def print(self, renderable, **kwargs) -> None: + self.console.print(renderable, **kwargs) + + def out(self, text: str) -> None: + sys.stdout.write(text) + + def banner(self) -> None: + Banner(self.console)() + + def success(self, text: str) -> None: + Message(self.console).success(text) + + def info(self, text: str) -> None: + Message(self.console).info(text) + + def warning(self, text: str) -> None: + Message(self.console).warning(text) + + def error(self, exc: PortabaseError, *, unexpected: bool = False) -> None: + Message(self.console).error(exc, verbose=self.verbose, unexpected=unexpected) + + def hint(self, text: str | None = None) -> None: + Hint(self.console)(text) + + def section(self, title: str) -> None: + Section(self.console)(title) + + def status(self, text: str): + return Status(self.console)(text) + + def progress(self) -> Progress: + return Progress(self.console) + + def summary(self, rows: list[tuple[str, str]], *, title: str | None = None) -> None: + Summary(self.console)(rows, title=title) + + def table( + self, columns: list[str], rows: list[list[str]], *, title: str | None = None + ) -> None: + DataTable(self.console)(columns, rows, title=title) + + def diff(self, text: str) -> None: + Diff(self.console)(text) + + def form(self) -> Form: + style = QUESTIONARY_STYLE_PLAIN if self.no_color else QUESTIONARY_STYLE + return Form(Prompt(self.console, style), self.non_interactive) + + def confirm( + self, question: str, *, default: bool = False, value: bool | None = None + ) -> bool: + return self.form().confirm(question, value=value, default=default) diff --git a/ui/components/__init__.py b/ui/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/components/banner.py b/ui/components/banner.py new file mode 100644 index 0000000..c2e7b79 --- /dev/null +++ b/ui/components/banner.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from rich.align import Align + +from ui.components.base import Component +from ui.components.hints import Hint + +BANNER = """ +[brand]█▀█ █▀█ █▀█ ▀█▀ ▄▀█ █▄▄ ▄▀█ █▀ █▀▀[/brand] +[brand]█▀▀ █▄█ █▀▄ █ █▀█ █▄█ █▀█ ▄█ ██▄[/brand] +[hint]Deploy your infrastructure anywhere.[/hint] +""" + + +class Banner(Component): + def __call__(self) -> None: + self.console.print(Align.center(BANNER)) + self.console.print(Align.center(Hint(self.console).random() + "\n")) diff --git a/ui/components/base.py b/ui/components/base.py new file mode 100644 index 0000000..bf3495e --- /dev/null +++ b/ui/components/base.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from rich.console import Console + + +class Component: + def __init__(self, console: Console) -> None: + self.console = console diff --git a/ui/components/diff.py b/ui/components/diff.py new file mode 100644 index 0000000..fa4ec68 --- /dev/null +++ b/ui/components/diff.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from rich.syntax import Syntax + +from ui.components.base import Component + + +class Diff(Component): + def __call__(self, text: str) -> None: + if not text.strip(): + self.console.print("[info]ℹ No changes.[/info]") + return + self.console.print(Syntax(text, "diff", theme="ansi_dark", word_wrap=False)) diff --git a/ui/components/hints.py b/ui/components/hints.py new file mode 100644 index 0000000..0bb059b --- /dev/null +++ b/ui/components/hints.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import random + +from ui.components.base import Component + +HINTS = [ + "The Edge Key contains the connection details for dashboard and agent communication.", + "Portabase uses Docker Compose to isolate your databases.", + "List every configured database with 'portabase agent db list '.", + "Running 'portabase stop' will gracefully shut down your containers.", + "The agent polls GitHub for configuration updates.", + "Logs can be viewed in real time with 'portabase logs '.", + "Custom environment variables can be added to the generated .env file.", + "Need to update? Use 'portabase update' to get the latest version.", + "You can add several databases to a single agent during setup.", + "Portabase Dashboard provides a web interface to manage your infrastructure.", + "Docker not running? The CLI offers to start it for you.", + "All configurations are stored locally in the component's folder.", + "The 'portabase restart' command is useful after manual .env modifications.", + "Portabase is open source. Visit our GitHub to contribute.", + "Use the --start flag with 'agent' or 'dashboard' to skip the final prompt.", + "Internal databases are automatically backed up when using volumes.", + "The dashboard requires a PostgreSQL database to store its own data.", + "Switch the update channel to 'beta' with 'portabase config set update_channel beta'.", + "The Portabase network keeps communication between your containers private.", + "Lost your Edge Key? You can find it in the dashboard.", + "The 'portabase uninstall' command safely removes containers and their data.", + "Use 'portabase --version' to check your current installation details.", + "The 'databases.json' file keeps track of all managed database instances.", +] + + +class Hint(Component): + def random(self) -> str: + return f"[hint]{random.choice(HINTS)}[/hint]" + + def __call__(self, text: str | None = None) -> None: + self.console.print(f"[hint]{text}[/hint]" if text else self.random()) diff --git a/ui/components/message.py b/ui/components/message.py new file mode 100644 index 0000000..ea58795 --- /dev/null +++ b/ui/components/message.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import traceback + +from core.errors import PortabaseError +from ui.components.base import Component + + +class Message(Component): + def success(self, text: str) -> None: + self.console.print(f"[success]✔ {text}[/success]") + + def info(self, text: str) -> None: + self.console.print(f"[info]ℹ {text}[/info]") + + def warning(self, text: str) -> None: + self.console.print(f"[warning]⚠ {text}[/warning]") + + def error( + self, exc: PortabaseError, *, verbose: bool = False, unexpected: bool = False + ) -> None: + label = "Unexpected error" if unexpected else "Error" + self.console.print(f"[danger]✖ {label}:[/danger] {exc.message}") + if exc.hint: + self.console.print(f" [hint]↳ {exc.hint}[/hint]") + if verbose or unexpected: + self.console.print(f" [hint]code: {exc.code}[/hint]") + if verbose and exc.cause is not None: + self.console.print( + f" [hint]cause: {type(exc.cause).__name__}: {exc.cause}[/hint]" + ) + if verbose: + self.console.print( + "".join(traceback.format_exception(exc)), highlight=False, markup=False + ) diff --git a/ui/components/progress.py b/ui/components/progress.py new file mode 100644 index 0000000..94f1826 --- /dev/null +++ b/ui/components/progress.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from collections.abc import Callable, Iterator +from contextlib import contextmanager + +from rich.progress import ( + BarColumn, + DownloadColumn, + SpinnerColumn, + TextColumn, + TransferSpeedColumn, +) +from rich.progress import Progress as RichProgress + +from ui.components.base import Component +from ui.components.hints import Hint + + +class Progress(Component): + @contextmanager + def download(self, description: str, total: int) -> Iterator[Callable[[int], None]]: + with RichProgress( + SpinnerColumn(), + TextColumn( + "[progress.description]{task.description}\n" + + Hint(self.console).random() + ), + BarColumn(), + DownloadColumn(), + TransferSpeedColumn(), + console=self.console, + ) as progress: + task = progress.add_task(description, total=total or None) + yield lambda amount: progress.update(task, advance=amount) diff --git a/ui/components/prompt.py b/ui/components/prompt.py new file mode 100644 index 0000000..02ba91a --- /dev/null +++ b/ui/components/prompt.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from collections.abc import Sequence + +import questionary +from questionary import Style +from rich.console import Console + +from ui.components.base import Component + + +class Prompt(Component): + def __init__(self, console: Console, style: Style) -> None: + super().__init__(console) + self.style = style + + def text(self, message: str, *, default: str | None = None) -> str | None: + return questionary.text(message, default=default or "", style=self.style).ask() + + def integer(self, message: str, *, default: int | None = None) -> int | None: + answer = questionary.text( + message, + default="" if default is None else str(default), + validate=lambda value: ( + value.strip().lstrip("-").isdigit() or "Enter a whole number" + ), + style=self.style, + ).ask() + return None if answer is None else int(answer) + + def secret(self, message: str) -> str | None: + return questionary.password(message, style=self.style).ask() + + def confirm(self, message: str, *, default: bool = False) -> bool | None: + return questionary.confirm(message, default=default, style=self.style).ask() + + def select( + self, message: str, choices: Sequence[str], *, default: str | None = None + ) -> str | None: + return questionary.select( + message, choices=list(choices), default=default, style=self.style + ).ask() + + def path(self, message: str, *, default: str | None = None) -> str | None: + return questionary.path(message, default=default or "", style=self.style).ask() diff --git a/ui/components/section.py b/ui/components/section.py new file mode 100644 index 0000000..97be076 --- /dev/null +++ b/ui/components/section.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from rich.panel import Panel + +from ui.components.base import Component + + +class Section(Component): + def __call__(self, title: str) -> None: + self.console.print("") + self.console.print(Panel(f"[bold]{title}[/bold]", style="cyan", expand=False)) diff --git a/ui/components/status.py b/ui/components/status.py new file mode 100644 index 0000000..4e53488 --- /dev/null +++ b/ui/components/status.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from contextlib import AbstractContextManager + +from ui.components.base import Component +from ui.components.hints import Hint + + +class Status(Component): + def __call__(self, text: str, *, spinner: str = "dots") -> AbstractContextManager: + message = f"[bold magenta]{text}[/bold magenta]\n{Hint(self.console).random()}" + return self.console.status(message, spinner=spinner) diff --git a/ui/components/summary.py b/ui/components/summary.py new file mode 100644 index 0000000..def6e4c --- /dev/null +++ b/ui/components/summary.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import re + +from rich.panel import Panel +from rich.table import Table + +from ui.components.base import Component + +_SENSITIVE = re.compile(r"(password|secret|key|token)", re.I) +_URL_CREDS = re.compile(r"://([^:/@]+):([^@/]+)@") + + +def mask(label: str, value: str) -> str: + if _SENSITIVE.search(label): + return "••••••••" + return _URL_CREDS.sub(r"://\1:****@", value) + + +class Summary(Component): + def __call__( + self, rows: list[tuple[str, str]], *, title: str | None = None + ) -> None: + table = Table(show_header=False, box=None, padding=(0, 2)) + table.add_column("Property", style="bold cyan") + table.add_column("Value", style="white") + for label, value in rows: + table.add_row(label, mask(label, str(value))) + self.console.print("") + self.console.print( + Panel( + table, + title=f"[bold white]{title}[/bold white]" if title else None, + border_style="bold blue", + expand=False, + ) + ) diff --git a/ui/components/table.py b/ui/components/table.py new file mode 100644 index 0000000..64ac262 --- /dev/null +++ b/ui/components/table.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from rich.table import Table + +from ui.components.base import Component + +_STYLES = ["cyan", "blue", "magenta", "green", "white", "dim"] + + +class DataTable(Component): + def __call__( + self, columns: list[str], rows: list[list[str]], *, title: str | None = None + ) -> None: + table = Table(title=title) + for index, col in enumerate(columns): + table.add_column(col, style=_STYLES[index % len(_STYLES)]) + for row in rows: + table.add_row(*[str(cell) for cell in row]) + self.console.print(table) diff --git a/ui/form.py b/ui/form.py new file mode 100644 index 0000000..3727d72 --- /dev/null +++ b/ui/form.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import Any + +from core.errors import UserAbort, ValidationError +from core.fields import Field +from ui.components.prompt import Prompt + +_TRUE = {"1", "true", "yes", "y", "on"} +_FALSE = {"0", "false", "no", "n", "off"} + + +class Form: + def __init__(self, prompt: Prompt, non_interactive: bool) -> None: + self.prompt = prompt + self.non_interactive = non_interactive + self._askers: dict[str, Callable[[Field], Any]] = { + "text": lambda field: self.prompt.text(field.prompt, default=field.default), + "int": lambda field: self.prompt.integer( + field.prompt, default=field.default + ), + "secret": lambda field: self.prompt.secret(field.prompt), + "bool": lambda field: self.prompt.confirm( + field.prompt, default=bool(field.default) + ), + "choice": lambda field: self.prompt.select( + field.prompt, field.choices, default=field.default + ), + "path": lambda field: self.prompt.path(field.prompt, default=field.default), + } + + def ask(self, field: Field, value: Any | None = None) -> Any: + if value is not None: + return self._coerce_and_validate(field, value) + if self.non_interactive: + if field.default is not None: + return self._coerce_and_validate(field, field.default) + raise ValidationError( + f"Missing {field.flag}", + hint=f"Required in non-interactive mode: {field.prompt}", + ) + return self._ask_until_valid(field) + + def collect( + self, fields: Sequence[Field], values: dict[str, Any] + ) -> dict[str, Any]: + return {field.name: self.ask(field, values.get(field.name)) for field in fields} + + def text( + self, prompt: str, *, value=None, default=None, validator=None, name="value" + ) -> str: + field = Field(name, prompt, "text", default=default, validator=validator) + return self.ask(field, value) + + def integer( + self, prompt: str, *, value=None, default=None, validator=None, name="value" + ) -> int: + field = Field(name, prompt, "int", default=default, validator=validator) + return self.ask(field, value) + + def secret(self, prompt: str, *, value=None, validator=None, name="value") -> str: + return self.ask(Field(name, prompt, "secret", validator=validator), value) + + def confirm( + self, prompt: str, *, value=None, default: bool = False, name="value" + ) -> bool: + return self.ask(Field(name, prompt, "bool", default=default), value) + + def choice( + self, + prompt: str, + choices: Sequence[str], + *, + value=None, + default=None, + name="value", + ) -> str: + field = Field(name, prompt, "choice", default=default, choices=tuple(choices)) + return self.ask(field, value) + + def _ask_until_valid(self, field: Field) -> Any: + if field.help: + self.prompt.console.print(f"[info]ℹ {field.help}[/info]") + while True: + answer = self._askers[field.kind](field) + if answer is None: + raise UserAbort() + try: + return self._coerce_and_validate(field, answer) + except ValidationError as error: + self.prompt.console.print(f"[danger]✖ {error.message}[/danger]") + + def _coerce_and_validate(self, field: Field, value: Any) -> Any: + value = self._coerce(field, value) + if field.kind == "choice" and value not in field.choices: + raise ValidationError( + f"Invalid value for {field.flag}: {value!r}", + hint="Choices: " + ", ".join(field.choices), + ) + if field.validator is not None: + value = field.validator(value) + return value + + @staticmethod + def _coerce(field: Field, value: Any) -> Any: + if field.kind == "int" and not isinstance(value, int): + try: + return int(str(value).strip()) + except ValueError as error: + raise ValidationError( + f"{field.flag} must be a whole number, got {value!r}" + ) from error + if field.kind == "bool" and not isinstance(value, bool): + normalized = str(value).strip().lower() + if normalized in _TRUE: + return True + if normalized in _FALSE: + return False + raise ValidationError(f"{field.flag} must be true or false, got {value!r}") + if field.kind in ("text", "secret", "path", "choice"): + return str(value) + return value diff --git a/ui/theme.py b/ui/theme.py new file mode 100644 index 0000000..816576f --- /dev/null +++ b/ui/theme.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from questionary import Style +from rich.theme import Theme + +PALETTE = { + "brand": "#ff6600", + "accent": "#5f00d7", + "info": "cyan", + "warning": "magenta", + "danger": "red", + "success": "green", + "muted": "grey50", +} + +RICH_THEME = Theme( + { + "info": f"dim {PALETTE['info']}", + "warning": PALETTE["warning"], + "danger": f"bold {PALETTE['danger']}", + "success": f"bold {PALETTE['success']}", + "title": f"bold white on {PALETTE['accent']}", + "key": f"bold {PALETTE['brand']}", + "value": "white", + "hint": f"italic {PALETTE['muted']}", + "brand": f"bold {PALETTE['brand']}", + } +) + +QUESTIONARY_STYLE = Style( + [ + ("qmark", f"fg:{PALETTE['brand']} bold"), + ("question", "bold"), + ("pointer", f"fg:{PALETTE['brand']} bold"), + ("highlighted", f"fg:black bg:{PALETTE['brand']} bold"), + ("selected", f"fg:{PALETTE['brand']} bold"), + ("answer", f"fg:{PALETTE['brand']}"), + ] +) + +QUESTIONARY_STYLE_PLAIN = Style([]) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..19cda1b --- /dev/null +++ b/uv.lock @@ -0,0 +1,961 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "altgraph" +version = "0.17.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/a2/04a9383e7512c91c54b2f34b3ff86dc7d2610506f588c2bda36e952a68f7/ast_serialize-0.11.1.tar.gz", hash = "sha256:cc5db2983805f6be786488aac8c5998d5b71965488d1b18c44d435a2205a5cb4", size = 953785, upload-time = "2026-09-09T16:05:27.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/cb/3577c66278e4ea4ff6968aee04327447335ecfc58c76123f68cf7f76d75e/ast_serialize-0.11.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f7ca1557553fdab313999ad69156de154ff87152cae963698be2e765164bb6c0", size = 897021, upload-time = "2026-09-09T16:03:59.316Z" }, + { url = "https://files.pythonhosted.org/packages/47/db/9b4eed53ab0bb63653f56ef71062fa91693c8c26b0b648c58d98365c990c/ast_serialize-0.11.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8b54e44763a851c336ca137c60a2434511f0d155e0b924ef374bfbc8d8db8927", size = 1235148, upload-time = "2026-09-09T16:04:01.201Z" }, + { url = "https://files.pythonhosted.org/packages/fd/97/1ebe323015c3cf530f08e88df9929bfd9f5cf8165842bd4b37a7c958565c/ast_serialize-0.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:753afabcc4abf295f515ea33185cf997a1990b88a29d3a3336a9acf62ea85950", size = 1216082, upload-time = "2026-09-09T16:04:02.87Z" }, + { url = "https://files.pythonhosted.org/packages/ac/72/0d3a368edc2d1e0e188c0d3f23821f4e20a6a2291b23d9df482a6b11277f/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98b8aba6bb682b9e8859c628382b4881c0600b28056d63c3168c227c449f3284", size = 1282848, upload-time = "2026-09-09T16:04:04.316Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/2a546a57d7caa708d739c1747bbdf45cc3aa9dac9407a11d9d67e8a0e255/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:651a7890558896c3e08089e51ab3b3a43710eab15b72ad861c400bc9a51923ba", size = 1285337, upload-time = "2026-09-09T16:04:05.899Z" }, + { url = "https://files.pythonhosted.org/packages/bf/16/4ff4179584f8b8bec348787bf721cb7ce3d4c707c7878778065249b59b9a/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff8ddc1453bfe7934292409c7b8e9f68b0a6cff4e4c12535e4b4a66fd83aca0c", size = 1554906, upload-time = "2026-09-09T16:04:07.42Z" }, + { url = "https://files.pythonhosted.org/packages/df/e8/e54a54676ca8965e1ae3cfe065dc26c75158e0b3a3ce35af283da106fbf9/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:daec16b026c5741950a00c708acdf698c4f17babbe4829dd8232324822e9514f", size = 1301472, upload-time = "2026-09-09T16:04:08.782Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ab/618835a2479868a0d4ac807dd04cf2f89d24afe22c4171538d9779c2772e/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9961abcb93bf03652ab09c00862a2396abacd57551c769ad34490a2ae508a61d", size = 1301293, upload-time = "2026-09-09T16:04:10.17Z" }, + { url = "https://files.pythonhosted.org/packages/61/cb/8acc29a1b279ccdfb30dcbdf099e1d8d34c4ee77be9be751c48061433129/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:8ca9e48edf246f09fd9bdb64fbd9cd8d18cd2a69ffc24d44891466e17595094f", size = 1307807, upload-time = "2026-09-09T16:04:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/da/74/bada875132f452cb5c1179e3156df43cee6df6cb51f773220688be3d4bb4/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6a57e0e025e9dcee48e466b3cf909178e298d238148664e796fc0f60ded5e52e", size = 1356265, upload-time = "2026-09-09T16:04:13.429Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ae/720a8042fc8c1d170f4cc7ed46295ef078f8198ed3476f58210a1e675e0c/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:110386eebccf200446d5f4dcc215f80866a5d3d3d054d3ced6ec1bf386cd3f02", size = 1459957, upload-time = "2026-09-09T16:04:14.878Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2c/ce2ff1ffd4d70376073393c154033728c8535c17211b7710fee7b0f04474/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ef3950747f3a588f692cf4d6f2937eedd1a2d5e199f863af29e8ec7da78a4f36", size = 1562369, upload-time = "2026-09-09T16:04:16.563Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3e/d7a5dd5f609bd0bac45617ce9425e4bd1e369b998c8678edfd70c985408c/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0bc94b81878a3f999fe4cdb606bf00ef7dff6b4f66d0e089b2cf7bfc00d59beb", size = 1556466, upload-time = "2026-09-09T16:04:18.396Z" }, + { url = "https://files.pythonhosted.org/packages/99/cd/a45179802a8637f2be252163ec4e4948534fa8d88798dd391c9cc54e096e/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:341dfc9e6b4e47e5c37e1b7257a77d39f407cf49a371ee90225ab624c2fe8b36", size = 1687190, upload-time = "2026-09-09T16:04:19.965Z" }, + { url = "https://files.pythonhosted.org/packages/01/eb/ef206d764ec0554368501b8b5268f5468819dc0588815acb0575465643df/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7b524cb1b08e15db0f116075c39ab51674b6d3c8103ec1a59c8ca464be83e28a", size = 1481188, upload-time = "2026-09-09T16:04:21.368Z" }, + { url = "https://files.pythonhosted.org/packages/8b/37/9a724c523af7c8b97a659bf4dc28a8e60f97d0cf4d19be277db4067ca734/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c2358b341aca08bd2e1005713bc480bc06eee9f345b67fcb826829aa7b8d0d3", size = 1500731, upload-time = "2026-09-09T16:04:22.905Z" }, + { url = "https://files.pythonhosted.org/packages/48/10/890d5fcaf568088de7989e8cdb4ca525f485bd6cb25eea49d45f017500af/ast_serialize-0.11.1-cp314-cp314t-win32.whl", hash = "sha256:b9e61143a5904f46daa0cde74ba60d3b7c0e3e000752ee138e2bc68e126a5803", size = 1119009, upload-time = "2026-09-09T16:04:24.469Z" }, + { url = "https://files.pythonhosted.org/packages/9d/61/2bd32bd16b5e2ee07badc8c32a73c545dc5a9f087ed19c07eee1243d5a1b/ast_serialize-0.11.1-cp314-cp314t-win_amd64.whl", hash = "sha256:31df8649761d5cb6eb0de2209bd4a167c7477c0516147c70c0b2f5bddc7839bb", size = 1155665, upload-time = "2026-09-09T16:04:26.638Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b6/84bb5e1dc418e660524b873c5bac9be2bf13d38676c96feef4cd4e7d03f3/ast_serialize-0.11.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2f8f33e416a4c7aad12ad757e895c676e953b4027b80b90718dccea83d45111a", size = 1131854, upload-time = "2026-09-09T16:04:28.099Z" }, + { url = "https://files.pythonhosted.org/packages/8c/dd/16de2c0d23a6b298c735d4e4b56d86fa70476eef98e2c66ceaa65503b62f/ast_serialize-0.11.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:a015ede631eeb098a23de8ec0cfe11a1dd02159ab7a26bdc7c6bde2befbbc7b4", size = 1235495, upload-time = "2026-09-09T16:04:29.654Z" }, + { url = "https://files.pythonhosted.org/packages/27/f7/302d2251e6298bbeabc8a1815c9147127031517b305001a02f34fd46b6b5/ast_serialize-0.11.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:57b7d52d1f5c92905cd648bda9efc83cac33097de1e5bb68de8feca9b5f7e87a", size = 1215642, upload-time = "2026-09-09T16:04:31.186Z" }, + { url = "https://files.pythonhosted.org/packages/93/06/93b6527646613502f364cebfb23783fa6701cdbe90761ee26144f1fa20b9/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e47b9b028efbc0486263a49f782ad0ae3e879855fe5a2897b59feefb78e1c40c", size = 1283526, upload-time = "2026-09-09T16:04:32.597Z" }, + { url = "https://files.pythonhosted.org/packages/2f/ac/ffb216262c9a582d039d846081b8c3017dc368cbed5a2520deca5cb7f8cb/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:00adb748034c7b1938ec56925f9f6230ffcd9c56d47355fe32485df29b90b977", size = 1287526, upload-time = "2026-09-09T16:04:34.214Z" }, + { url = "https://files.pythonhosted.org/packages/a2/06/19a4c4837c4d5e73b982938d6da3c9ae10513b61527575b3e1b8396f9298/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:82359d00ea808c260c327e43955c0190bb7baef152a01ae0b5c74d05387b9552", size = 1558354, upload-time = "2026-09-09T16:04:35.669Z" }, + { url = "https://files.pythonhosted.org/packages/e5/29/2373199907b2d97feed176115308dfb3dd0446566b047009828f266d2d66/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f35bb9ddeed4008c7d3e272dd1999885d712e1ce623b0a65ba8a8ed1d51c35cf", size = 1302731, upload-time = "2026-09-09T16:04:37.423Z" }, + { url = "https://files.pythonhosted.org/packages/33/55/ab6805a1457dbe565c84f8d36e2aa4207ef22408adfd01d225823cd07484/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74093ed682ba58456f0d4450da7fb62b826fddd97824d44fc4a81ca6e0bf9e23", size = 1301594, upload-time = "2026-09-09T16:04:38.861Z" }, + { url = "https://files.pythonhosted.org/packages/52/e2/4f54eab2201bb420d39bf61423abcb5d4daffd28e2270e1c5eee2bf3c0f1/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:4dd7218870c203eff4533cfc04a39f29077d6789b20f5b746e7648fe5762a548", size = 1309355, upload-time = "2026-09-09T16:04:40.543Z" }, + { url = "https://files.pythonhosted.org/packages/10/07/755dd98664e2374080b72ccb5fea63d9f11b4bec2409a05b2a4ebc97618d/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e8d20700766171a8a17f89cf53d7bd9712c509460d28a51f7f98340577b78aa", size = 1356645, upload-time = "2026-09-09T16:04:42.299Z" }, + { url = "https://files.pythonhosted.org/packages/34/a0/fadbde3e108064236e2679728ce6d8247aefc81bac5fd82505b59cf69172/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:20b3371c0403099cd55d59f4bcedb6d3994d9db3fa41711c648760a89ef2a575", size = 1459696, upload-time = "2026-09-09T16:04:44.069Z" }, + { url = "https://files.pythonhosted.org/packages/02/fd/b4f95249bd895368ea8290668e27d1f0a5c4ad888bd72a34c7beb2a500d6/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:cc1a78b913f8665dda145999b1b4805ad2ef442ecbe3b819512cf7f95e70498a", size = 1562517, upload-time = "2026-09-09T16:04:45.461Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/6afac594380410710d9d863b9ef41e9c6ca89bd901bda1464ee9e99180a8/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:38f7141881e783bccc362d201d7fd7437934216b669b9fef39febc2f63fd2d9b", size = 1556951, upload-time = "2026-09-09T16:04:47.12Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/5a81b5cbf1731f4722bc90adf4b4ace7dda69ca29837844147063aeafb22/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:13903a3f212ab9e5d05a6c3f2337288f4f8f4c18e6ab3817417ae7a3d46dee14", size = 1691039, upload-time = "2026-09-09T16:04:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/d9b903206cbd6d5143838367d385fa88ec49eaae2cc12f284327c6e0af05/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:e5a9c53e64732118ae8235d0e57eb90e2333e90ff749a1a7dca6f8bfdfa5200e", size = 1483297, upload-time = "2026-09-09T16:04:50.136Z" }, + { url = "https://files.pythonhosted.org/packages/ed/48/ee13b4079ec67e9b333a87e5bb8eee643ab1b49f7173b3fca71158c6cdf6/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:aecb606f67f21fd1c0ffba1826470d2ee400e41b23881ad9a1b2157306865b1a", size = 1501686, upload-time = "2026-09-09T16:04:51.734Z" }, + { url = "https://files.pythonhosted.org/packages/15/67/2175b79e1ee6b042e4bf8ed6871e60cafea7e9cf3fc69e87d7a9d520ae77/ast_serialize-0.11.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:d1ef9c478d8c8ca83499704d13e5ac32c840ed7ca7884aaaf716166aca5a2806", size = 1119522, upload-time = "2026-09-09T16:04:53.248Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c3/8c96aa5f121e9bdda0346b2df3919185eb1ec9a2cce2d20e311b4575ca0b/ast_serialize-0.11.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:27eef0739e0110f5db1ff5dfa38b3dadfcb6f0c31975a2b412f9f2bd72139cea", size = 1157260, upload-time = "2026-09-09T16:04:54.944Z" }, + { url = "https://files.pythonhosted.org/packages/10/bc/8aa663209e335eca73c9e1006197222b6b8dafa6665e513cdd975aa65496/ast_serialize-0.11.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:f9454960bbf185d33c669ccd5c9b76c96709a4542a95a65e27342c4d10dd8e20", size = 1132060, upload-time = "2026-09-09T16:04:56.501Z" }, + { url = "https://files.pythonhosted.org/packages/aa/52/185edf155ff422744acfb2869a8adff84b7dc308027852feb97287b8688b/ast_serialize-0.11.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:2db9df3bca1431da25946ee8f2c50df01212e34e6029532bc90b92d05fa8d7a0", size = 897219, upload-time = "2026-09-09T16:04:58.217Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a7/e738887429de70350d9e25f84a95efbdd086b79579a811509dee8b02d7d5/ast_serialize-0.11.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7b9a6089f1337838492b217707e9a55d0b9b407fe9169a960fa12282abf2234c", size = 1240585, upload-time = "2026-09-09T16:04:59.616Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/46b64fa883f3c8b8cf51629978f16d8a2da3d5c5c02f64add40864907703/ast_serialize-0.11.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:68929da7fb1c7375f69641baac9519d5c41ee441cd09a591ea5dfc83107ffe6f", size = 1228038, upload-time = "2026-09-09T16:05:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/ad/89/fe75c8d0f104a4be11cd0e23acecf129484c9ba06c7130033752d63a78c5/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8945557ed3015173dbf8d44184da9621add9162720acbc0ac4832b5a091b4e8a", size = 1292388, upload-time = "2026-09-09T16:05:02.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/56/3f21c881900d8254a35195e1e962355b4d570ccd9cc193b0ba08abb81952/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9cbed53bb8992b72e587dd47d0ca71703f5f715420f48ed57587c06c2fcd213", size = 1294413, upload-time = "2026-09-09T16:05:04.212Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b2/8d77be3dad1139158c59e370391f4990ea73f7f102983392272856bd3a63/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65c836d1ab7af65e64a1dd73de82572566e838dd27529b1124eac0ac86f35e76", size = 1565921, upload-time = "2026-09-09T16:05:05.753Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/0675b5796c897f957fbdac6af0ebeeaa9a63cee866c1c54f1e5136ef5bf6/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ab6df9898600ff45149b86c12bb5aa3359cc71843ad99ad0c924d11392469ab", size = 1312160, upload-time = "2026-09-09T16:05:07.269Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/1749fa4efae0dc98aad3c3d29f0178e2609208c3ba34700b4551082e40d5/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3eebd3dd25a81839ac7d25b5f3fb3527f6141b35eca29f63d2613fe6254307fd", size = 1312405, upload-time = "2026-09-09T16:05:08.753Z" }, + { url = "https://files.pythonhosted.org/packages/00/14/2afc9f9d7db551f805b4d5e496946dc3dc2a703d27294ccc200b6ff07a7a/ast_serialize-0.11.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:311333c5f58f55fc04121be2c5adffa42a19e1038a43a2cef862fdf58c45bf4f", size = 1319458, upload-time = "2026-09-09T16:05:10.552Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4a/8b2866d9d6d5d0b037d7bf2b74db6e75a66695c503248632116f24009a8d/ast_serialize-0.11.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:aa8d0a9f6d02e7626cdf540555bc2f5313eabe803dcca04854fc628ddf4b1058", size = 1365055, upload-time = "2026-09-09T16:05:12.069Z" }, + { url = "https://files.pythonhosted.org/packages/47/17/73119504574f9b46610ffb718501b254c8adb727d4e43c5babc3c62d4529/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e209a797fdf8680d58d2e969a9ddb25058ea682f6cff59dbabc98d8aef4e0db1", size = 1467771, upload-time = "2026-09-09T16:05:13.458Z" }, + { url = "https://files.pythonhosted.org/packages/56/f5/776bfb7a856ba0bb9bd373b76a90230fb77b1a10c987810b621ed244d5ba/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b391becd31e9889da438d388aac0362e3dfd222affbe45d920580c351486c787", size = 1571459, upload-time = "2026-09-09T16:05:15.15Z" }, + { url = "https://files.pythonhosted.org/packages/c7/43/86a655170ee36a8fdce65a922b7e34040bb319eb4ec251161032343c1fc3/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:021afb3482d27d1dace9c8999724a13083e96c7ece785e53e418bf5df3b50e0a", size = 1568959, upload-time = "2026-09-09T16:05:16.677Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b1/015a3a156dc9bd46e9c4e4f24a939eb0b87179b1c53c9495ac6f088f13b4/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:c1dc60251d93beff32d4147ecd4ccc518b0f022a900093f6f3021c2012416f0f", size = 1698173, upload-time = "2026-09-09T16:05:18.248Z" }, + { url = "https://files.pythonhosted.org/packages/0e/ea/a988b70980aca3a8a3fb731901dc911fe73a456cebbc225b767bbe5490ff/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:b77d6a2267227d68d8221cfca177f8c6d9f68019f3250401d0355d2638db9fb9", size = 1493298, upload-time = "2026-09-09T16:05:19.806Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/e049b2701ddab9087d7f62448f4c2c1b1463e676baa471345cf3e4a31e1b/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e68ac7d7fd1a5d42a3a6d726d7cc8a7e0c9987934f11c6a5162aa2cf68e81e24", size = 1510213, upload-time = "2026-09-09T16:05:21.402Z" }, + { url = "https://files.pythonhosted.org/packages/46/a2/23d555eb842d1397dce54fac31851e6f151e03a0ae74db389d9b39718c33/ast_serialize-0.11.1-cp39-abi3-win32.whl", hash = "sha256:ed449d786b9032a7b85fcd6c2f7f54c4cd0e1e1ad254d396805ff922096bf08d", size = 1125239, upload-time = "2026-09-09T16:05:22.796Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4a/251f3fd1b8a5549edaedf8f3ba0b9fb5060e194d3c0ed208b593fccaeff1/ast_serialize-0.11.1-cp39-abi3-win_amd64.whl", hash = "sha256:6b43f5a9b9a8dd20ba3124914e63aa7d7427de761ac849081cda403c2112fda0", size = 1164260, upload-time = "2026-09-09T16:05:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b5/08e2f643feb9e4d72d42a484949ad51238b18262a01c7036196c201cc330/ast_serialize-0.11.1-cp39-abi3-win_arm64.whl", hash = "sha256:a579ea6f473aab3958a64734194b22fbaec108ec45d994c28d3bd721e1e1da32", size = 1137533, upload-time = "2026-09-09T16:05:26.095Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, +] + +[[package]] +name = "macholib" +version = "1.16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" }, + { url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" }, + { url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" }, + { url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" }, + { url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" }, + { url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, + { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, + { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" }, + { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" }, + { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" }, + { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pefile" +version = "2024.8.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "portabase-cli" +version = "26.8.12" +source = { virtual = "." } +dependencies = [ + { name = "cryptography" }, + { name = "jinja2" }, + { name = "pyyaml" }, + { name = "questionary" }, + { name = "requests" }, + { name = "rich" }, + { name = "typer" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pyinstaller" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "cryptography", specifier = ">=44.0.0" }, + { name = "jinja2", specifier = ">=3.1" }, + { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "questionary", specifier = ">=2.1.0" }, + { name = "requests", specifier = ">=2.32.5" }, + { name = "rich", specifier = ">=14.2.0" }, + { name = "typer", specifier = ">=0.20.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.15" }, + { name = "pyinstaller", specifier = ">=6.17.0" }, + { name = "pytest", specifier = ">=8.3" }, + { name = "ruff", specifier = ">=0.16.0" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyinstaller" +version = "6.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "packaging" }, + { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "pyinstaller-hooks-contrib" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/60/d03d52e6690d4e9caf333dcd14550cde634ce6c118b3bc8fa3112c3186fd/pyinstaller-6.20.0.tar.gz", hash = "sha256:95c5c7e03d5d61e9dfb8ef259c699cf492bb1041beb6dbe83696608cec07347a", size = 4048728, upload-time = "2026-04-22T20:59:36.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/e4/e228d6d1bbb7fd62dc660a8fb202a583b023d3a3624ca95d1a9290ee4d6a/pyinstaller-6.20.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:bf3be4e1284ee78ddccba5e29f99443a12a7b4673168288ffc4c9d38c6f7b90e", size = 1047642, upload-time = "2026-04-22T20:58:32.006Z" }, + { url = "https://files.pythonhosted.org/packages/ce/bd/afb631bcb3f9040efebd4f6d067f0828b51710818f69fb41a2d4b7787f52/pyinstaller-6.20.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:72ae9c1fdea134afa791f58bdc9a1934d5c7609753c111e0026bfc272b32b712", size = 742494, upload-time = "2026-04-22T20:58:36.285Z" }, + { url = "https://files.pythonhosted.org/packages/76/08/0729a5bac14754150e5d83b39d87d842eb42b0bffcaa03dbad6252e23a39/pyinstaller-6.20.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1031bcc307f3fbeffd4e162723e64d46dbf591c82dd0997413afb2a07328b941", size = 754191, upload-time = "2026-04-22T20:58:40.603Z" }, + { url = "https://files.pythonhosted.org/packages/e6/82/bc0ee4c7b97db1958eb651e0da9fb1e672e5ae53ca8867fd97701de52906/pyinstaller-6.20.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:8df3b3f347659fa2562d8d193a98ad4600133b8b8d07c268df89e4154376750e", size = 751902, upload-time = "2026-04-22T20:58:44.7Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/770002d6aaa54173881cb2c49bb195ba67b97bf39bac1cdf320f28401629/pyinstaller-6.20.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b0d3cc9dd8120d448459bd3880a12e2f9774c51443af49047801446377999a59", size = 748634, upload-time = "2026-04-22T20:58:48.579Z" }, + { url = "https://files.pythonhosted.org/packages/fe/db/68ba1fccb71278b2124fb90b37b7c8c0bc4c1173fba45b94466df3d9cb7f/pyinstaller-6.20.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:03696bb6350177c6bc23bcaf78e71a33c4a89b6754dd90d1be2f318e978c918b", size = 748490, upload-time = "2026-04-22T20:58:52.749Z" }, + { url = "https://files.pythonhosted.org/packages/03/0f/ac77ffa996a56be3d5c8f85734a007f8347240691657f9704e7de2527fa3/pyinstaller-6.20.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:6357f1699f6af84f37e7367f031d4f68abdba65543b83990c9e8f5a4cebed0b7", size = 747650, upload-time = "2026-04-22T20:58:57.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/56/1ee91c3a2bc10ca1f36da10a6fd55ff7efc4dec367171eb25992a827874f/pyinstaller-6.20.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0ab39c690abad26ba148e8f664f0478acc82a733997f4f22e757774832802da9", size = 747413, upload-time = "2026-04-22T20:59:01.174Z" }, + { url = "https://files.pythonhosted.org/packages/d7/55/ae264339996953c4cdf9d89d916a0a8fa26a83cf917a742fff8b9d5f3fe8/pyinstaller-6.20.0-py3-none-win32.whl", hash = "sha256:9a7637e8e44b4387b13667fdcaac86ab6b29c446c16d34d8401539b81838759c", size = 1331584, upload-time = "2026-04-22T20:59:07.201Z" }, + { url = "https://files.pythonhosted.org/packages/76/8c/300f57578882cce259bfb5ae56fda3b69caa3fe9df40a176c719920ea6e2/pyinstaller-6.20.0-py3-none-win_amd64.whl", hash = "sha256:d588844e890ee80c4365867f98146636e1849bbca8e4284bbf0c809aff0f161a", size = 1391851, upload-time = "2026-04-22T20:59:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ea/b2f8e1642aecda78c0b75c7321f708e49e10bb3c00dd4f148c40761a1527/pyinstaller-6.20.0-py3-none-win_arm64.whl", hash = "sha256:bd53282c0a73e5c95573e1ddc8e5d564d4932bec91efbaed4dc5fdff9c2ae7f2", size = 1332259, upload-time = "2026-04-22T20:59:20.509Z" }, +] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2026.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/67/f4452d68793fb15beba4f19ef39a38a8822f0da7452b503c400d5a21f5c1/pyinstaller_hooks_contrib-2026.5.tar.gz", hash = "sha256:f066dfca8f7c45ff6336c9cf9fe25b4e48bfeb322a1aa24faaedfb8a8d1b0b08", size = 173689, upload-time = "2026-05-04T22:36:55.124Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/5c/fd465d11da4d12b50d7eb5d2ee2ceb780d8d049dbb489f3828d131e387af/pyinstaller_hooks_contrib-2026.5-py3-none-any.whl", hash = "sha256:ea1535783fbdac4626351709e83f3ea80b681d3a4745763ebb407b5e27342eb9", size = 457314, upload-time = "2026-05-04T22:36:53.598Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, + { url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, + { url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, + { url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "typer" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, +]