diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..a926f57 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +contact@soluce-technologies.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. \ No newline at end of file diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..b5916c9 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,261 @@ +--- + +# Contributing to Portabase + +Thank you for considering contributing to **Portabase!** 🎉 Contributions help make this project better for everyone. + +Please take a moment to review this guide. It will help you understand how to contribute effectively. + +--- + +## Table of Contents + +1. [How to Get Started](#how-to-get-started) +2. [Running the CLI (Development)](#running-the-cli-development) +3. [Reporting Issues](#reporting-issues) +4. [Submitting Changes](#submitting-changes) +5. [Code Style Guidelines](#code-style-guidelines) +6. [Pull Request Process](#pull-request-process) +7. [Community Guidelines](#community-guidelines) + +--- + +## How to Get Started + +1. **Fork the repository** + Click the "Fork" button at the top-right corner of this repository. + +2. **Clone the repository** + ```bash + git clone https://github.com/Portabase/cli.git + ``` + +3. **Set up the development environment** + Follow the steps in the `README.md` to install dependencies and configure the project. + +4. **Create a branch** + Use the feature branch to work on changes. + ```bash + git checkout -b feature/ + ``` + +--- + +## Running the CLI (Development) + +The project is a [Typer](https://typer.tiangolo.com/) CLI managed with +[uv](https://docs.astral.sh/uv/). The entry point is `main.py`. + +### Set up the environment + +Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then sync +the dependencies (creates `.venv` and installs everything from `uv.lock`): + +```bash +uv sync +``` + +### Run any command from source + +While developing, run the CLI through `uv run` instead of the installed +`portabase` binary. The pattern is: + +```bash +uv run python main.py [ARGS] [OPTIONS] +``` + +Anything after `main.py` is a normal CLI invocation, so `portabase ` +(once built/installed) and `uv run python main.py ` are equivalent. + +Show the top-level help and version: + +```bash +uv run python main.py --help +uv run python main.py --version +``` + +> Tip: append `--help` to any command to see its arguments, e.g. +> `uv run python main.py agent --help`. + +### Creation commands + +Create an agent (interactive; flags pre-fill the prompts): + +```bash +# name is required (creates a folder); everything else is optional +uv run python main.py agent my-agent +uv run python main.py agent my-agent --key --tz Europe/Paris --polling 10 --start +``` + +| Arg / Option | Default | Description | +| --- | --- | --- | +| `name` (arg) | — | Agent name; creates a folder of that name. | +| `--key`, `-k` | prompt | Edge Key (Base64 or JSON). Prompted if omitted. | +| `--tz` | `UTC` | Timezone. | +| `--polling` | `5` | Polling frequency in seconds. | +| `--start`, `-s` | off | Start the agent immediately after setup. | + +Create a dashboard: + +```bash +uv run python main.py dashboard my-dashboard +uv run python main.py dashboard my-dashboard --port 9000 --start +``` + +| Arg / Option | Default | Description | +| --- | --- | --- | +| `name` (arg) | — | Dashboard name; creates a folder of that name. | +| `--port` | `8887` | Web port. | +| `--start`, `-s` | off | Start the dashboard immediately after setup. | + +### Lifecycle commands + +Each takes the path to a component folder (the one created above): + +```bash +uv run python main.py start my-agent +uv run python main.py stop my-agent +uv run python main.py restart my-agent +uv run python main.py logs my-agent # follows by default +uv run python main.py logs my-agent --no-follow +uv run python main.py uninstall my-agent # prompts for confirmation +uv run python main.py uninstall my-agent --force +``` + +| Command | Arg / Option | Description | +| --- | --- | --- | +| `start` / `stop` / `restart` | `path` (arg) | Path to the component folder. | +| `logs` | `path` (arg), `--follow/--no-follow`, `-f` | Stream logs; follows unless `--no-follow`. | +| `uninstall` | `path` (arg), `--force`, `-f` | Remove containers and data; `--force` skips the prompt. | + +### Configuration commands + +Decrypt Portabase `.enc` backup files (single file or a folder of `.enc` files): + +```bash +# single file -> explicit output +uv run python main.py decrypt backup.tar.gz.enc backup.tar.gz --key master_key.bin +# folder -> decrypt every .enc into an output folder +uv run python main.py decrypt ./backups ./restored --key master_key.bin +# omit output to write next to the input; omit --key to use ./master_key.bin +uv run python main.py decrypt backup.tar.gz.enc +``` + +| Arg / Option | Default | Description | +| --- | --- | --- | +| `input_path` (arg) | — | A `.enc` file, or a folder containing `.enc` files. | +| `output_path` (arg) | input's directory | Output file or folder (must match the input type). | +| `--key`, `-k` | `./master_key.bin` | Path to the master key file (raw 32-byte or Base64 AES-256 key). | + +Manage the configured databases of an agent: + +```bash +uv run python main.py db list my-agent +uv run python main.py db add my-agent +uv run python main.py db remove my-agent +``` + +Manage global CLI configuration: + +```bash +uv run python main.py config show +uv run python main.py config channel stable # or: beta +``` + +### System commands + +```bash +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 + +If you encounter a bug or have a suggestion for improvement, follow these steps: + +1. **Check existing issues** to avoid duplicates. +2. **Open a new issue** if needed: + - Provide a clear and descriptive title. + - Describe the issue with steps to reproduce it (if applicable). + - Include relevant logs, screenshots, or code snippets. + +--- + +## Submitting Changes + +1. **Ensure your branch is up to date** + ```bash + git pull origin main + ``` + +2. **Write meaningful commit messages** + Follow this format: + ``` + [type] Summary of changes + ``` + Example: + ``` + feat: add user authentication + fix: resolve crash on login page + ``` + +3. **Push your branch** + ```bash + git push origin feature/ + ``` + +4. **Open a Pull Request (PR)** + Go to the repository on GitHub and click "New Pull Request." + +--- + +## Code Style Guidelines + +- Follow the [specific coding style guide] (e.g., Prettier, ESLint, PEP8). +- Use meaningful variable names and include comments where necessary. +- Tests before submitting your changes. + +--- + +## Pull Request Process + +1. Ensure your code passes all tests and linters. +2. Provide a clear description of what your PR does. +3. Reference any related issues (e.g., `Closes #123`). +4. Wait for a review from a maintainer. + +--- + +## Community Guidelines + +- Be respectful and inclusive to all contributors. +- Follow the [Code of Conduct](CODE_OF_CONDUCT.md). +- Feel free to ask questions if you’re unsure about something. + +--- + +Thank you for contributing! 🙌 + +--- +## 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 and the Discord notification. + +Stable versions must match `X.Y.Z` and can only be cut from `main`. + +The workflow pushes with a token minted from the Portabase GitHub App (`APP_ID` repository variable, `APP_PRIVATE_KEY` secret), scoped to *Contents: write*. The app must be installed on this repository and allowed to push to `main`. A tag pushed with the default `GITHUB_TOKEN` would not trigger the release workflows. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..dd84ea7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..182ba25 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,50 @@ + +--- + +# Security Policy + +## Supported Versions + +We take security seriously and aim to support the following versions of the project with security updates: + +| Version | Supported | +|---------|--------------------| +| Latest | ✅ Fully Supported | + +--- + +## Reporting a Vulnerability + +If you discover a security vulnerability in this project, we appreciate your help in disclosing it responsibly. + +1. **Contact Us** + Please report the vulnerability by emailing **[contact@soluce-technologies.com](mailto:contact@soluce-technologies.com)**. Include the following details: + - A detailed description of the issue. + - Steps to reproduce the vulnerability (if applicable). + - Any potential impacts or risks. + +2. **Response Time** + We aim to respond to security reports within **72 hours**. Once the issue is verified, we will: + - Acknowledge receipt of your report. + - Provide a timeline for addressing the issue. + - Keep you informed throughout the process. + +3. **Public Disclosure** + We will coordinate with you before publicly disclosing the vulnerability. Credit will be given to the reporter unless otherwise requested. + +--- + +## Security Best Practices + +We encourage all users to: +- Use the latest stable version of the project. +- Review the project’s dependencies and update them regularly. +- Follow secure coding practices when using this project. + +--- + +## Thanks + +We thank the security community for their vigilance and help in keeping this project secure! + +--- \ No newline at end of file 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/logo.png b/.github/assets/logo.png new file mode 100644 index 0000000..c9b9962 Binary files /dev/null and b/.github/assets/logo.png differ 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..9a71216 --- /dev/null +++ b/.github/workflows/bump.yml @@ -0,0 +1,92 @@ +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/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 + id: app-token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + permission-contents: write + + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.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/notification.yml b/.github/workflows/notification.yml deleted file mode 100644 index 60a2174..0000000 --- a/.github/workflows/notification.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Release Notification - -on: - release: - types: [published] - -jobs: - notify: - runs-on: ubuntu-latest - steps: - - name: Send Discord Notification - env: - DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} - RELEASE_TITLE: "${{ github.event.release.name }}" - RELEASE_URL: "${{ github.event.release.html_url }}" - RELEASE_BODY: "${{ github.event.release.body }}" - AUTHOR_NAME: "${{ github.event.release.author.login }}" - AUTHOR_ICON: "${{ github.event.release.author.avatar_url }}" - run: | - PAYLOAD=$(jq -n \ - --arg title "$RELEASE_TITLE" \ - --arg description "$RELEASE_BODY" \ - --arg url "$RELEASE_URL" \ - --arg author "$AUTHOR_NAME" \ - --arg icon "$AUTHOR_ICON" \ - '{ - content: "||@everyone|| New release published", - embeds: [{ - title: $title, - url: $url, - description: $description, - color: 5814783, - author: { - name: $author, - icon_url: $icon - }, - footer: { - text: "Portabase" - } - }] - }' - ) - curl -H "Content-Type: application/json" \ - -d "$PAYLOAD" \ - "$DISCORD_WEBHOOK" \ No newline at end of file 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/release.yml b/.github/workflows/release.yml index 4e20f0d..c170c0c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,66 +3,57 @@ name: Release on: push: tags: - - 'v*' + - '*.*.*' permissions: contents: write + id-token: write + attestations: write -jobs: - build: - name: Build for ${{ matrix.os }} (${{ matrix.arch }}) - runs-on: ${{ matrix.runner }} - strategy: - matrix: - include: - - os: linux - arch: amd64 - runner: ubuntu-latest - - os: macos - arch: arm64 - runner: macos-latest - - os: macos - arch: amd64 - runner: macos-15 - - 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: | - uv run pyinstaller --onefile --name portabase_${{ matrix.os }}_${{ matrix.arch }} --paths=. main.py +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: portabase_${{ matrix.os }}_${{ matrix.arch }} - path: dist/portabase_${{ matrix.os }}_${{ matrix.arch }} - - release: - needs: build +jobs: + channel: + name: channel runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + prerelease: ${{ steps.detect.outputs.prerelease }} steps: - - name: Download all artifacts - uses: actions/download-artifact@v4 - with: - path: ./dist - pattern: portabase_* - merge-multiple: true - - name: Generate Checksums - working-directory: ./dist - run: | - sha256sum portabase_* > checksums.txt - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: dist/* - generate_release_notes: true - make_latest: true + - id: detect env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + 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 + + publish: + needs: [channel, build] + uses: ./.github/workflows/publish.yml + with: + prerelease: ${{ needs.channel.outputs.prerelease == 'true' }} + + announce: + needs: [channel, publish] + uses: ./.github/workflows/discord.yml + with: + 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/.gitignore b/.gitignore index 09e0d1a..fcb772d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,12 @@ -dist/ -build/ +/dist/ +/build/ .venv/ __pycache__/ -uv.lock -*.spec \ No newline at end of file +# Keep portabase.spec: it is the build definition. +*.spec +!portabase.spec + +.claude +.codex/ + +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/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml index a6c15b8..f478a08 100644 --- a/.idea/material_theme_project_new.xml +++ b/.idea/material_theme_project_new.xml @@ -3,7 +3,9 @@ diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..76c43c7 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/portabase-cli.iml b/.idea/portabase-cli.iml index 24643cc..470dc81 100644 --- a/.idea/portabase-cli.iml +++ b/.idea/portabase-cli.iml @@ -1,10 +1,12 @@ - + + + diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..973d32f --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,28 @@ +cff-version: 1.0.0 +title: Portabase CLI +message: "If you use this software, please cite it as below." +type: software +authors: + - family-names: Gauthereau + 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." +keywords: + - portabase + - cli + - command-line + - tool + - developer + - productivity + - automation + - database + - management + - integration +license: Apache-2.0 +version: 26.09.2 +date-released: "2026-09-14" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..545fb3e --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Portabase + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md index e69de29..252a001 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,90 @@ +
+
+ + Logo + + +

