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.
-
+[](https://score.getplumber.io/github.com/Portabase/cli)
[](LICENSE)
[](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" },
+]