Portabase CLI

+ +

+ The official command line interface (CLI) for managing and deploying Portabase instances with ease. +

+ +[![Plumber Score](https://score.getplumber.io/github.com/Portabase/cli.svg)](https://score.getplumber.io/github.com/Portabase/cli) +[![License: Apache](https://img.shields.io/badge/License-apache-yellow.svg)](LICENSE) +[![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey)](https://github.com/Portabase/portabase) + +[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-336791?logo=postgresql&logoColor=white)](https://www.postgresql.org/) +[![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=white)](https://www.mysql.com/) +[![MariaDB](https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white)](https://mariadb.org/) +[![Self Hosted](https://img.shields.io/badge/self--hosted-yes-brightgreen)](https://github.com/Portabase/portabase) +[![Open Source](https://img.shields.io/badge/open%20source-❤️-red)](https://github.com/Portabase/portabase) + + + +![Python][Python] +![Typer][Typer] +![Rich][Rich] + + +

+ + Website • + Documentation • + Installation • + Report Bug • + Request Feature + +

+ +
+ +## Installation + +You can install Portabase CLI using bash with the following command: + +```bash +curl -sSL https://portabase.io/install | bash +``` + +- Development setup - [details](https://portabase.io/docs/cli#development-setup) + +For more installation options, please refer to the [official documentation](https://portabase.io/docs/cli). + +## License + +Distributed under the Apache License. See `LICENSE.txt` for more details. + +[Python]: https://img.shields.io/badge/python-3670A0?style=for-the-badge&logo=python&logoColor=ffdd54 + +[Typer]: https://img.shields.io/badge/typer-FF5733?style=for-the-badge&logo=typer&logoColor=white + +[Rich]: https://img.shields.io/badge/rich-5E60CE?style=for-the-badge&logo=rich&logoColor=white + + + + +## 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/__init__.py b/__init__.py deleted file mode 100644 index d3a987c..0000000 --- a/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -__version__ = "25.12.1b3" - -#25 = Année -#12 = Mois -#Numéro de version = 1,2,3... -#Lettre = a (alpha), b (beta), rc (release candidate) or nothing for stable releases -#Lettre beta = version de test publique avant la version stable -#Exemple: 25.12.1b1 = Décembre 2025, première version beta publique avant la version stable \ No newline at end of file diff --git a/commands/agent.py b/commands/agent.py index 0251042..176bb0d 100644 --- a/commands/agent.py +++ b/commands/agent.py @@ -1,178 +1,221 @@ -import typer -import secrets -import uuid -import os +from __future__ import annotations + from pathlib import Path -from typing import Optional -from rich.panel import Panel -from rich.prompt import Prompt, Confirm, IntPrompt -from core.utils import console, print_banner, check_system, get_free_port -from core.config import write_file, write_env_file, add_db_to_json -from core.docker import ensure_network, run_compose -from core.network import fetch_template -from templates.compose import AGENT_POSTGRES_SNIPPET, AGENT_MARIADB_SNIPPET - -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"), - 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) - project_name = name.lower().replace(" ", "-") - - if not key: - key = Prompt.ask("[key]Edge Key[/key]") - - raw_template = fetch_template("agent.yml") - - env_vars = { - "EDGE_KEY": key, - "PROJECT_NAME": project_name - } - - extra_services = "" - extra_volumes = "" - 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 Confirm.ask("Do you want to configure a database?", default=True): - mode = Prompt.ask("Configuration Mode", choices=["docker", "manual"], default="docker") - - if mode == "manual": - console.print("[info]External/Existing Database Configuration[/info]") - db_type = Prompt.ask("Type", choices=["postgresql", "mysql", "mariadb"], default="postgresql") - friendly_name = Prompt.ask("Display Name", default="External DB") - db_name = Prompt.ask("Database Name") - host = Prompt.ask("Host", default="localhost") - port = IntPrompt.ask("Port", default=5432 if db_type == "postgresql" else 3306) - user = Prompt.ask("Username") - password = Prompt.ask("Password", password=True) - - add_db_to_json(path, { - "name": friendly_name, - "database": db_name, - "type": db_type, - "username": user, - "password": password, - "port": port, - "host": host, - "generatedId": str(uuid.uuid4()) - }) - console.print("[success]✔ Added to config[/success]") +from typing import Annotated, Any + +import typer + +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 + ) + 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 + }, + ) + self._write(project) + self.ui.success(f"Agent '{name}' created in {path}") + + if self.ui.non_interactive: + self.ui.hint( + f"Add databases with: portabase agent db add {name} " + "--engine postgresql --mode new" + ) + else: + 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 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) + + +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: - console.print("[info]New Local Docker Container[/info]") - db_engine = Prompt.ask("Engine", choices=["postgresql", "mariadb"], default="postgresql") - - if db_engine == "postgresql": - pg_port = get_free_port() - db_user = "admin" - db_pass = secrets.token_hex(8) - 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") - - add_db_to_json(path, { - "name": db_name, - "database": db_name, - "type": "postgresql", - "username": db_user, - "password": db_pass, - "port": pg_port, - "host": "localhost", - "generatedId": str(uuid.uuid4()) - }) - console.print(f"[success]✔ Added Postgres container (Port {pg_port})[/success]") - - elif db_engine == "mariadb": - mysql_port = get_free_port() - db_user = "admin" - db_pass = secrets.token_hex(8) - 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": "mysql", - "username": db_user, - "password": db_pass, - "port": mysql_port, - "host": "localhost", - "generatedId": str(uuid.uuid4()) - }) - console.print(f"[success]✔ Added MariaDB container (Port {mysql_port})[/success]") - - if volumes_list: - 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) - - final_compose = final_compose.replace("${PROJECT_NAME}", project_name) - - 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): - with console.status("[bold magenta]Starting...[/bold magenta]", 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]") \ No newline at end of file + 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 + ) + 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, + ) + 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 baa5ae1..0000000 --- a/commands/common.py +++ /dev/null @@ -1,64 +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 -from core.docker import run_compose - -def start(path: Path = typer.Argument(..., help="Path to component folder")): - path = path.resolve() - validate_work_dir(path) - with console.status(f"[bold magenta]Starting {path.name}...[/bold magenta]"): - 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) - with console.status(f"[bold magenta]Stopping {path.name}...[/bold magenta]"): - 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) - with console.status(f"[bold magenta]Restarting {path.name}...[/bold magenta]"): - 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 = path.name.lower().replace(" ", "_") - 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() - - with console.status(f"[bold red]Uninstalling...[/bold red]"): - 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 new file mode 100644 index 0000000..c781241 --- /dev/null +++ b/commands/config.py @@ -0,0 +1,97 @@ +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") + + +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'." + ) + 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() + ) + + +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), + ] diff --git a/commands/dashboard.py b/commands/dashboard.py index 42242e0..78d16b7 100644 --- a/commands/dashboard.py +++ b/commands/dashboard.py @@ -1,60 +1,327 @@ -import typer +from __future__ import annotations + import secrets +import sys from pathlib import Path -from rich.panel import Panel -from rich.prompt import Confirm -from core.utils import console, print_banner, check_system, get_free_port -from core.config import write_file, write_env_file -from core.docker import run_compose -from core.network import fetch_template - -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 = name.lower().replace(" ", "-") - - raw_template = fetch_template("dashboard.yml") - - auth_secret = secrets.token_hex(32) - base_url = f"http://localhost:{port}" - pg_port = get_free_port() - - env_vars = { - "PORT": port, - "POSTGRES_DB": "portabase", - "POSTGRES_USER": "portabase", - "POSTGRES_PASSWORD": secrets.token_hex(16), - "POSTGRES_HOST": "db", - "DATABASE_URL": f"postgresql://portabase:PWD@db:5432/portabase?schema=public", - "PROJECT_SECRET": auth_secret, - "PROJECT_URL": base_url, - "PROJECT_NAME": project_name, - "PG_PORT": str(pg_port) - } - env_vars["DATABASE_URL"] = env_vars["DATABASE_URL"].replace("PWD", env_vars["POSTGRES_PASSWORD"]) - - final_compose = raw_template.replace("${PROJECT_NAME}", project_name) - - write_file(path / "docker-compose.yml", final_compose) - write_env_file(path, env_vars) - - console.print(Panel(f"[bold white]DASHBOARD CREATED: {name}[/bold white]\n[dim]Path: {path}[/dim]\n[dim]DB Port: {pg_port}[/dim]", style="bold #5f00d7")) - - if start or Confirm.ask("Start dashboard now?", default=False): - with console.status("[bold magenta]Starting...[/bold magenta]", 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]") \ No newline at end of file +from typing import Annotated, Any +from urllib.parse import quote + +import typer + +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")] + + +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" + ), + ] = 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() + + 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)), + ("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 ..." + ) + self.ui.hint( + f" portabase dashboard auth add {name} oauth github --client ID ..." + ) + + 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" + ) + 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", + ) + + 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), + ] + + @property + def groups(self) -> list[CommandGroup]: + return [self.auth] diff --git a/commands/db.py b/commands/db.py index cdb6030..2ff0646 100644 --- a/commands/db.py +++ b/commands/db.py @@ -1,95 +1,289 @@ -import typer -import uuid +from __future__ import annotations + +import sys from pathlib import Path -from rich.table import Table -from rich.panel import Panel -from rich.prompt import Prompt, IntPrompt -from core.utils import console, validate_work_dir -from core.config import load_db_config, save_db_config, add_db_to_json - -app = typer.Typer(help="Manage databases configuration.") - -@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: - table.add_row( - db.get("name", "N/A"), - db.get("database", db.get("name", "N/A")), - db.get("type", "N/A"), - f"{db.get('host', 'N/A')}:{db.get('port', 'N/A')}", - db.get("username", "N/A"), - db.get("generatedId", "")[:8] + "..." +from typing import Annotated + +import typer + +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." + ) + + +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." + ) + + 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"{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: + 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}. " + f"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 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}", ) - console.print(table) - -@app.command("add") -def add_db(name: str = typer.Argument(..., help="Name of the agent")): - path = Path(name).resolve() - validate_work_dir(path) - - console.print(Panel("Add External Database Connection", style="bold blue")) - - db_type = Prompt.ask("Type", choices=["postgresql", "mysql", "mariadb"], default="postgresql") - friendly_name = Prompt.ask("Display Name", default="External DB") - db_name = Prompt.ask("Database Name") - host = Prompt.ask("Host", default="localhost") - port = IntPrompt.ask("Port", default=5432 if db_type == "postgresql" else 3306) - user = Prompt.ask("Username") - password = Prompt.ask("Password", password=True) - - entry = { - "name": friendly_name, - "database": db_name, - "type": db_type, - "username": user, - "password": password, - "port": port, - "host": host, - "generatedId": str(uuid.uuid4()) - } - - add_db_to_json(path, entry) - 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) - - 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]") \ No newline at end of file + + +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 new file mode 100644 index 0000000..f1c08f5 --- /dev/null +++ b/commands/decrypt.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Annotated + +import typer + +from commands.base import Command +from core.crypto import ( + ENC_SUFFIX, + DecryptionError, + decrypt_enc_file, + default_output_for, + load_master_key, +) +from core.errors import ConfigError, ValidationError + + +def _looks_like_dir(path: Path) -> bool: + if path.exists(): + return path.is_dir() + return str(path).endswith(("/", "\\")) or path.suffix == "" + + +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) + 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 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)." + ) + 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/compile.txt b/compile.txt deleted file mode 100644 index ee496c8..0000000 --- a/compile.txt +++ /dev/null @@ -1 +0,0 @@ -uv run pyinstaller --onefile --name portabase --paths=. main.py \ No newline at end of file diff --git a/core/config.py b/core/config.py index e76625d..da07a34 100644 --- a/core/config.py +++ b/core/config.py @@ -1,57 +1,40 @@ import json import os -import uuid from pathlib import Path -TEMPLATE_BASE_URL = "https://portabase-cli.s3.fr-par.scw.cloud/templates/v1" - -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_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 "generatedId" not in db_entry: - db_entry["generatedId"] = str(uuid.uuid4()) - - config["databases"].append(db_entry) - save_db_config(path, config) \ No newline at end of file +GLOBAL_CONFIG_DIR = Path.home() / ".portabase" +GLOBAL_CONFIG_FILE = GLOBAL_CONFIG_DIR / "config.json" + + +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 new file mode 100644 index 0000000..18b4a57 --- /dev/null +++ b/core/crypto.py @@ -0,0 +1,147 @@ +import base64 +import contextlib +import json +import os +import struct +from pathlib import Path + +from Crypto.Cipher import AES + +from core.errors import PortabaseError + +ENC_SUFFIX = ".enc" +DEFAULT_KEY_FILENAME = "master_key.bin" +CIPHER_NAME = "AES-256-GCM" +_BASE_NONCE_LEN = 8 +_LEN_PREFIX_LEN = 4 +_AES_256_KEY_LEN = 32 +_TAG_LEN = 16 +_MAX_CHUNK_PLAINTEXT = 256 * 1024 * 1024 +_WRITE_SLICE = 4 * 1024 * 1024 + + +class DecryptionError(PortabaseError): + code = "E_CRYPTO" + exit_code = 8 + + +def load_master_key(key_path: Path | None) -> bytes: + if key_path is None: + key_path = Path.cwd() / DEFAULT_KEY_FILENAME + + if not key_path.exists(): + raise DecryptionError(f"Master key file not found: {key_path}") + if not key_path.is_file(): + raise DecryptionError(f"Master key path is not a file: {key_path}") + + raw = key_path.read_bytes() + + if len(raw) == _AES_256_KEY_LEN: + return raw + + try: + decoded = base64.standard_b64decode(raw.strip()) + except ValueError: + decoded = b"" + if len(decoded) == _AES_256_KEY_LEN: + return decoded + + raise DecryptionError( + f"Invalid master key in {key_path}: expected a 32-byte AES-256 key " + f"(raw or base64), got {len(raw)} bytes." + ) + + +def _read_header(handle) -> tuple[bytes, int]: + header_line = handle.readline() + if not header_line: + raise DecryptionError("File is empty: missing header.") + try: + header = json.loads(header_line) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise DecryptionError(f"Invalid or missing JSON header: {exc}") from exc + + cipher = header.get("cipher") + if cipher != 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: + raise DecryptionError( + f"Invalid base_nonce length: {len(base_nonce)} (expected {_BASE_NONCE_LEN})." + ) + + chunk_size = header.get("chunk_size") + if not isinstance(chunk_size, int) or not 0 < chunk_size <= _MAX_CHUNK_PLAINTEXT: + chunk_size = _MAX_CHUNK_PLAINTEXT + return base_nonce, chunk_size + + +def decrypt_enc_file(enc_path: Path, out_path: Path, key: bytes) -> None: + tmp_path = out_path.with_name(out_path.name + ".part") + + try: + with open(enc_path, "rb") as src: + base_nonce, chunk_size = _read_header(src) + max_ciphertext = chunk_size + _TAG_LEN + + out_path.parent.mkdir(parents=True, exist_ok=True) + with open(tmp_path, "wb") as dst: + chunk_index = 0 + while True: + len_buf = src.read(_LEN_PREFIX_LEN) + if not len_buf: + break + if len(len_buf) != _LEN_PREFIX_LEN: + raise DecryptionError("Truncated chunk length prefix.") + + chunk_len = struct.unpack(">I", len_buf)[0] + if chunk_len < _TAG_LEN: + raise DecryptionError( + f"Chunk {chunk_index} length {chunk_len} is smaller than " + f"the {_TAG_LEN}-byte tag (corrupt file)." + ) + if chunk_len > max_ciphertext: + raise DecryptionError( + f"Chunk {chunk_index} length {chunk_len} exceeds the maximum " + f"{max_ciphertext} bytes (corrupt file or wrong format)." + ) + + ciphertext = src.read(chunk_len) + if len(ciphertext) != chunk_len: + raise DecryptionError( + f"Truncated chunk {chunk_index}: expected {chunk_len} bytes, " + f"got {len(ciphertext)}." + ) + + nonce = base_nonce + struct.pack(">I", chunk_index) + cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) + try: + plaintext = cipher.decrypt_and_verify( + ciphertext[:-_TAG_LEN], ciphertext[-_TAG_LEN:] + ) + except ValueError as exc: + raise DecryptionError( + f"Authentication failed on chunk {chunk_index} " + "(wrong key or corrupt data)." + ) from exc + + del ciphertext + for start in range(0, len(plaintext), _WRITE_SLICE): + dst.write(plaintext[start : start + _WRITE_SLICE]) + chunk_index += 1 + + os.replace(tmp_path, out_path) + except BaseException: + with contextlib.suppress(OSError): + tmp_path.unlink(missing_ok=True) + raise + + +def default_output_for(enc_path: Path) -> str: + name = enc_path.name + if name.endswith(ENC_SUFFIX): + return name[: -len(ENC_SUFFIX)] + return name + ".dec" diff --git a/core/docker.py b/core/docker.py deleted file mode 100644 index 14ff0fb..0000000 --- a/core/docker.py +++ /dev/null @@ -1,19 +0,0 @@ -import subprocess -import typer -from core.utils import console -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 = cwd.name.lower().replace(" ", "_") - 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 9fd1d69..0000000 --- a/core/network.py +++ /dev/null @@ -1,18 +0,0 @@ -import requests -import typer -from rich.console import Console -from core.config import TEMPLATE_BASE_URL - -console = Console() - -def fetch_template(filename: str) -> str: - url = f"{TEMPLATE_BASE_URL}/{filename}" - try: - with console.status(f"[dim]Fetching template from {url}...[/dim]"): - 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/utils.py b/core/utils.py index 90fb7fd..c7eaaad 100644 --- a/core/utils.py +++ b/core/utils.py @@ -1,60 +1,63 @@ -import socket -import shutil -import typer -from pathlib import Path -from rich.console import Console, Theme -from rich.align import Align -import subprocess - -custom_theme = Theme({ - "info": "dim cyan", - "warning": "magenta", - "danger": "bold red", - "success": "bold green", - "title": "bold white on #5f00d7", - "key": "bold #ff6600", - "value": "white" -}) -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)) - -def get_free_port(): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("", 0)) - return s.getsockname()[1] - -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) +import base64 +import binascii +import json +import re +import secrets +import string + +def generate_password(length: int = 16) -> str: + + if length < 8: + length = 8 + + lower = string.ascii_lowercase + upper = string.ascii_uppercase + digits = string.digits + symbols = "!@#%^&*()-_=+[]{}|;:,.<>?" + + password = [ + secrets.choice(lower), + secrets.choice(upper), + secrets.choice(digits), + secrets.choice(symbols), + ] + + all_chars = lower + upper + digits + symbols + password += [secrets.choice(all_chars) for _ in range(length - 4)] + + secrets.SystemRandom().shuffle(password) + + 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("-_") + slug = re.sub(r"^[^a-z0-9]+", "", slug) + + return slug or fallback + + +def validate_edge_key(key: str) -> bool: try: - subprocess.run( - [docker_path, "info"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=True + try: + decoded_bytes = base64.b64decode(key, validate=True) + decoded_str = decoded_bytes.decode("utf-8") + data = json.loads(decoded_str) + except (binascii.Error, UnicodeDecodeError, json.JSONDecodeError): + try: + data = json.loads(key) + except json.JSONDecodeError: + return False + + required_fields = ["serverUrl", "agentId", "masterKeyB64"] + return isinstance(data, dict) and all( + field in data for field in required_fields ) - except subprocess.CalledProcessError: - console.print("[danger]✖ Docker is installed but the Daemon is not running.[/danger]") - console.print("[dim]Please start Docker Desktop or the docker service.[/dim]") - 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 \ No newline at end of file + except TypeError: + return False 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..83c5c0b --- /dev/null +++ b/engines/mongodb.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import secrets +from typing import Any + +from core.errors import ValidationError +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 + + +def validate_port(value: int) -> int: + if not 0 <= value <= 65535: + raise ValidationError( + f"--port must be between 0 and 65535, got {value}", + hint="Use 0 for a mongodb+srv:// (Atlas) connection.", + ) + return value + + +class MongoEngine(DbEngine): + key, display, default_port = "mongodb", "MongoDB", 27017 + template = "engines/mongodb.yml.j2" + auth_variants = True + + def fields_existing(self) -> list[Field]: + overrides = { + "port": Field( + "port", + "Port", + "int", + default=self.default_port, + help=( + "Set the port to 0 for an SRV connection (mongodb+srv://, " + "e.g. MongoDB Atlas); use the cluster hostname as host." + ), + validator=validate_port, + ), + "username": Field("username", "Username", "text", default=""), + "password": Field("password", "Password", "secret", default=""), + } + return [overrides.get(field.name, field) for field in super().fields_existing()] + + 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 + + @staticmethod + def is_srv(spec: DatabaseSpec) -> bool: + return not spec.managed and not spec.port + + def agent_entry(self, spec: DatabaseSpec) -> dict[str, Any]: + entry = super().agent_entry(spec) + if self.is_srv(spec): + del entry["port"] + return entry + + def describe(self, spec: DatabaseSpec) -> str: + if self.is_srv(spec): + return f"mongodb+srv://{spec.host}" + return super().describe(spec) 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/install.sh b/install.sh deleted file mode 100644 index f5cbd8d..0000000 --- a/install.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/bin/bash -set -e - -BASE_URL="https://portabase-cli.s3.fr-par.scw.cloud/latest" -BINARY_NAME="portabase" -INSTALL_DIR="/usr/local/bin" - -GREEN='\033[0;32m' -RED='\033[0;31m' -BLUE='\033[0;34m' -NC='\033[0m' - -echo -e "${BLUE}==> Portabase CLI Installer${NC}" - -OS="$(uname -s | tr '[:upper:]' '[:lower:]')" -ARCH="$(uname -m)" - -if [ "$ARCH" == "x86_64" ]; then - ARCH_TAG="amd64" -elif [ "$ARCH" == "arm64" ] || [ "$ARCH" == "aarch64" ]; then - ARCH_TAG="arm64" -else - echo -e "${RED}Error: Architecture '$ARCH' not supported.${NC}" - exit 1 -fi - -if [ "$OS" == "darwin" ]; then - OS_TAG="macos" -elif [ "$OS" == "linux" ]; then - OS_TAG="linux" -else - echo -e "${RED}Error: OS '$OS' not supported.${NC}" - exit 1 -fi - -TARGET_FILE="${BINARY_NAME}-${OS_TAG}-${ARCH_TAG}" -DOWNLOAD_URL="${BASE_URL}/${TARGET_FILE}" - -echo -e "Detected: ${GREEN}${OS_TAG} ${ARCH_TAG}${NC}" -echo -e "Downloading from: ${DOWNLOAD_URL}" - -if ! curl -L --progress-bar -o "/tmp/$BINARY_NAME" "$DOWNLOAD_URL"; then - echo -e "${RED}Download failed! Check your internet connection or if the version exists.${NC}" - exit 1 -fi - -chmod +x "/tmp/$BINARY_NAME" - -echo -e "Installing to $INSTALL_DIR (requires sudo)..." -if sudo mv "/tmp/$BINARY_NAME" "$INSTALL_DIR/$BINARY_NAME"; then - echo -e "${GREEN}✔ Installation successful!${NC}" - echo -e "Run '${BINARY_NAME} --help' to get started." -else - echo -e "${RED}Move failed.${NC}" - exit 1 -fi \ No newline at end of file diff --git a/main.py b/main.py index 5115a68..22ce500 100644 --- a/main.py +++ b/main.py @@ -1,37 +1,220 @@ +import os +import platform +import re +import sys +from dataclasses import dataclass +from typing import Annotated + +import click import typer -from typing import Optional -from commands import agent, dashboard, common, db -from core.utils import console -from __init__ import __version__ - -app = typer.Typer(no_args_is_help=True, add_completion=False) - -def version_callback(value: bool): - if value: - console.print(f"Portabase CLI version: {__version__}") - raise typer.Exit() - -@app.callback() -def main( - version: Optional[bool] = typer.Option( - None, - "--version", - help="Show the version and exit.", - callback=version_callback, - is_eager=True, - ), -): - pass - -app.command()(agent.agent) -app.command()(dashboard.dashboard) -app.command()(common.start) -app.command()(common.stop) -app.command()(common.restart) -app.command()(common.logs) -app.command()(common.uninstall) - -app.add_typer(db.app, name="db") + +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) + if __name__ == "__main__": - app() \ No newline at end of file + 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 22ce2c6..d0fea3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,73 @@ [project] name = "portabase-cli" -version = "0.1.0" -description = "Add your description here" +version = "26.09.2" +description = "The official command line interface (CLI) for managing and deploying Portabase instances with ease." readme = "README.md" requires-python = ">=3.12" dependencies = [ - "rich>=14.2.0", "typer>=0.20.0", + "rich>=14.2.0", + "questionary>=2.1.0", + "requests>=2.32.5", + "pyyaml>=6.0.3", + "pycryptodome>=3.23.0", + "jinja2>=3.1", +] + +[dependency-groups] +dev = [ "pyinstaller>=6.17.0", - "requests>=2.32.5" + "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/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 529e9d9..0000000 --- a/templates/compose.py +++ /dev/null @@ -1,31 +0,0 @@ -AGENT_POSTGRES_SNIPPET = """ - ${SERVICE_NAME}: - container_name: ${PROJECT_NAME}-${SERVICE_NAME} - image: postgres:17-alpine - networks: - - portabase - - default - ports: - - "${PORT}:5432" - volumes: - - ${VOL_NAME}:/var/lib/postgresql/data - environment: - - POSTGRES_DB=${DB_NAME} - - POSTGRES_USER=${USER} - - POSTGRES_PASSWORD=${PASSWORD} -""" - -AGENT_MARIADB_SNIPPET = """ - ${SERVICE_NAME}: - container_name: ${PROJECT_NAME}-${SERVICE_NAME} - image: mariadb:latest - 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 -""" \ No newline at end of file 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..36c1061 --- /dev/null +++ b/tests/engines/mongodb.py @@ -0,0 +1,194 @@ +import re + +import pytest + +from core.errors import ValidationError +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", ""), + ("password", "secret", ""), + ] + 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}", + ] + + +def port_field_mentions_srv(): + port = next(field for field in MONGO.fields_existing() if field.name == "port") + assert "mongodb+srv://" in (port.help or "") + + +@pytest.mark.parametrize("port", [0, 27017, 65535]) +def port_validator_accepts(port): + field = next(field for field in MONGO.fields_existing() if field.name == "port") + assert field.validator is not None + assert field.validator(port) == port + + +@pytest.mark.parametrize("port", [-1, 65536]) +def port_validator_rejects(port): + field = next(field for field in MONGO.fields_existing() if field.name == "port") + assert field.validator is not None + with pytest.raises(ValidationError): + field.validator(port) + + +def srv_existing(): + spec = MONGO.from_existing( + {**EXISTING_ANSWERS, "host": "cluster0.abcde.mongodb.net", "port": 0} + ) + assert spec.port == 0 + assert MONGO.is_srv(spec) + assert MONGO.describe(spec) == "mongodb+srv://cluster0.abcde.mongodb.net" + assert MONGO.agent_entry(spec) == { + "name": "External DB", + "database": "app", + "type": "mongodb", + "username": "u", + "password": "p", + "host": "cluster0.abcde.mongodb.net", + "generated_id": spec.id, + } + + +def srv_missing_port(): + spec = DatabaseSpec( + id="x", engine="mongodb", name="Atlas", host="c.mongodb.net", port=None + ) + assert MONGO.is_srv(spec) + assert "port" not in MONGO.agent_entry(spec) + assert MONGO.describe(spec) == "mongodb+srv://c.mongodb.net" + + +def srv_without_auth(): + answers = {"host": "c.mongodb.net", "port": 0, "database": "app"} + spec = MONGO.from_existing({**answers, "username": "", "password": ""}) + entry = MONGO.agent_entry(spec) + assert "port" not in entry + assert (entry["username"], entry["password"]) == ("", "") + + +def non_srv_existing(): + spec = MONGO.from_existing(EXISTING_ANSWERS) + assert not MONGO.is_srv(spec) + assert MONGO.describe(spec) == "db.example:1234" 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..33c9bc3 --- /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 Crypto.Cipher import AES + +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" + for index, start in enumerate(range(0, len(plain), chunk_size)): + nonce = base_nonce + struct.pack(">I", index) + cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) + ciphertext, tag = cipher.encrypt_and_digest(plain[start : start + chunk_size]) + out += struct.pack(">I", len(ciphertext) + len(tag)) + ciphertext + tag + 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..de95f97 --- /dev/null +++ b/uv.lock @@ -0,0 +1,849 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.15' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "(python_full_version >= '3.15' and platform_machine != 'x86_64') or (python_full_version >= '3.15' and sys_platform != 'darwin')", + "python_full_version < '3.15' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "(python_full_version < '3.15' and platform_machine != 'x86_64') or (python_full_version < '3.15' and sys_platform != 'darwin')", +] + +[[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 = "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 = "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.9.2" +source = { virtual = "." } +dependencies = [ + { name = "jinja2" }, + { name = "pycryptodome" }, + { 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 = "jinja2", specifier = ">=3.1" }, + { name = "pycryptodome", specifier = ">=3.23.0" }, + { 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 = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, +] + +[[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" }, +]