From d793b72d89b722c234813427717ddd59f56754b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 5 Jan 2026 11:29:47 +0100 Subject: [PATCH 001/124] fix: version name --- __init__.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/__init__.py b/__init__.py index d3a987c..a0e7b91 100644 --- a/__init__.py +++ b/__init__.py @@ -1,8 +1,9 @@ -__version__ = "25.12.1b3" +__version__ = "26.01.1b2" -#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 +#25 = Year +#12 = Month +#01 = Release number in the month +#Version number = 1,2,3... +#Letter = a (alpha), b (beta), rc (release candidate) or nothing for stable releases +#Beta letter = public test version before the stable version +#Example: 25.12.1b1 = December 2025, first public beta version before the stable version \ No newline at end of file From a0499fe6aade1540ff4b974203e722b4d8d0df6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 5 Jan 2026 11:44:36 +0100 Subject: [PATCH 002/124] update: version inject from tag --- .github/workflows/release.yml | 7 +++++++ __init__.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e20f0d..cca3ef7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,6 +34,13 @@ jobs: - name: Set up Python run: uv python install + - name: Inject Version + shell: bash + run: | + VERSION="${{ github.ref_name }}" + echo "" >> __init__.py + echo "__version__ = \"$VERSION\"" >> __init__.py + - name: Build binary run: | uv run pyinstaller --onefile --name portabase_${{ matrix.os }}_${{ matrix.arch }} --paths=. main.py diff --git a/__init__.py b/__init__.py index a0e7b91..c4e6b46 100644 --- a/__init__.py +++ b/__init__.py @@ -1,4 +1,4 @@ -__version__ = "26.01.1b2" +__version__ = "v0.0.0" #25 = Year #12 = Month From 5c7b41b117e5423945d73158caa2c044aa475923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 5 Jan 2026 11:55:59 +0100 Subject: [PATCH 003/124] fix --- .github/workflows/notification.yml | 45 ------------------------------ .github/workflows/release.yml | 45 +++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 46 deletions(-) delete mode 100644 .github/workflows/notification.yml 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/release.yml b/.github/workflows/release.yml index cca3ef7..426b52f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,4 +72,47 @@ jobs: generate_release_notes: true make_latest: true env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Send Discord Notification + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + RELEASE_INFO=$(gh release view "${{ github.ref_name }}" --json name,url,body,author) + + RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name) + if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ github.ref_name }}"; fi + + RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url) + RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body) + AUTHOR_NAME=$(echo "$RELEASE_INFO" | jq -r .author.login) + AUTHOR_ICON="https://github.com/${AUTHOR_NAME}.png" + + 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 From 2da996f57f0fe561e6eaa08f6d910b538c91de38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 5 Jan 2026 12:02:02 +0100 Subject: [PATCH 004/124] fix --- .github/workflows/release.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 426b52f..9f27613 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,7 +79,7 @@ jobs: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - RELEASE_INFO=$(gh release view "${{ github.ref_name }}" --json name,url,body,author) + RELEASE_INFO=$(gh release view "${{ github.ref_name }}" -R ${{ github.repository }} --json name,url,body,author) RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name) if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ github.ref_name }}"; fi @@ -87,6 +87,7 @@ jobs: RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url) RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body) AUTHOR_NAME=$(echo "$RELEASE_INFO" | jq -r .author.login) + if [ -z "$AUTHOR_NAME" ] || [ "$AUTHOR_NAME" = "null" ]; then AUTHOR_NAME="Portabase"; fi AUTHOR_ICON="https://github.com/${AUTHOR_NAME}.png" PAYLOAD=$(jq -n \ From bfd7867c5ad41d438f51d2e29bc07aea4b979664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 5 Jan 2026 12:06:44 +0100 Subject: [PATCH 005/124] fix --- .github/workflows/release.yml | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f27613..2f31de3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,11 +65,33 @@ jobs: working-directory: ./dist run: | sha256sum portabase_* > checksums.txt + - name: Build Changelog + id: build_changelog + uses: mikepenz/release-changelog-builder-action@v4 + with: + configurationJson: | + { + "template": "#📋 Changelog\n\n## 🚀 Commits\n#commits\n\n## 🐛 Issues Fixed\n#issues", + "pr_template": "- #TITLE (#PR)", + "commit_template": "- #TITLE (#HASH)", + "categories": [], + "ignore_labels": [], + "sort": "ASC", + "fetch_via_commits": true, + "fetch_reviewers": false, + "fetch_release_information": true, + "reviews": false, + "filter_commits": false + } + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: files: dist/* - generate_release_notes: true + generate_release_notes: false + body: ${{ steps.build_changelog.outputs.changelog }} make_latest: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From cef41fa12cb57b54418fe1015ecaea40d6d394a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 5 Jan 2026 12:12:14 +0100 Subject: [PATCH 006/124] fix --- .github/workflows/release.yml | 40 +++++++++++++++++------------------ 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2f31de3..eab151f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,26 +65,26 @@ jobs: working-directory: ./dist run: | sha256sum portabase_* > checksums.txt - - name: Build Changelog - id: build_changelog - uses: mikepenz/release-changelog-builder-action@v4 - with: - configurationJson: | - { - "template": "#📋 Changelog\n\n## 🚀 Commits\n#commits\n\n## 🐛 Issues Fixed\n#issues", - "pr_template": "- #TITLE (#PR)", - "commit_template": "- #TITLE (#HASH)", - "categories": [], - "ignore_labels": [], - "sort": "ASC", - "fetch_via_commits": true, - "fetch_reviewers": false, - "fetch_release_information": true, - "reviews": false, - "filter_commits": false - } - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Generate Changelog + id: changelog + shell: bash + run: | + PREVIOUS_TAG=$(git describe --tags --abbrev=0 --exclude="${{ github.ref_name }}" 2>/dev/null || echo "") + + echo "Tag actuel: ${{ github.ref_name }}" + echo "Tag précédent: $PREVIOUS_TAG" + + echo "content<> $GITHUB_OUTPUT + echo "" >> $GITHUB_OUTPUT + + if [ -z "$PREVIOUS_TAG" ]; then + git log --pretty=format:"- %s (%h)" >> $GITHUB_OUTPUT + else + git log $PREVIOUS_TAG..HEAD --pretty=format:"- %s (%h)" >> $GITHUB_OUTPUT + fi + + echo "" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT - name: Create GitHub Release uses: softprops/action-gh-release@v2 From 2b8268a73c70f052f1c2ede0db9063a211257550 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 5 Jan 2026 12:20:18 +0100 Subject: [PATCH 007/124] fix --- .github/workflows/release.yml | 37 ++++++++++++++++------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eab151f..133214f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,26 +65,23 @@ jobs: working-directory: ./dist run: | sha256sum portabase_* > checksums.txt - - name: Generate Changelog - id: changelog - shell: bash - run: | - PREVIOUS_TAG=$(git describe --tags --abbrev=0 --exclude="${{ github.ref_name }}" 2>/dev/null || echo "") - - echo "Tag actuel: ${{ github.ref_name }}" - echo "Tag précédent: $PREVIOUS_TAG" - - echo "content<> $GITHUB_OUTPUT - echo "" >> $GITHUB_OUTPUT - - if [ -z "$PREVIOUS_TAG" ]; then - git log --pretty=format:"- %s (%h)" >> $GITHUB_OUTPUT - else - git log $PREVIOUS_TAG..HEAD --pretty=format:"- %s (%h)" >> $GITHUB_OUTPUT - fi - - echo "" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT + - name: Build Changelog + id: build_changelog + uses: mikepenz/release-changelog-builder-action@v5 + with: + configurationJson: | + { + "template": "# 📋 Changelog\n\n## 🚀 Changements\n#commits", + "fetch_via_commits": true, + "fetch_reviewers": false, + "fetch_release_information": true, + "sort": "DESC", + "commit_template": "- #TITLE ([#HASH](#URL))", + "max_commits": 100, + "ignore_labels": ["ignore"] + } + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Create GitHub Release uses: softprops/action-gh-release@v2 From d91ebb1b8781ddc199d13106a707939b2b2be3fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 5 Jan 2026 12:37:55 +0100 Subject: [PATCH 008/124] fix --- .github/workflows/release.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 133214f..7808d26 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,13 +72,11 @@ jobs: configurationJson: | { "template": "# 📋 Changelog\n\n## 🚀 Changements\n#commits", - "fetch_via_commits": true, - "fetch_reviewers": false, - "fetch_release_information": true, + "fetchViaCommits": true, + "fetchReviews": true, + "fetchReleaseInformation": true, "sort": "DESC", "commit_template": "- #TITLE ([#HASH](#URL))", - "max_commits": 100, - "ignore_labels": ["ignore"] } env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 18d26b7be84f5e711f1a45fd74855928d7a0d773 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 5 Jan 2026 12:47:40 +0100 Subject: [PATCH 009/124] fix --- .github/workflows/release.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7808d26..fa69152 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,13 +69,10 @@ jobs: id: build_changelog uses: mikepenz/release-changelog-builder-action@v5 with: + mode: "COMMIT" configurationJson: | { - "template": "# 📋 Changelog\n\n## 🚀 Changements\n#commits", - "fetchViaCommits": true, - "fetchReviews": true, - "fetchReleaseInformation": true, - "sort": "DESC", + "template": "# 📋 Changelog\n\n## 🚀 Changes\n#commits", "commit_template": "- #TITLE ([#HASH](#URL))", } env: From 1394ff90e8fac43124e91b69393aaf71c26a6155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 5 Jan 2026 13:09:45 +0100 Subject: [PATCH 010/124] fix --- .github/workflows/release.yml | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa69152..6a43e21 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,8 +72,28 @@ jobs: mode: "COMMIT" configurationJson: | { - "template": "# 📋 Changelog\n\n## 🚀 Changes\n#commits", - "commit_template": "- #TITLE ([#HASH](#URL))", + "template": "#{{CHANGELOG}}", + "categories": [ + { + "title": "## Feature", + "labels": ["feat", "feature"] + }, + { + "title": "## Fix", + "labels": ["fix", "bug"] + }, + { + "title": "## Other", + "labels": [] + } + ], + "label_extractor": [ + { + "pattern": "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\\([\\w\\-\\.]+\\))?(!)?: ([\\w ])+([\\s\\S]*)", + "on_property": "title", + "target": "$1" + } + ] } env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -100,9 +120,9 @@ jobs: RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url) RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body) - AUTHOR_NAME=$(echo "$RELEASE_INFO" | jq -r .author.login) - if [ -z "$AUTHOR_NAME" ] || [ "$AUTHOR_NAME" = "null" ]; then AUTHOR_NAME="Portabase"; fi - AUTHOR_ICON="https://github.com/${AUTHOR_NAME}.png" + + AUTHOR_NAME="Portabase" + AUTHOR_ICON="https://github.com/Portabase.png" PAYLOAD=$(jq -n \ --arg title "$RELEASE_TITLE" \ From cd95a1c385a37b65c8f7c258a31ba353f7cf0e84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 5 Jan 2026 16:39:55 +0100 Subject: [PATCH 011/124] remove: compile.txt --- compile.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 compile.txt 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 From 5db09058b5524f59c1451f615c392f081663cfc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 5 Jan 2026 16:40:12 +0100 Subject: [PATCH 012/124] add: github related files --- .github/CODE_OF_CONDUCT.md | 128 ++++++++++++++ .github/CONTRIBUTING.md | 110 ++++++++++++ .github/ISSUE_TEMPLATE/bug_report.md | 38 ++++ .github/ISSUE_TEMPLATE/feature_request.md | 20 +++ CITATION.cff | 26 +++ LICENSE | 201 ++++++++++++++++++++++ 6 files changed, 523 insertions(+) create mode 100644 .github/CODE_OF_CONDUCT.md create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 CITATION.cff create mode 100644 LICENSE 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..c31ff21 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,110 @@ +--- + +# 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. [Reporting Issues](#reporting-issues) +3. [Submitting Changes](#submitting-changes) +4. [Code Style Guidelines](#code-style-guidelines) +5. [Pull Request Process](#pull-request-process) +6. [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/ + ``` + +--- + +## 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! 🙌 + +--- \ No newline at end of file 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/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..36b909b --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,26 @@ +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 +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: 0.0.0 +date-released: "2026-01-01" \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1851dee --- /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 2024 Soluce Technologies + + 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 From 36b98cf7020f0420f1302c37ecd2b021f0aefa51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 5 Jan 2026 16:40:15 +0100 Subject: [PATCH 013/124] add: github related files --- .github/SECURITY.md | 50 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/SECURITY.md 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 From ebcc0ed798a55ff5637aca6fca080f5a2b9fca1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 5 Jan 2026 16:40:31 +0100 Subject: [PATCH 014/124] update: release version injection --- .github/workflows/release.yml | 6 +++--- main.py | 12 ++++++++++-- pyproject.toml | 4 ++-- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a43e21..8cc5186 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,12 +38,12 @@ jobs: shell: bash run: | VERSION="${{ github.ref_name }}" - echo "" >> __init__.py - echo "__version__ = \"$VERSION\"" >> __init__.py + VERSION="${VERSION#v}" + python -c "import re; p='pyproject.toml'; c=open(p).read(); c=re.sub(r'version = \".*\"', f'version = \"{VERSION}\"', c); open(p, 'w').write(c)" - name: Build binary run: | - uv run pyinstaller --onefile --name portabase_${{ matrix.os }}_${{ matrix.arch }} --paths=. main.py + uv run pyinstaller --onefile --name portabase_${{ matrix.os }}_${{ matrix.arch }} --paths=. --add-data "pyproject.toml:." main.py - name: Upload artifacts uses: actions/upload-artifact@v4 diff --git a/main.py b/main.py index 5115a68..49b7acd 100644 --- a/main.py +++ b/main.py @@ -2,7 +2,15 @@ from typing import Optional from commands import agent, dashboard, common, db from core.utils import console -from __init__ import __version__ + + +try: + import tomllib + from pathlib import Path + with open(Path(__file__).parent / "pyproject.toml", "rb") as f: + __version__ = tomllib.load(f)["project"]["version"] +except (FileNotFoundError, KeyError, ImportError): + __version__ = "unknown" app = typer.Typer(no_args_is_help=True, add_completion=False) @@ -34,4 +42,4 @@ def main( app.add_typer(db.app, name="db") if __name__ == "__main__": - app() \ No newline at end of file + app() diff --git a/pyproject.toml b/pyproject.toml index 22ce2c6..642d3e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "portabase-cli" -version = "0.1.0" -description = "Add your description here" +version = "0.0.0" +description = "The official command line interface (CLI) for managing and deploying Portabase instances with ease." readme = "README.md" requires-python = ">=3.12" dependencies = [ From 8b11e7306774e9f001cbdfb4f04658ea6d980723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 5 Jan 2026 16:40:48 +0100 Subject: [PATCH 015/124] update: README.md --- .github/assets/logo.png | Bin 0 -> 35814 bytes README.md | 56 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 .github/assets/logo.png diff --git a/.github/assets/logo.png b/.github/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c9b9962cbec4315c3dd9859a64104a9ce86e4995 GIT binary patch literal 35814 zcmZVm2{_fy_W+L1wOrw%4Y!mwJ8`pDma9dPt*n)}D#;R}WcMniMN*csFG&<3k|V}!hu*3{mlse zrK>x@c9P`5%P2x#yk=RPSrgRqTP^u-hQqJxFLn5F^YY(~Ne>e%n9^X_Wn z0OWRTeIvHu6#i%;rm!m1zKgwz)3Z%qi1eSH`sokz3-dukr=uD_9TVLbE%%3)7fposLeWn7*{coPYO0&9nK&ITaJ4NmBuWg@pb^NjrahoiPqRYmG49ZLfDI(mH#h#k--Z16#?h9#Pjnq=rOL7F!&x7`t=qN+ z1mhA$&F07bl_yT6+;knfb@aq*dr{RFZ?s3BYdDsWGFqGb=c=jA)ioA z%mKpkhR}OH0YA-7#wv9n)e_Kz)|eKaxc=0aPfgC7t{Mv)jQBdySQ`odX^$REk_sgw zMkEIXIQh{zfX7zmWC*WsvHth5Suq!c7@e#y2_~fGFZZOT3p75o1AVt)s8-ymY%U*O z6!l2j0YS1bL8gsQpH#7tbt-#cO7i_G=5lg0QAHovABi`^D?8KXxbu=iyNR7;CMuoC zo-3G6yl@hs+d7zM#ZOXSKH*!L^Hfb%;3AJns*i1@oWNN5n5k#XSNOb&8Yirs_b(W*@lp;J-gh_jj(ceWH$Z6TlF6jB5@s0cW@)Zco_|$5u5vhjBi4+QE}_W z*27OxO!v=>LnQCoYO_j*shlaTMmB_aakqgi$~g8A@NB`G)6M1;O6u1Yky#UCfg0$R zutOL`Y&n#zQ{s4t^eoS($z=41r-C*oBKE^lvb2SF2iWN4XWsKH)J41WK4g%r&ZkW{ z*ADsLmb||jq1SkmH23E9B#- zO|U5{b&iKVTqn8u#Hpr{V{CsOLh#TfwkZS@u7=z@T$ZmyWVNi9$S!+ z5AppjbpcjzQqLfxfvpv6v>khvDVuC~tcZ?bj0U7D*qC}vO&zsE2sLfT$RsV3LodE& z&c%5@pV}h8hUjj1XTEp&gzkqj?9|YUaKI~N55`Nxf?u(EtmC9x+}bA&4v}a?dQV$T zOMNHaum}drr;Z#*;9T)ftn-N$};VawA4B z1}uBmfs`Wbsxs-Z9pn_-14i{HhTSJJ@3~J!mTdHOR`DDtj}5ORp3l#nQk8AUhOA_O za?IscmpM$r(kA}q6yK>t$bADI#4E)W`_0Gosha>B-S9?!bnX3B2sOCLB9yRiN`dx++E`2`0rN4N5bbY2S>(yQh#~1t9nR^qgzZwZ(_DJu%qeSAmZb?ZMF3)FWpA#%wBq*&0?=TTe=I;gS_WC(ZHoq zkaV>M5GMZ)hqhDmNta2el?Gyvx4(CN@w7+GsC~36`f3-OB z?52im;|>s_+r0;$e!AJ?dIK~@;xGwWMUEC(p9;Q|@sbl+l?+Y&sBq=nfS7S$Dv3g= zu5Y?F-*gaR9?P=|5ofWm$y!c$D2W5|7KU%Yl!XDOtRBc)?{$UwcYNNAJh2}34M)gA z7wD0F-JOI~Pej;0*9%F6pI7i;ZctX2j(dmij2Bt+AtsM9^eIB$c-{Yz#cCrkbG@sr z4DM|T)i|l02If0&7Z?~RJH3m*6X$b6$sZBg*Ar`q7}C&V5_Y)0w1XcpIWhZqSI8s! z*^5A{ORS$j6?!4-C*GDP{`v?1EXH3wG0hlDV1J(24m(I#BcfuNBK{Y!pkdcDP_RFz zL=-`mx}z}tA8-pJC*|@%+4H7Ta?k6##$Z+k)*qvE|45OcIcZ}RHf9qy05rt}OE#Oo zo#k_ji-cZ($?CHS#h5mi*47EgF1?CQ&t|aVLIwOb(%Sd4h-#keZ2osu0eO zuGfo|_nj_I0(%CKy$`_HG!6`f7SI9NyOOSe>7xD z;V<`qnal(JNeXe89Lxc*;>6>)-u<|H_gq|6XE6K&`;Lr@H8#JVD72H%vuD-*G6z5y zzixuTw?K8{hiY}edvhrB{k?zd$x2WhWndg_f$+;NJEIh0Z83L|YCrMZM~J zN~d}b}E5%s(TVs@YHc_|m3_F)yWy4;xGKC3Cfjwo^5Fg*BRQ4V7@sDt;d zN)JsyfNThqV=uBYbr)wcqR{wfVA_7Q-Q0+BTTl;-RPQ0Lc4NO%K>WT&ZMwB9zvmjn za5tbKCVg`f^~DNA)VDP#H0(i)OpGQS>_N_lVn zx_@do3H`8#qeSNZIWW^N zMc1;tDHB0h!z}Y~m~w3+9E0Ql3NqOM!keO7puvym$<~9*Ui+-81ZJ3nGScmV$Ge8v zP?MUtu&KxHb?oTFt7~E?b8qqO&WZQ!?*kCiWUYg&t@1g~l_+HrsAqC@_wY-9m3PYE zfTjboGInrmeYpGW1QwtX!?73Jhe^n{fdjB$I*B31nWrAb#qpAM+?Ee3RJ3BkWeS3G{Txq$H4VC$sUr( zv}*l~m_PM%qDt3rpaJp5D@tHIB6eU~UqLZw)0 zMe|o*eXD58%ba=AzuQ8bL|=Gzk)23-48yuhv-R1Cq%9Q)1sqcW2YrTx4{MfmOTIJi z;;OS0uW!5q_p-c=+~DG3`hJ}l0wVaoIW6DYSZ_DOgIuCOdIv%y;=$k{{~EFKZ$^j+ zQv$e}O<~h&UOen5;gf~D+PN_?xT|AvBhG?+F-z(^Hr*E{!VXlBuhXkH5`mIeWY8l5`N=5 zZ*#?NI4Gv;GN?u+xrh){+{A}u!BtBS0JTER-=*%1?q+$yvUY%x#jp`vM2j4g4)5n? zT|o)KL)ZI2Cz%o*;E8j# zP1s5gOgzVz7UU9#v0R2H@4$>{+N35VK)_iD4g(?nQ{E=@JKX$4W938B(WZR@?+PIOs9oP`73%@oNJ~ILPf1&U;%3F!M$ifd0`a=Wm z?AriX@hSqICL08V+q6*k{XEDdH&Qyp zaq&!MZjH5pJ0}vjzIbGJzp%az0CJDF0Eh&9g>0w*5KO|`odY#s`Rd>Ap#oP@L&k?b zQwpZG`U*flVZYjrE*rRZlG8YJhkZb96RzUPHA^%Rb7?2W*I^SNphN?FX;v#tDac(F z_M^`Go)-cuC$7O*IzZ1j?n^?95d$FSDv+%1Cnt*G9v#xKl6~$yN6H2g zKR>S}O&So#tM4doK62a$_)6_qf>_rj^)TIHklQN;wSiu2$Uh2@(Xu;zdMh8CW&1T4 zY$zGi!}#=RZbZ`(-ws03{S}~(EF0S0dnWKS+)FnBHNvsB!m76z_P*VtRBNmYvyeG|? zK($hUgv8X?(2Jcgci0qYirkOpd!v7z#pisfbKq&C7iQUw97GXFR`)3-@Y#@?blVmn zrW=@2=JV`5Y^eK)xFvwWyJ?9k{dM;=(98R9^+%7JdWg6I+qP+m0Ty=$gSiPt2%?4b z4B2x@z5Nhhxx7|wa$CmtyT#nCfU6>Q`t_RG%1o@jmqdm;+9HKfn_wAnX~#jA*; zrXWC&T6C~T^y)JATl^@SDb`pJFYfu7jlLP+T<;8{@w`?g5z!|{?ZM5lc4ns?>h*m( z<_yjel0PaNO$LS3ml?+c)DdZ*pu$NakC)TJAk>m4+hWmU`y$~ zUwnJ#v>~Fd0vY>ihIy1HSKgm%3ryRn}%`@0%9r^8$2yti$_1Vqk#I#j7 z2V%afZxI_(=wI``P!$y}rGb>O#_|*l!U|wTnSWWsWaLg8!YRr;q>Xgb4~N6q0wj|8 zA90yL+^6Hv5EtgN#hEHoii=*BZklzY*Nh^F3%FTc;F2O54qm+e+V%P) z+yIL8lH%hy8Ef!f_e7QhO)MUPXwvxcUQ-9pW!-wpPT98t4J1cok%(m&SN;NPZkmQT z^V8n(Zi<9`7Z`&|Nd{u9iSu8Dy4l2h-6fQOCak`Z8dr%S&0C3pcges48-oMZp>z#{ zQ-HD$RvWR0pis-?V*O&$w97rC*I?AA9?84z5i>3!`8%#-5A&k!5AfUr% z8MrP`mttD6e$P+uV~x)UWDEG^Zjx)9k~x zv!C1=0~UvR{naZRKp^IQflW+0?z#~R_eUbB-J^kr>CWo-;s`e?-<$yoJ3}3#RAn&_ z>=`Ip#1bwgh;kWR5#%(YeJzQ^@@Ruia@y^AA)Xlche1I&D#@T)-yh=!js z7^LeftF8Z^(H4>U3dg}0$19lU@B+BZj=HUz(I1dLlU!TPAx^jn-zO3JAZ(ej8F1`n zg5Yu>8F7GUBmWwHANk!x!Q)MGB~T;+v=)bFT5{98k|qG7z(|koidE=`7);T(FM7Vj za|7&kZXTcSIAUOu(G?H2ow0s?s6&X@hF5xD2gfyHe+#Z~iV@)E)GG}H4N0v|hM*B^ zibN0`QWTxvBa}rLvT#R$>xv^v!N%{$H`e%L2lzGU?DPQO`iB}$(1Xg~+HgkVpfd3z z>L<3L)R>^{Rh%hWc#@J5?2pM*Za7V14VkXf=;Xv5a8of^2W(OqJp)sAD)BWb84}?u z0QKZWJoEsuaRcs=(-_y|a7+-XvA`AZ6;iZby@ENxO43}S_ed%7cz`n4I7)pv#%ee4 z(Y&S%OK}C}BOHJ!s_y}drKRpUy!0b@jQt6hHE@x0O8CiUf-_x(v1F}uZbT1Q-Cwc@ z;grV5R~c}Se;CjP`hael#5)2(V}KLaKr1r$MMAJ2g=FG?hk^eL$|La;ka}wh*y#v-Ygmtk&1SDnP z%uW#nsz7TJ#^~Ye8LCv{LuPoW=Jil^_%t4}xC;fA!m#E*;18Pb&i{i4mlo)UHXtDI z5t;D)4@*NBp@LV0#*77N!;MhF+`N?UYzCx0EydLkK*|gzNW`|;&%1>X+VoqYzYKi< zQ^0C?W2}Fu#Z|37^LFb>e!jR-#jsg8*Z6s^-G2CEd)kD$ zYtZcJ$!*#;A%bH!zZAKD*(-&iF%No6t)O1d@8Tp&R#fPn99g%Oo6^@6v|MK7bUN>* z8$##*+b^v9g0k_cM~8p1LtBcf`j%JVn~fZ)StqE;ZoNI#@y#uK>7DVCpOcBpeNyC1sLZuPXA7U}Uv!>zMITK-9C_J&$93^$i{^XzO_k-4qvbw zFt2nt{p$5%g^!sv(LYalj|@+!$L?+Yb%9fd&9mH4L&LdiA=%h)J7y*|)<@yf(CqN+ z@wL(@04(9{hrBE1CAZ&Su3mH}wNk@f{rfrZ=8mCl>#%rSw~UnR+&XH}Qtv)Kx@Hwp z)nlTB!$o}qyG0~i&f2}aK|3!PC%mt5=Ve)v)upL}kC&_<;$Gw~xMT$c-7vq0+-){3 zp;~W#sTdWks{Yp!d`Lh4+vO*k$viJPK-TADFAEq>-fYT-W!irH?dPS6j5Cy6;Yb zam?aUts$C#=tX{%wuwQU2hX{$Mb#k2SlY@D@MOxAQr7mJue3(X^3ai*`LuiPf{y~NB@gr$1VV4z0D=?P7ehD0J>YO2cl9)>}D>@WUN1C zz>@UdK2v^Uq14KPK8SGBhqU*%cYQYMOhnYi}*7tQ& z8rlcbIYt86$X1GTcy*e(WL8w1hRd&~x#$cBC68ko6N;gHkq@VxpP|J;PA|!f4;&PC ze%9jMo8c!6b|>3wzt6GNIjxkpWi0#`nDCSqqn9C#qmVXdyn*jL*Wxi7&*WL}GNDj)#vJ0GqsN#PlM1Rz0%2%M_5?B|EC=lKLyfY6Fpbz_u+&y6-MGA&J zwm`-eQ=?!9cjj$^1W7;m^-yQPv}xy_q0(#Z_g52TD`+%* zjB$#u=#tB)V~kY>oRF_RlbN{)Q2*=^zWVS}-Kc2=8(BBiy4LHd#~!>oGW(TTnl^b) z)AevN2PCx4>wwhCO9(-T6(}aApL(7TK<|YJtwv**s+Ic5pgYEx#x(0A(og>AF-y3q z)P#SjVnFrrV=ZngSrU8jTS4_kR+bpXGGh|6jh8DUS#d^+?~y$BcLk96ffhK{zN<14 z$;E;*u-R9Lm8!+Vs|Fm=Z6hW?7O_@dMG21w4NH@FJk`iz%ePkc28t z`40jTD5al+k9nAb;*#(Hiz|32s|m>Gi?H-&t-8YL&-~Dh++w>*WWR6LVZWY4oDY-o zJ92gG5$-p6ez>h>Cv=As=L;1JaTjebB+*lf@3fP2cQTouVh%wcqrr`F6txOj|T;zP}0igDj*=bDc^VdGYhmGfXPvUH*S5 zcJK}IRze&z7IFs4%q!G2u=DPYl{z~ilW%Q>6QaHA@eFs1#hj|)1&BzhtL=tM0Oa<= z0?C;-i1VJ(e#P%D@m_y8okRW3~61Mz$nc&ypXhtTUBmfT_%%@ zmD?6AyRfS0{wiDm z@gjAw*J#FLDgKaI^{CIrl?%%|WGEa@Kz4fw_tOKS=#`KnXez?Lm^at-9dK2>F&_T#LVS5O<56E|hw>L{vIGL@2N`QCb z36^kahY+`cqVg+d`=EJ7?Wx7?RbN_Y{a@hAhMBPrt@_8gf1!hB>Zz z?t^HT>g1>?;dy%4p}WS$^{GM;3-K3?|5pd$u#rK{gH&(EaWxE!S+&Mq_Rb@N3kAz) z6cZN4`$gmj0UtO#9zR9iELS`>w+{u?<5Vv+riZdmlW38l5_5)|N>1Swj;MliR+!+_FSPNvXuaz$UrM2z2aMJ)V)Ql! zm;=E&_UIKfmzc$!>h4k4>2`41Qz)=dHFI{e<=936Hu^%n_LhABgwe*zcA9J;RfatI z{w;cQydf#lbo|NA4QAGr{P)+O)BXAj&0QDY$&`U^M)@X!*uTJ zZ8R2+H%{SU3sL#;^3p+3hxu15B6b^&U5;2+xS_J1&Y8XU(27OUx|3^?ziW~zSA%Ql zR~=pW9vrEAwNMEIC$#`f1{`NA_2PP^u0d$g&IE}l4{z?79x0faGz-% zYk`D4MZTrhlSpZ96T>QX_cM1{7MlHP^|`0s>F5uX z(v)vLoHz$ro)7sBXnFocael>Xq1vG`;_M~a0u7^;xhz<+@4o4b?JVPWxk=bv{kP-Q zn*UOyt!OQ!*39D{o(#>WV;aWKxsvVJIVk(yMQ24VDiI@~Vm$Nifte{}MP+?aJz#-uH&7 zFYTk1HX-mohNBnMZnQ{w1~ikR zQybW8kM4daDd#I3$T}IA?i#fgWlj)Qb~losYJDj@;9#ZoTNICuFVHNXZ0lsd4ua98xqFu^m9Re!rZuXV%MldBp~l*FDQagfCTemtYHNfeM

p0 z!Ua~;m`wTf4}uF8O7w)l4}f+7Dy z)kaeilkB`{gN%W=MhBlYI41Qk?hb-n+UsOpYYXhws?n3td?4;GI;^bWIja ztMqFB$m=)lG{~hJtN(4baC;3-__kY;PqnU~SU{ctT6X(v#qb{R|7a(J`KTq|IsYN0 zYPTKETk4Y@BU(bXE{lka75ZMaNjL4@kXO;3lsi6J>}`D!{A%tH`J}seW9ZR-T$Bt> zfVThb&c0zED%sw91!ebYmS{D}DvYK;T7Vj*V@Qi~ZacNGu2TSyMgg7)kNn?u)bFQW zv@@hB<4_(nU#AGD(+i%6)}b0OQy{+!EoJh)Pg0fLQu5=CdXNPEskX3WGu}o+YIShm zW&gDgRUtS1ssUvuvdmP&qvPiuM~7T9*RJ?K00;h2R_5dXug^%q!wTvj%pUw8bO0(e z`bUW=mGF|JBM(haG4uZN6|uYyf z{{7OyMSVm&M^8a1Xe!THBU+t@Bn)G&HlMn_5&bDPohVrG(Budcj)YQP$dmySAZ?ue z%HX@_<{$BllY@(rPtDU4QPi-Q?g%pHq=bH7^wJPD-!bs9O!@GSI0jB!BFXF>T#$Uk zobh`ZU||T@hT)hbLvR~-z^C_p_Y5-s-C=4uu%-4ZD-s21#6I=IQ+JEw)0$+@8US+t zPDf%(+#N0L1JfPar65V)FMV4N;2|4!|G$SG?LIl-sczHS6(+L~>juRs`P0)^wmgfZ zF6AexI~-|KYr3QO>NGy z#h_TosXxD}74qk&;r{^f#`G0mZf$v>{l6j4VuGP~j?ejRC_lM#(Hh8ApMDBu0BU>e z(xCX)wBjMp4rZgDh?v=d5SaIO>@L*>5DuMLytZG#-ako3pR;%Ca> zcaqJeFp!T<%lEkJ!+maawz%taWKu}_rw)#t)hwJoWO5KC2A^^}Co2z}5(B$<_ox^K zK|EF@vpZYnn@)Hvy?sInRCN-VwXbjWT9kQh3$^uTyoLDY#!u{IZ2qz@I-dbPywBD@ zboY-j?ZrS%qU0jg&lboyIyP(V{XBnprZ@nHk0BU*GO()dj2NZ!ABlQ8nzvOz(s}IT zUKXNsTW(nz!m4>ppBn5*Avbw!}fM@x%oVq z?`+MhXp24pn*Q(MJY7$vP1~TG1xB@d-8C*lP>DSleP*>RFSW#@|DEAt zOqp&7HY|8#e^xxh+wLOkL?E0x2SxAo2U`f!l&>w0%CxGU8_|*Frq=v%*#mV0xT!!X zhM$Faz#AtCs?9fDR$oe>nxIyBmlInEm#TQFF|pT>HmmSYJ+2ck`x{2tLLNcoCvKF^7b8>m zBE3J{&foZ8&;iQ5`B4TuSU8Lg1pVU;4{2NF%Uc`vZg#lPv)hY}DGjBU(8!Fq{;hAW zY?frS*4$xjBt=c*%QGRh52CuFE`v2w*SrBLW z^pJuF139nm%posIW2LJ7s8t}Qy2tLBQiKJl*Dg3fZMpp#u=3Qf2`!&Ck*=r7yr5N& z!|~-R#rR$V6a#fibA2tb4SWoem<4zcWsft$v{n!#%17l!&zsANPcj!nJ4VKcOgpaj zTKSdxFCrCAyuPa}+z?G|)%%r|QH+uW-nSFF z-PVOWj!Y}!!a3YN7qV*Vs)3y#-PBA-CU!m z=qt$6Bj&ZWr4#e&hJOBkhszrmoO3&GC`lMf4B0Mh`nje4VRZETb_qzrSfxY#jpV9K zrE|11S8L_M@yvs33#$qy^ar)uL+jJz(ckQuvb0~r^IBE)J#Ti9$XP$#tRFdAI5rEO zkypFu_{mSkaK7Zr$P%J#mAi0dnIKFJ)~prKJVRH%dn~9&FYU9P(1$?O-Rgqb4R<(snN?@nH|pm+ zsj_W%bLAp>q^C-zev6$HjZosG*cTao2)eU?kBDRsa3davWIQ`hI+%U?(|6UqRv8>L zu|1&NTiHFI5_Y+U?<4mBJ-fd?9X%@|ud#&5`J&LsQ4)P{CQK;~ywtMlxd~xhcZK|| zB9r6b4ux*lZ4)OR^0{8?$zDM*{#pr!u=^M|M9p6c&+6NH<<{IKIgQ|g;S=kP*3!FcA);YnFTuIp}p(4UK>LSD3dAB_~hsX)wUod4ID=$$Ej8oD8M7=RWVs z>ca1I@{P&YPn`+&+lJ=0RFAEBl3S#RGLjihP)dMCP9L5s__&weCHM3|U1dntGF3EQ z!MIO-YBaYgRA4;_Wcahq)?j$&3mwZ!9%+`vaHS5N3y6Q+B1`c3Zhd7^YHCOuumwne zuT1D38QXd8J%7sTP!)ZyZ6ZkZ=u%TkM*P2(>J z;GOR}tEus8_qyV-CVLBH-6HnK1U+&N=gKWv9S6CgTE!uW*D`;;6Fs}Snk?~p8t-ob87 zuQqyQN=g33X#F`YECIt9fr2WwD@M;kRkfd|i={C;b1USQ46m5?rLCa!`nHs_A_g(> z+Ntf|ypVTQR#M0#)r%@`napTrg!#5tBfkap7A&w~8qrT{UDa*M!FRnvvgIYy$E@{L z-k+=GGcACxd;aPNrH(?#Xm=BG*A+C{;;+GwlAyvD5Hodc9d_obC~L#rTI%(tUP{%& zCD>L$Y{Bgs)X7=#+c%+Pcj+J(e$NCRfA0%DlPtA+_f4{gEzhU2?XT-yQum6a{`Jv+ z-I+92Rx|Bf$LHB;RU~4msC>k`{hP;3GtV|ctrH3r@3F1pr>^E~1oa;XX!(q`^B~zR z3#;2ZY9CGTe@t$7)1n~H$V2z3S{)U6cQS@Tp*j(zT<*K0C>t18n(((zO*pJe1oL(c4U?2dRIrAo!N1Z@5s4#p2N1c z?sbEcPc3SPtBK2vKr&I6{Gd2C$uEeR!upVP;<3OQBFr}{x#u8yegAmIuqkC&fI3(V z=IV8glNe!hkb}<_UP?K?yQn2yd`q-_nmIWyjuL{6?Vw;iav112kkQgCp-IWx=A2pb zg#xv^!8uCBceIo%7`vAF4hnxco{}HR~b%RK*&L4c};aiP2 zr^k-*mn=W|p2EyC9Bs7L6DW1t9r0%`klMS>O<$#WEqeIjY(G4g3UQUSe7|#;ObNrB zX6)+Ay0vmUyV+2oV+cIsB6MVMJ*Hmc3lZ8%Tqi-?%3ICNF!_}G-S|`P(4F=o0d|^l zdXRHOTv2)2V4=1l4C&bp70Ga2pc&X}KOZ`h6EAw7ll)En&S-yMUck^*H3@3X**RXO zlOBykh(Z}M15z7>uWQ5@Cb&dz{7t-U#EBu}rHo79UX0WI3LfC6X0(%I>x-v01i5y7 z=4aCUV?}+oGjORLlv-5>x+2}Xe9>;w1etvFxv_nt8=2zu4pgerL1j5}NvQ>zlS3-; zleOdjiL~q2sQGlve}ke(MB7!LWxfhshcNo1iF7r!0n{*fV7~Qoz5^5oMbJP_5Q2f5LZOm;2rNDrNg`w-D-JZ>8?mh6$ zQ^B^-&5bKX!hXz@lFLQGs!8-fK<98U39WnkM^UUx7ZwVQNc6~St2qCtB~TH2xRh#t zl>=X|njxbMxHA4xM1t@l&#OS^aBk_0DSQt(UbY(FVI7(RT$PLEj-!W7-)dcM;(8`V z3`lBsLXct!W#3W(`p$T^!5jy^7`{)`@{DV&Ji=$NxSky{aaVe~Cgbud7W` z%aizSUtNHaPH~mYx&<#oEHbCIG=AFdWLu|wj;%=&$|6<#R?%>H%|r>qh;%*VI$yl$ zIGVm!edl1oqgUmBjbUq<<}CeWBwnPzTT>`h%sm&waQ?0E6zb2RhG_P6!#W@eV~+K? z`ME^(HHhG(xEexop+?o`0XK6S_bMW(I8NojC`ihOEXeM>ZU|<|ild(r$C2;xLlXs& zDs3{)ao8*_d{UY07-FD)ut)21Ed6ycibX0cQDC!hidWnKq^ zFdhYjJzrhYEQSkBh)-*vsGXh+%Z43(A9wH3gsm6@DCKk%A~U6u$C0X&p>xF3PYNEm zS|o*k$S|(YhMyf?%o>u_ph0fP!e1VO=8(s*@z3LavBRR($ z#nt`r0vlE~d%(L2To%;);G6zdD<}(LDgONSgJEZ6k;wy9v7n|he-YwO>^IxC2A)%R zi^Ez!CoI|NAB8k`6tU;Y%i2ULCo}$H!JB?qeXf4e2Oi}SuXcT|MAfGg`(io~`BMseQ;zptK4t!}kBVJ;B$ z&Tg5?lOD2k%`@{A2hMJo$(9Y!;%DZoOgY~+U@f>a5-LY-IS1AiOh7@%?MKHm)*O!z zhcYz?Q{hOQoKyhWq7M{#7Y4{E0HGw#%{`xU`A~)A4o52B{_Y*w)|^P*#pWb8dXegL z^Xsc=dH{tv8s(zkX&6C}{yyv55hMa`D5SZ5^+8eeIk{%8AIH#b$A}>N?fW(|kXFML z2p-Ff6$*sKL&^7+X)2K}TO1({i*LLCOrH&WXGa({5hMjGiy+{{*YEkK?gt6Y0#qU zxSH&G1|Xxl4~SCt$p4P;A1Gwae%a>a6oI9MdBS|F8N@rsoF}(LC2%kf+N-GGN5hEW zsV_0~Y8hf|5kL1MJcDGV8!hrN*t#(yj#x<#=~ z>f>I^$mxphFN`au3we{_F~Qj1v-6sZ=VR}H54lgwGq_%A&(O-sP*itUPKbuj*1Rhb zUq{6xNvOn858Re^XLULAFS zVi+|JR8XAM<#8-2sQV8`aM&npbr2-r20J?Q`ANgbWn|sUQseAMhWU1U}TO?g7{gd!`n8(MyBf2GsZR2vyR*X<1H`vsbY5k2lEN@O(c5b+-IYWB|=O5 ztz*>~Qh^U((jE0&UEeiOF9XjEOs4%kS;Vr9-YF}CD!yLI92}Zq}EnUm@W!sSO`82x?V; zdr$XH@tI!c6dP2dp1IbOfvk_e-rabL$>;!|`Y*lubU=kuRFQ~Zs6MrL~#LDqx5f7Z9;PNxQO zQeIVqCfw}n=-vgmT;DmD6m%3w(v)jCZ1>laY0^6k(kWHE0x>jT7sBnOKez@NK#kA$ zK?8N~CmuCP=6{j&I%(S$M^dC z9)x(4vLWk$jUv?B^2iDBSaL< z9eYjKIl&Bu^+OGK2I)JL-~UeDpR8V_WXnu(0I%`&y>HLf6dzuOVjjx*kv~87Yg6kI zpaz1B)A+?Brys9_H!Oi}lGF}Ji)D>g)s{WNhw|%(R)Z9H6Q1wTt@#{`*FvjW7avtd zQW$5H4OQMn!Yh>SfZLkvZZmUVRrJ7@$1l*~4MfJnTX0Q(Jh;lcN?WUJ;2_@3^>C2Y zZV&T79(r1xdIe3hI`2PVcF4oy2W~*E1yKuTLAu0fiE{tah2M|ehO9?a%GF^@nfl;u zb@RZMB7{kqCvx{*vjuxbv%*WXwY701w9mDwX;G*RqeVdBN)MZ`ahO&x{it-=!FF!< z(&@f0H+$}06T_Z0O}PdC@;`Fa~oh1w7{F6dvwnBlu27%H(Ykm&BWnJ`l+AQ~w!J#@Ys`0{Ja7^>xN}+MDTq+ZOED_#UvKD7#jB(2lq@GdWqF z=UFWf4^mE)NwE&T?8rDVYOzxi4npE?8>zE-BM+R!QaBhx4`)@FR`rjAdo0gE>X5Dw zHFzgDflMuyLC!_+!ciAgt-JGP55)hskFMMRx_0JJ^=Y37ICNa-0Uf6gXcs>wz5X#d zST^^)K~UTEo1^UYLiqC&!S`1`I1V}P_~QcX*ITsvd8vbTtvts7&$^Tcy7`boYgP86 zGU~j#=D&>+c6*p07l~%0EW!$I_Yqi@CoR`QzPJglgjQf?(6T|JUJ*scOA;*Kn0#-t)|~G6Vj~QJ5(09rItVBn;Zxd zYP}Kh)g#IQ9p5Tjx3nn%zQTcbT5-30J4*9Tn*Wa@o;W3HMz5|(wY(-6HC$#2pt^lnDwmM`e>mCTlHG3T$EP_=Y<#s^N*%z z3miKuS(w_eJQpTCh5^;blYNy(NtKe=3t&i#M|wmB!{u40+L73D(Emc}Y`US?+&kFz z?&uC4cmO^KbM`+FIx;$&8&n|x=Zw%}>=sg&SEGcW79IR>S+41%f&`t@?-F2|6MRYkQHS9~tXW3--@1nuyT!idQ= z;7&8yCDc!m;wr$Kk<@ARgCP4fA=ih*?}LFCQd2>mkd_(FD1U`R_x0WEyBi-o#?PYB z=QK6eVr$fdx;NOTn7)h44i0hb zOsj#LKe5H};Vn5YGCG;V$Ab}EN1Jt(gO?l2q_<-ELD%2bu!ne9XGA$gfJYD-u^qNb zjhAV$)UI%!SAd8lq9gDo7}q|WmjE! z=Y$5H{{fAkk%n1f*x@RJlY;m|rj$P~;6(&U8aD2M69CO3v?sMm`wqij6F_s#W}Bi1 zL1c*L64{6^VifpmZ&E@ydHCYg5UKJ^TfvP(phNg3g_vB;u&-ZWs+x>5?t8#qwVuQh z74VD|P5(4ujkv73oMtrvhEE~-pO>FSw_jVe&-H?jhc` zHh_S914*T;W#UScnD-NGAzh<#^@&HpMj8Gy6nPrksbE~n3PG8RdS2Qg-A5e6W?8VD z$iiNYX*FBk4iy!%pFc~(gKp|$8RQa;W#yw{1=^JVk-+c$(P7U;*C=~ep`5VLj&97`eQi03ngH2z znreG8 zC=uCnMRvJGrH~U-jf6WPvYYH{V;!=*pXc1>_kI7o=bq(xKKuSOMlQihj~4<@xA<k_1U7}lt9CQ%5r!NZ_Dmfld# z9~QO)Sl=d%t=6&p=7b?`YEr+!6H^|+Opw>Mza#q`7lj@2`CYu0b^^WRbPRH{u$Ft# zIy}_-MpJ1;l6P8g(eZK zjCmGRQaFA()(3xj#loN9IMQo1L}7(X<@2m1{;>Btt=z#Hz7!;-MZtl_?9C^$uy03a zPE=1>se7(eB?5tUe=~+T2=v;#XAp8*oHcM*dz*B|l-7Mv6ZU<@{W zxKSwV1`rbg92(qjRt|)+EK9*^P>Oi6@!b)Z%5Ok^Dq>nyhOrNM1q-^S7|VlqRiNnO zoRI60VwmI6rE^4?FkD)i3Gd_)>?>IS*^2p^Sl&4Wx}KPgUED!Xk2qBfPH^de zo0BZtf0x1xw8jSSMV~>$rt#=#6j!>{Ql4FQGnlIH%#p;1(66q7@A@^VeJx1!=Zr8@ ziQx}35!a6M9|FNtg}LY;{+an@17oM36Bo@9Q9*&W%&+xoTxl6BQ$7S)M;V}co* zhn^3mctBVK6SS@_AjD#!ypI`5c|Q^=d>%-x2Nr0aV{mdL7b*ge%qfF%OE#@}O8_sC z&)h?NKK>LQ@R*#Ht4hHC^zFHbi~~XaSTJoF_M^^V(@%`+Nm~)D)^lch5C#G14t8U2 z|1TK5F2`pNVY>v6UKgNdE|vYD&~HLxvwidE7x}wGAo5&ahn`R)_F3zv;@Pq3{ykGh z5T>vRsQj`H+#XD0C>B`dmH36w-G9>4{4xv0F-F`^?#4QbTvj+DTI={=t$n4cM!Rf; zF_to^Ts1x|b5mdHM4Y~s_D_&~ESlaBoQ6P_o_nFyt0$2Dmprd5=q-o`36+2*Wc+|= zni44B*Et`LQQnWf`{7KT<9ncTUaphAS<$b}z_e`d@3GJY@h{y4D zSbUVk0nkU?^_2JL&)m?YcBaYmt7dXxuI4`NoqRCgAaAU13@xkiVdU|iMr0wLQ#aMhuEFybpSaKw% z?%Y{vijsMUdz|J355qyLoVA8lHXFHc<7G*@r)Iknbkm~xH5M_7(e zazk>Te+$RU8jN7Xk}`BK5=_xAaqhDmtZeNNZ8`k%;rS<0GnYa1+N39}*{RM4s{Wsk zdLa*%P|)a*GH?prIf&SrFUk(IhQ5w z&*?Q@EZIBl(zTZ6!07k@{EJgmFvb3|4d=j2n?4*{$npL7{J+zB`o#BN`i$)FpOUE> zw9|#2F{FoBnh!KN#sBSlc3~A&>927YLvR!5Ut^Pzx*ptAlX7(~0k{1;@ybO8#FeBK zOst%~-&aT6+7=gVNqF7#m#MPzY*sddKdnhzn)@j`zxRQVN`p0r%vL=90PL0XX~r6? zdpFYdh!R4L$9cOQm7gq7C{+=y!GhL8@bo(pSDgt;m)@+Wzl>cR%a!?s_9^X39B;y= z=U{55M58zw8bGH2HWtV&jYtk_$KRPL=ad3DBeSV(<(sRa)NTo z1p0{s540oi?>LKHKFp4;VSzcX+rk7&DVMth1s(#nw-z5xt!WB}gg8$Q+cjrY+y>zM zYE|SoCq`2o2~`Y+Ss2;92}q1B!%Q6qw=nMd0iE|pKYE*vh>qIN91dSip+7x)H*_=| z;Jv3ofH)0HYl(lc0hfCT11E(@gJ7X%n#1d6f|!;Xm!QJY)KjUAX5+n_SXjYNojx5m zbklps)%i~)-LE!5>Hos19~%)=X;uSG_kadshHn0n%Q2PU{q~|h!93Yls+FA@!Uvtc zq#Ze^4xhDlb@<*++gQZYG^M9J7xqH$IV@E`P0C9A|D~hsBXuihtO3K44$~s^o%aGD| zp>CRkR-_Ic2oXn@g{yihW3`Z zX=a7SJ98frs0?LOWVi?tc5iNbQQ{9JTnJ3tgtss>PF+rx2W>K_O;T91sdB~}X>MA0 zbcL%aN7!nGuIJW9I$Rhd&v;wI&|&l;h{D8I)h&t&`qMm&X8ayDSM*-s0h8E&@)cBd z(nFHQoi4AZ+wE0L++RD8aGyWy{CsNr6P>ygJ_?pk62qo_&ZB~~Jm+T^rRo8|vVX4K zEjkS({o9`++r6n{6yoK$w6!U^N9_3~eKg@v%DW(i32XV#>XvbGj)RtE#wA$ZXtM@Q z2|TZ$t`us~#RvZVnO8D^(<`Rp`(@VBeChYVz{ve99X`sd`UnO?MfwMB3RpuAh*?w- z5TW_rKz}DfU?9l~CPIpar(8XxSP}}L8C(NlE2j6)SSa18sAauviyTMT+cmHf3FN5a zYryFfvq=?KDvC5CSO>t##AT5j5Y%r%oD$!qYGmVN^~g2ZwZA7X``S2^1BMV9iZ zu-ERvMtqQlzM-lKr4v}-5fw=G$e|~`p(i#VAw=Z>#@?N{Byjkl#42i_?w?>0n_R>K zNKw+9d%?#N9?H0{2<-);=<{0>zk&1&EGRJ0sF_2Fsnlm%DEg5IK#$o*Efs?|%3pE4 zcET5ZMK96i7HQE=JNl^wi^ydmhO25-+DRdkMQ+@3a<8#Km!y|D6CznLFQ^s=av zlNp0b;K7%}BkhHSmn0#jHWP3%AhEuPV;hx+A^E%`=vEIZU0r^hV;};8W%{`Noh=TS z&A=7~XjA)b(owq64A|syfnDokq*OSWF>?GT^uW!aH|0!P+87cv^S;uu?3L!FN3V!A5SC|)?p*2Kz~EoL2LwA@ z+7Dxes)vW8HgJSd+<$dU6c7XuQAs;Jc+OqAiBjc>+2kbnuN-zdb(2ewv)Kv%H}!?m zkR3)pL7Fo`P30O6%u5v8$ou*6^b=f2T!ycHIT8nZ56k-qN}6Tg)X|=uD$R>a8HfCN zJC-Kzw`#d0BycTOVsi0@`Au!)#9stccKT>(um>QR5ZUHd6!U zWSd1Rt1IXqU2C|@xCHa~%jk;PQh z@6-o+z7NrIZ!)`$1*WeScTx}Id~u3%#t=q~Ty$5s|L{f7`bMDV*U0G`l?eQNxJgvEI8#b}wg-_ZtMi8ymnp4qHb0hFt%*H37$WT#EFnq) zH`Wq#`A4q==uhT6Oz0IRRAXx$^VZRr#V>XbZ4a8MxkXV;Uq$0#%d?&%CFVZ+v9{@3 zS~X#ORNDlNQ~R=44hDDnAL7JnS}*7=f`9u;$mH_Nb)PG_83JI1955|{;omlHud#(G zTWmEJt}y1?4K}>j^POgwUkaqu-w@FK)oOkROdG~`wuW!kdptqJw3;RmFQJ3Z;SJWIWrZi4u3|d-Si01;i7Fm zvj4NFsGwjxy5nHAx5S^E#O)IA4K{^B;%|@=^0c6DCN!osOcbY@3%jB?P}fG7s+v1^JO8g zlpQd9%j`|y*bYnJ*xb~_;~5>8uie+W@tcqe^fgT>*$*S&@}1xCt*LUQnQy&6yB}tO znIdj*dLCQX%*paQUD`DD})Fo zO{w?@wYRz#_J-QE3hdT=)!>i}vsNr}x;}uOxdD2n-tM7*IK6X+`grB3GWyj{izF`H z3n?-fjM1YPxXtct?5Ui~@mWLHmhUK0&E5RFQ_`1hDgyCu1YxUtf{*d``YVI8b<9sq zZTQFMpLbV?iXMQTp5QN6`|1jTZOn5oJd2Z$ zRJ_K+umo|EMZW2<oxBGO`-XK>(vR__!k)k2NzUy zVBrS_|MVFEa0{B+C;7&FI1()m(-c}>up?zJ(hCmC`;{<6_^G$=DqadXa!BNQv(h!~ zJkDFac@R%3&(@y+{Y@A=Z|2Co;;iGzgKyU;W`DEO_tA;M<%W>F<-?anu$GvVb`W+F z)<`zzy$~6K8qSRCGo{<8R4MeqdrL2#0dnu06<1$LoEV97=E2VF5kSDwG#l4q4?}D)vW-N^OIM#a+DU&btjg^Ti z$At1QN7i3cCYn@g`GRu4cTn7rX$mGLejT%IMN_EorPiFAEBE7mQObvAme*;$8F>Q< z96a&pYsLLb--;lq0Z~98d}N0zhg$v46`>bfdP7nyX zzJBwEdW{R5#;`%E>mNCsu26iS%5HWz&Y2t2&P*48G;!9z`HSfEb`Ag-u!m*C93GSR+aWBIFuBLt>!3QD;LwG@9v{Q8A>-t zYH#>tT$$kqek#7Ym9gnnjy69|KuGGGwlLh1z#}j>GS$yJ3ujC`?#ij!cB#-+ay0sO05T`D@}e8+A0 zo9ZvBT6oe9`OXaxq=?ZD|mL3w&Zc?;fqFb^}~i7gNoHNUtc`=dO+ z7-qxB5}o4U8RS2~3E?-bJmF~+H&z>-YV4PssaJO&xyL1d)wEO(1SiLf({zQOpQ!qp zwCb@01uqW_WbndZfd;%MDya*z$@uVNnM;s$@j_)-w2o#8r)~HW>CjVmsTgAJjo*@5 zAq}WD|tBm|Jt>@xSsTw}EigF=HR5iJY_u9EC9^Px%RH@@# z6wQc{Y^&CnuRXUd|RApm|bsJVJxN-i{4c+DkFqAxH+oF5~~G6(L=wVA&y z<{%bUcb8cbn5`8%TaXG*jLS>+)$h~T{uq_a_D{##F2l9Da|;ge7?gc zNl$C5PFdJEu@9mU&7Hcl^;dRSVT19Mu|=rJznCz4v2)G7vb@{Q*)+bbax(`y!StGL zOiEcb9ErVl-`F6qy19z}xGcfTFbE5(65?ev^Z4H5#UiSb$^&an%ZofeO+v<37F@%jna)l>BB0@!N4$YNObNNVV$%jdQOz!jX-*dHjTN663 z_`0@_l@=eSOai41RTb7oEYr8e%*))E@=KG>Hm zw@?Ud=YN_u$nAfh)1gYs9{w<|UKhG$Y(OouD`?Ks@G1vEiyb{fFFQ{u|Nb^cKPNSB z0hh#8oO+Ae6L}uO#Ng+moD*$1^=fsUNsmHiI_qO!YU>E|J87;2&h*c}HkQRY|N6Z{ zU}R_($}G#99#WH^AE@f%ofzPq%hMrrR_2!NZO*62Z5a_b?ax_7J2$(_#5vVeW}q-{ zaVaHLE3d*viEqmj$6b@JEm!uODdJwQFPX}1;Q9Mh>&tPTi3j0ICKoF6RHQ>=*DaQI zm18=Q9SI@Bx!;b9&r=4YDd6`5f9sxcynN~FGp8oG#AX}cXY+xL!4@?)oWr>tN~adT zOI~dZ)YE#PQa$V_nd{rI#p9#r`|J&WOuXrU(4*%M@Za{Bb09z3l`lejc`5Rydf)Zo zYg46L>nDy|J%%gqzDx`gW*-$;LR|U-XTeud{ z00=b282iH>Z6h81JnKi@fnuU3-%0pi-zsLdwJJy5=C1_c}a|TAB!q)TyL1HKuDIQ zK z$V@WRTTHWJ0Nj~rh;KQ6p;V!+VMtL*DPBpu{#^+6tlM=3`Wvy9jOZn>JH88{ckabAy@ymx^r@=ZRK9O(6c(peF5l&MT-7!Ztz3EYAUzzqE75-$XjC` z=G7TmwF5ors7yCSwE$H-Vx4_2EKmuHsM1y(vJ(X#(-#=yfzZKqY#sKc@5<9DqY^X= zaWEeMIV?JWU=A2UwK*kFAr19MFE-@#TA)wfzx}SR2$l7ASUvBl^hB2|Q3AQj1Z{u7 zs;f5>Vx$1fQ7e_tSKyk94M&IXg9#urEa~xvb1-w1zAKL-#j*p%2QVTGWzB^u9njNf zio;XxgX0Zl$53t4zYT|_ep7^~CHv6>K#2@dVSt<{U8NczYj|boqPTH>XT8gL}lc@p`pjck=Ye(-CqthtxAbDia zVJ-Wb3>6+hX<^H41P|FL_Ua#PIQJL2e% zX#r*CyK`iIoJb!H<-ly*j^Ali^hXoRF%P|1WmI*o`VnCPYP6%lItB=S6<6{=1BCI@ z5E6}MgZ!U-Nl3!1Lc2DG6xQPu#7FcQ_gSUU-V-f}kd$Svf)FX2Yd+d}#!U?0yIQd8 zEsXXmc%39M({LT`CtO=fSroLrfUPuv6(Jwz%`G?YwC=<~Vp8 z&S2qw9-dM^3E@$U8Itw2TD=c3#%3 z*UQQ_1ywZkF4xrv}y5HC8l*uh0(&DVZk`>pi0Xqd!cB2B~FiQ=ptaM4R zxxa9RGR5h{+fa8abzbFj-lBudRtI5>vmu$o1_Ij8WQM&?C{I5G(&%2w(IjYV+7Lxm7~>jXyHv711lJ6zsKVuN|n;*~eOK_bIh2B`ajKAr=Iw&?hk+%vnV5P_uk`wS5G-4lTr-eiJ9 zG&sft#*j(&?j`>;c=S4{Tmx?r!1Ay23u4r`Z4TQjZ$7;k$&W=4#T#<$M$3?%vVA|| z)nl~uDLVm&8nLsvQRK)d#lt0pJv!osfOymCrD+$m;q{f=ou~pXY@)5EVk`tf|H(#& zZVq5TDs#9~vB~g-56B0v9LpXKaqpVVm|&yt)2SM`jriNtvZ<#MWW!~skHo0CMOxtR zVTz_D1k8Yf(|L+e+6Nl2w{F)?OBCOyk9!Gh#FDl-;6_5uklo2GePvOR zw1R5T;ycdewj+|l`N3dg)Ekp(>Jx14Bd{1FD>vvcsoQ7Iqqi3zi->X=?Ax{h zCER5z@mOQ0-i9li9Ps-H6t}E}efC%M50Il$x?Sq3ZrVdQ;p%Fr^1>Wv^FS+spqaVu zb@eTi$_P~~fgls_ub#RyGPAYp$grDQIRg8*IMKY+Ru zoLZ1d+6$WW)!$>2j!s+}@6qQEv4z8hbCoxv?fpt<{JtzWu&3bv({6X7#m|>~KUkuB zDF;9f$VZiM4Dy5EVLEmp zz2xtc1*YX-O25*w{I@Lt-kmw)VBFBppHt$X4)HQ%O-Ix+T@$9lS%+O#i*LA_1toEr z;VI7WEELZ`h1)iVc&@;+av0H{LK=wF!sz=~5<$36BY3sY$o0q`W3Oiba7FWwYAeow z{1b*xOFu}LqJ$e&n$A8^n7qzKJRAP(&$0Md0)>gv6R3;k5_k)`$1QptPTU zO1SzA%xG1h7x^&=B^exsn7ZuD_0x5NvHU#ron9+{^I4NNam2k~0l`G*YGS&A<@GcO zI2qqtrw}#X@)C}0qhk0S;ItyG$RTdq-(44KD2iad+6*_9K54oGOO->qza7}|G zmjPn2{Fa|AMl+F*Vmt=Ix2f$!?7!*9rqq6c9uCA$u_XP_Y_n!uDkO6bUTX%x5x2n{ zDV)5xG#H;#5VS zFaMW1Ts|U^+v)3a*3}V^M)>)?j$3t6R^NsHrVP z`-4RM>~fDhoH;Ufa-gUT$;aSRZWJ3lhdj?S-S5EwEQk?mvXl6TAfRqNNAlJ*Yre74 z(o%pa|3Hfqib#+&p1zmEZ7TpoOcMt3BJSC};1i65OX&2&PuU9^b0ebT9RQ(NFA7xz z9`#2`lZUP6D=s=AB6IXlbcc3~F6enEfJrmmhLOz-wvf{O5G&bVbVbR5UhB1b752m& zr8)1;2O|h%MpqXL4Q(Nh%jy<^7tWs?fDb`uLaoREY?T26JFc!q#HrL^B}-1PdHq3T zAky7ap^(EJoj9Ydhg7ewT#Bol@Ce?skFWtU?B+oIkA?H<2`lFo|DeguZ%HJ zL!by>%th?N*i$!hZ+545|LPt+Ik2Yab0*@N6$p@RUrgk|(3a#HOiqCsISpj=On8TG zz$CsjZVG--e^Y?v2q5U*NeVQtUHiF!tt@Z(KDY%S*sPoNiklnDBq8fzQN2Zz4Ht$u z5+3en=6AstJdPKg>7k|elN)>pJ)yb<0A|ZR1Ng5c8A6ax!MhbH|c$qFK;Ru74BJwmlyb+n8oMGi~Bhz$Nk-$K#;l<=S@;d^U-xF|3 z7*w-j-%z$1!-EyQ!rtzA3um=j{~z~RKO@!~K+FOAhWtGfmAPaqh>ri+w$o*!E&VTu z;?OkEp1&&{)5P$xvX0%Su5nX$g3S~0@h2DVp#{F(3dI=+7u&(oz#T>%ficwi#7pQn zB_50%+J@XHJfwCjw`?Jt&Ybck5)$0Jl;5TfVFn~JiONJAVAF?SLpcuAPrW7I1?D0=~Gv4S$!(K`WEtiwQih@PgS&T-=y zP$Nqd*|zkDZr5&9X;|%BBai8WMM7UzuUX;~r$2WauL9GR$svD_{uXQn-9oEN7t=%L z7S!P#j{Z(pVQI;5@OBvT8<&nwB4hLQpHAH9uq8PD)lWI{5!&cr^ptr|;jBF70{#=- zDgGyz#*!K%WhRXt##ZzKt&p$qBBfl*L!(6V6LNA7R5+8tXk-O|b)8DO?o97m-%2w* zZhAOin9%^*>Dc_MkPok=G2Eaw$7vlV_B}AG()8|#IkZY^qgQs=^hJSZ*AH{9;%fr3 zzgueO>2rrCQe44SViX|`6P}y{QOrD^<(K_k@yxvYa;lc7Jf>&>x+ta5Mm9y6^pT0l zZ8NdV)I|KPHq>e$^r%7aCpbMA?p+&G8UAA7Y}V>^+bB3Pi+%4RTEh=q$Xq*ltIRx@ zi|K?=6hr107RgzeL{lQxlM%Am6vILE179)h*-p!q#*x6)Irkl<==*p9LbvCQO1a<(M?OW(0e%M}?G_!MCx; zpOm>)VN0OP=wqa9q_1*LA+l~*x950iOn?Z+qYtEQW9>W;B9Dv`8kQ-F86YUwH?SS& z(!Y@796VXr!GC6^Xo!n$0VF}pS_OcS4K=Ew+u&?%AZ~jL&HsQd)s7-rK*>w zFTO&dZU!OfQCIj%2F3)ftGv1Mc;BI#OB4N$DMqR%9ttGqtyJtcYEC}(JUL{s&Hj7t za{a{uiKQte15PZsJ8TV%i$cyOeW#E3O2jsq`GpV4p2zk${QI!)?@2B3tA(|}|9D3q zTHKT+76P;<4n{W_LZa`KI9Nifu27mb%XI$nBS~rar7|n@@}=qCrO*V;8z6|pgSrCp(c?yyzxq(_uzrZWuDU3y!Al03g9OVK}KBh8-xPPD+p>;3wIp z6kU;_A0h*VgXkDk1q);gDjp#3n25oM=+<|2X9CvK4<#Ry?LcQHvX0bp{~?dbAgY^0 zlpl_)K-RfR?rs+@!H8P{QnulclZS3$++r(P+lehyZ#{;I{S5q+R?lFc@^TCo_Buli z8V^IZkq(WY=c!I4A zmBWgEm$5@|gDSB6sXQ`5ACBtq*vHBCgY2`tO)8zYU=h%YfDoZFS_~EVW4Jkd;L|t+ z83_?%)EtitR(@zhbsW9&AdX@SP&0q8N|T-U0(RG2lns ziHvmc)B{Y!^HBLA+Yv!Yh$}c7_w09Z1ACM~OBjuBzApJFnUt9B>>o?6VqAkSBQ}DI_5%y@W zO#n3d0sx?*fc|tJ*rke(dTqg5gJUNFY1Y|8};#b&F z%9Nl7{Oq-Zd=L8Yy>qBN0=8oVV&b6)-4x6Z!D_oVtBI2&x%*nJqnk#DatIzo9@vGM z2~Jue$PBWa0Q>Zoi<7{>okPhYnhxnBE9wGDxWMuwt{DrF48sKfSGOLFU00-SaHq#Y zV%pEC5GSR-gY_?rf9mAM4?qjz#w_3?IyYPk%x9zHXRkOM=WUD)K4#~0aHk`{);m$P zGYq2zKlx2AaEO50e{nUSCzd`G4nu)uE%u3QMZ}>>xVA6V(5{@Y_FQZ zJa3eB637L=o+M{nMKOS9FbWkKR=f%oBFc)yS;D2MrdF)8zFQuO3a8iZ8$ zC;!R^y1d1@{7P>({XYLmX>$?tOiRZ~L|v3YqDS%DC&d?6N`*0hD1#zgz6U+sLtG&} zZEO}S0dt4YxNke~eL}<)l1f@6fFIw@%(b~?SY!R9lp`L3-Lm}?6jDEZ095?l+50Fp z`~8H7t_5s8{CR%cV~BKO^d*o(<_K>hJDVH)pp}=dseZ9;9MG`}YQ1gqNw<3Oi0~*h zJ2D=*22yNs%R9bNegKMY49N8zWFi&WP~fyI;WkG^dGc3v(yb;o!4k6nfF?a;l^j4$L2S{rR{p?v5O+>FVlfaYwX|>1J0Nv41b-MrvEVM zxkfrfRPA +

+ + Logo + + +

Portabase CLI

+ +

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

+ + +[![License: Apache](https://img.shields.io/badge/License-apache-yellow.svg)](LICENSE) +[![Docker Pulls](https://img.shields.io/docker/pulls/solucetechnologies/portabase?color=brightgreen)](https://hub.docker.com/r/solucetechnologies/portabase) +[![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] + + +

+ + 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 +``` + +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 + From 7691ef5d3dad54a2967cbae2534c9050c774273f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 5 Jan 2026 16:40:56 +0100 Subject: [PATCH 016/124] remove: old version --- __init__.py | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 __init__.py diff --git a/__init__.py b/__init__.py deleted file mode 100644 index c4e6b46..0000000 --- a/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -__version__ = "v0.0.0" - -#25 = Year -#12 = Month -#01 = Release number in the month -#Version number = 1,2,3... -#Letter = a (alpha), b (beta), rc (release candidate) or nothing for stable releases -#Beta letter = public test version before the stable version -#Example: 25.12.1b1 = December 2025, first public beta version before the stable version \ No newline at end of file From f296809217898b10ad471a0f9a6addf3c6c6335c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 5 Jan 2026 16:47:43 +0100 Subject: [PATCH 017/124] fix --- .github/workflows/release.yml | 4 ++-- .idea/material_theme_project_new.xml | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8cc5186..05c036a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,8 +38,8 @@ jobs: shell: bash run: | VERSION="${{ github.ref_name }}" - VERSION="${VERSION#v}" - python -c "import re; p='pyproject.toml'; c=open(p).read(); c=re.sub(r'version = \".*\"', f'version = \"{VERSION}\"', c); open(p, 'w').write(c)" + export VERSION="${VERSION#v}" + python -c "import os, re; v = os.environ['VERSION']; p = 'pyproject.toml'; c = open(p).read(); c = re.sub(r'version = \".*\"', f'version = \"{v}\"', c); open(p, 'w').write(c)" - name: Build binary run: | 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 @@ From d7daa6db0175336070fc3ecd4875afadb7c4cfdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 5 Jan 2026 16:49:55 +0100 Subject: [PATCH 018/124] update: bad url in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 18e9830..fd9c847 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
- Logo + Logo

Portabase CLI

From 6cc5ef3679fd7569c977b6a2db002171def8ebaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 5 Jan 2026 16:51:34 +0100 Subject: [PATCH 019/124] update: bad url in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fd9c847..77a9eee 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
- Logo + Logo

Portabase CLI

From 0ec5e7d1b6ae975bc856f2a83c0da75d440320b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 5 Jan 2026 16:52:00 +0100 Subject: [PATCH 020/124] remove: install.sh --- install.sh | 56 ------------------------------------------------------ 1 file changed, 56 deletions(-) delete mode 100644 install.sh 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 From 19b6dc75f073db2c5930404d36ef31a4daa4f2cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Tue, 6 Jan 2026 13:26:24 +0100 Subject: [PATCH 021/124] update: README.md --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 77a9eee..9535c23 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

Portabase CLI

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

@@ -24,6 +24,8 @@ ![Python][Python] +![Typer][Typer] +![Rich][Rich]

@@ -46,6 +48,8 @@ You can install Portabase CLI using bash with the following command: 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 @@ -54,3 +58,9 @@ 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 + + + From ed887764e41992ef5a8d71af55aa596ba3c72d8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Wed, 7 Jan 2026 09:30:50 +0100 Subject: [PATCH 022/124] chore(release): 26.01.1b18 --- .github/workflows/github.yml | 147 +++++++++++++++++++++ .github/workflows/python.yml | 41 ++++++ .github/workflows/release-candidate.yml | 30 +++++ .github/workflows/release.yml | 167 ++++-------------------- CITATION.cff | 4 +- pyproject.toml | 2 +- release | 96 ++++++++++++++ 7 files changed, 340 insertions(+), 147 deletions(-) create mode 100644 .github/workflows/github.yml create mode 100644 .github/workflows/python.yml create mode 100644 .github/workflows/release-candidate.yml create mode 100755 release diff --git a/.github/workflows/github.yml b/.github/workflows/github.yml new file mode 100644 index 0000000..bc49fbf --- /dev/null +++ b/.github/workflows/github.yml @@ -0,0 +1,147 @@ +name: GitHub Release + +on: + workflow_call: + inputs: + artifact_name: + required: false + type: string + default: "" + prerelease: + required: false + type: boolean + default: false + make_latest: + required: false + type: boolean + default: false + discord_title: + required: true + type: string + discord_color: + required: true + type: number + discord_footer: + required: true + type: string + secrets: + DISCORD_WEBHOOK: + required: true + GH_TOKEN: + required: true + +jobs: + create-release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Check out the repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download artifacts + if: inputs.artifact_name != '' + uses: actions/download-artifact@v4 + with: + pattern: ${{ inputs.artifact_name }} + path: dist + merge-multiple: true + + - name: Generate Checksums + if: inputs.artifact_name != '' + working-directory: dist + run: | + sha256sum * > checksums.txt + + - name: Build Changelog + id: build_changelog + uses: mikepenz/release-changelog-builder-action@v5 + with: + mode: "COMMIT" + configurationJson: | + { + "template": "#{{CHANGELOG}}", + "categories": [ + { + "title": "## Feature", + "labels": ["feat", "feature"] + }, + { + "title": "## Fix", + "labels": ["fix", "bug"] + }, + { + "title": "## Other", + "labels": [] + } + ], + "label_extractor": [ + { + "pattern": "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)", + "on_property": "title", + "target": "$1" + } + ] + } + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: dist/* + generate_release_notes: false + body: ${{ steps.build_changelog.outputs.changelog }} + prerelease: ${{ inputs.prerelease }} + make_latest: ${{ inputs.make_latest }} + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + + - name: Send Discord Notification + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + GH_TOKEN: ${{ secrets.GH_TOKEN }} + run: | + RELEASE_INFO=$(gh release view "${{ github.ref_name }}" -R ${{ github.repository }} --json name,url,body,author) + + RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name) + if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ github.ref_name }}"; fi + + RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url) + RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body) + + AUTHOR_NAME="Portabase" + AUTHOR_ICON="https://github.com/Portabase.png" + + PAYLOAD=$(jq -n \ + --arg title "$RELEASE_TITLE" \ + --arg description "$RELEASE_BODY" \ + --arg url "$RELEASE_URL" \ + --arg author "$AUTHOR_NAME" \ + --arg icon "$AUTHOR_ICON" \ + --arg discord_title "${{ inputs.discord_title }}" \ + --arg discord_footer "${{ inputs.discord_footer }}" \ + --argjson discord_color ${{ inputs.discord_color }} \ + '{ + content: $discord_title, + embeds: [{ + title: $title, + url: $url, + description: $description, + color: $discord_color, + author: { + name: $author, + icon_url: $icon + }, + footer: { + text: $discord_footer + } + }] + }' + ) + + curl -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + "$DISCORD_WEBHOOK" diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 0000000..fbbb1c2 --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,41 @@ +name: Build Python Binaries + +on: + workflow_call: + +jobs: + build: + name: Build for ${{ matrix.os }} (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + strategy: + matrix: + include: + - os: linux + arch: amd64 + runner: ubuntu-latest + - os: 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=. --add-data "pyproject.toml:." main.py + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: portabase_${{ matrix.os }}_${{ matrix.arch }} + path: dist/portabase_${{ matrix.os }}_${{ matrix.arch }} + diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml new file mode 100644 index 0000000..5db26c7 --- /dev/null +++ b/.github/workflows/release-candidate.yml @@ -0,0 +1,30 @@ +name: Publish Python binaries for release candidate + +on: + push: + tags: + - '*.*.*a*' + - '*.*.*b*' + - '*.*.*rc*' + +permissions: + contents: write + packages: write + +jobs: + python_build: + uses: ./.github/workflows/python.yml + + github_release: + needs: python_build + uses: ./.github/workflows/github.yml + with: + artifact_name: "portabase_*" + prerelease: true + make_latest: false + discord_title: "||@release-cli|| New release candidate published" + discord_color: 16776960 + discord_footer: "Portabase" + secrets: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 05c036a..a203516 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,153 +1,32 @@ -name: Release +name: Publish Python binaries for release on: push: tags: - - 'v*' + - '*.*.*' + - '!*-*' + - '!*.*.*a*' + - '!*.*.*b*' + - '!*.*.*rc*' permissions: contents: write + packages: 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: Inject Version - shell: bash - run: | - VERSION="${{ github.ref_name }}" - export VERSION="${VERSION#v}" - python -c "import os, re; v = os.environ['VERSION']; p = 'pyproject.toml'; c = open(p).read(); c = re.sub(r'version = \".*\"', f'version = \"{v}\"', c); open(p, 'w').write(c)" - - - name: Build binary - run: | - uv run pyinstaller --onefile --name portabase_${{ matrix.os }}_${{ matrix.arch }} --paths=. --add-data "pyproject.toml:." main.py - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: portabase_${{ matrix.os }}_${{ matrix.arch }} - path: dist/portabase_${{ matrix.os }}_${{ matrix.arch }} - - release: - needs: build - runs-on: ubuntu-latest - 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: Build Changelog - id: build_changelog - uses: mikepenz/release-changelog-builder-action@v5 - with: - mode: "COMMIT" - configurationJson: | - { - "template": "#{{CHANGELOG}}", - "categories": [ - { - "title": "## Feature", - "labels": ["feat", "feature"] - }, - { - "title": "## Fix", - "labels": ["fix", "bug"] - }, - { - "title": "## Other", - "labels": [] - } - ], - "label_extractor": [ - { - "pattern": "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\\([\\w\\-\\.]+\\))?(!)?: ([\\w ])+([\\s\\S]*)", - "on_property": "title", - "target": "$1" - } - ] - } - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: dist/* - generate_release_notes: false - body: ${{ steps.build_changelog.outputs.changelog }} - make_latest: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Send Discord Notification - env: - DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - RELEASE_INFO=$(gh release view "${{ github.ref_name }}" -R ${{ github.repository }} --json name,url,body,author) - - RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name) - if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ github.ref_name }}"; fi - - RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url) - RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body) - - AUTHOR_NAME="Portabase" - AUTHOR_ICON="https://github.com/Portabase.png" - - PAYLOAD=$(jq -n \ - --arg title "$RELEASE_TITLE" \ - --arg description "$RELEASE_BODY" \ - --arg url "$RELEASE_URL" \ - --arg author "$AUTHOR_NAME" \ - --arg icon "$AUTHOR_ICON" \ - '{ - 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 + python_build: + uses: ./.github/workflows/python.yml + + github_release: + needs: python_build + uses: ./.github/workflows/github.yml + with: + artifact_name: "portabase_*" + prerelease: false + make_latest: true + discord_title: "||@release-cli|| New release published" + discord_color: 5814783 + discord_footer: "Portabase" + secrets: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CITATION.cff b/CITATION.cff index 36b909b..f705707 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -22,5 +22,5 @@ keywords: - management - integration license: Apache-2.0 -version: 0.0.0 -date-released: "2026-01-01" \ No newline at end of file +version: 26.01.1b18 +date-released: "2026-01-07" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 642d3e8..cc7708d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "portabase-cli" -version = "0.0.0" +version = "26.01.1b18" description = "The official command line interface (CLI) for managing and deploying Portabase instances with ease." readme = "README.md" requires-python = ">=3.12" diff --git a/release b/release new file mode 100755 index 0000000..1dbf212 --- /dev/null +++ b/release @@ -0,0 +1,96 @@ +#!/bin/bash + +set -e + +if [ -z "$1" ]; then + echo "Usage: ./release " + echo "Example: ./release v1.0.0" + exit 1 +fi + +VERSION=$1 +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + +if [ "$CURRENT_BRANCH" = "main" ]; then + if [[ ! "$VERSION" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: On 'main' branch, only release tags (vX.Y.Z) are allowed." + exit 1 + fi +else + if [[ ! "$VERSION" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+([-.]?(rc|alpha|beta|a|b)[0-9]*(\.[0-9]+)?)?$ ]]; then + echo "Error: On branch '$CURRENT_BRANCH', only pre-release tags matching vX.Y.Z[-.](rc|a|b|...)W are allowed." + echo "Examples: 1.0.0-rc.1, 26.01.1a, 26.01.1b, 26.01.1rc1" + exit 1 + fi +fi + +CLEAN_VERSION=${VERSION#v} +CURRENT_DATE=$(date +%Y-%m-%d) + +echo "Preparing release $VERSION..." + + +# package.json +if [ -f package.json ]; then + echo "Updating package.json..." + if sed --version >/dev/null 2>&1; then + sed -i "s/\"version\": \".*\"/\"version\": \"$CLEAN_VERSION\"/" package.json + else + sed -i '' "s/\"version\": \".*\"/\"version\": \"$CLEAN_VERSION\"/" package.json + fi +fi + +# pyproject.toml +if [ -f pyproject.toml ]; then + echo "Updating pyproject.toml..." + if sed --version >/dev/null 2>&1; then + sed -i "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" pyproject.toml + else + sed -i '' "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" pyproject.toml + fi +fi + +# Cargo.toml +if [ -f Cargo.toml ]; then + echo "Updating Cargo.toml..." + if sed --version >/dev/null 2>&1; then + sed -i "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" Cargo.toml + else + sed -i '' "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" Cargo.toml + fi +fi + +# CITATION.cff +if [ -f CITATION.cff ]; then + echo "Updating CITATION.cff..." + if sed --version >/dev/null 2>&1; then + sed -i "s/^version: .*/version: $CLEAN_VERSION/" CITATION.cff + sed -i "s/^date-released: .*/date-released: \"$CURRENT_DATE\"/" CITATION.cff + else + sed -i '' "s/^version: .*/version: $CLEAN_VERSION/" CITATION.cff + sed -i '' "s/^date-released: .*/date-released: \"$CURRENT_DATE\"/" CITATION.cff + fi +fi + +git add . + +if ! git diff-index --quiet HEAD --; then + echo "Committing changes..." + git commit -m "chore(release): $VERSION" +else + echo "No changes to commit. Proceeding to tag..." +fi + +if git rev-parse "$VERSION" >/dev/null 2>&1; then + echo "Tag $VERSION already exists. Aborting." + exit 1 +fi + +echo "Creating tag $VERSION..." +git tag -a "$VERSION" -m "Release $VERSION" + +echo "Pushing changes and tags to remote..." +git push +git push origin "$VERSION" + +echo "Successfully released $VERSION!" \ No newline at end of file From fdf3049ec53685d1d29a924dddb3e7b042eaa3c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Wed, 7 Jan 2026 09:37:54 +0100 Subject: [PATCH 023/124] chore(release): 26.01.1b19 --- .github/workflows/release-candidate.yml | 2 ++ .github/workflows/release.yml | 2 ++ CITATION.cff | 2 +- pyproject.toml | 2 +- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 5db26c7..6e88579 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -6,6 +6,8 @@ on: - '*.*.*a*' - '*.*.*b*' - '*.*.*rc*' + - '*.*.*alpha*' + - '*.*.*beta*' permissions: contents: write diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a203516..f26093f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,8 @@ on: - '!*.*.*a*' - '!*.*.*b*' - '!*.*.*rc*' + - '!*.*.*alpha*' + - '!*.*.*beta*' permissions: contents: write diff --git a/CITATION.cff b/CITATION.cff index f705707..7ca4f52 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -22,5 +22,5 @@ keywords: - management - integration license: Apache-2.0 -version: 26.01.1b18 +version: 26.01.1b19 date-released: "2026-01-07" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index cc7708d..714882d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "portabase-cli" -version = "26.01.1b18" +version = "26.01.1b19" description = "The official command line interface (CLI) for managing and deploying Portabase instances with ease." readme = "README.md" requires-python = ">=3.12" From 181da319cbec6b80e0f0641339205ce992df94cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Thu, 15 Jan 2026 15:13:13 +0100 Subject: [PATCH 024/124] chore(release): 26.01.2b1 --- CITATION.cff | 4 +- commands/agent.py | 6 +- commands/db.py | 4 +- core/config.py | 2 +- core/network.py | 9 ++- core/updater.py | 165 ++++++++++++++++++++++++++++++++++++++++++++++ core/utils.py | 18 ++++- main.py | 23 ++++--- pyproject.toml | 2 +- 9 files changed, 210 insertions(+), 23 deletions(-) create mode 100644 core/updater.py diff --git a/CITATION.cff b/CITATION.cff index 7ca4f52..6be0aec 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -22,5 +22,5 @@ keywords: - management - integration license: Apache-2.0 -version: 26.01.1b19 -date-released: "2026-01-07" \ No newline at end of file +version: 26.01.2b1 +date-released: "2026-01-15" \ No newline at end of file diff --git a/commands/agent.py b/commands/agent.py index 0251042..5f292fc 100644 --- a/commands/agent.py +++ b/commands/agent.py @@ -76,7 +76,7 @@ def agent( "password": password, "port": port, "host": host, - "generatedId": str(uuid.uuid4()) + "generated_id": str(uuid.uuid4()) }) console.print("[success]✔ Added to config[/success]") @@ -116,7 +116,7 @@ def agent( "password": db_pass, "port": pg_port, "host": "localhost", - "generatedId": str(uuid.uuid4()) + "generated_id": str(uuid.uuid4()) }) console.print(f"[success]✔ Added Postgres container (Port {pg_port})[/success]") @@ -152,7 +152,7 @@ def agent( "password": db_pass, "port": mysql_port, "host": "localhost", - "generatedId": str(uuid.uuid4()) + "generated_id": str(uuid.uuid4()) }) console.print(f"[success]✔ Added MariaDB container (Port {mysql_port})[/success]") diff --git a/commands/db.py b/commands/db.py index cdb6030..4d2d643 100644 --- a/commands/db.py +++ b/commands/db.py @@ -36,7 +36,7 @@ def list_dbs(name: str = typer.Argument(..., help="Name of the agent")): 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] + "..." + db.get("generated_id", "")[:8] + "..." ) console.print(table) @@ -63,7 +63,7 @@ def add_db(name: str = typer.Argument(..., help="Name of the agent")): "password": password, "port": port, "host": host, - "generatedId": str(uuid.uuid4()) + "generated_id": str(uuid.uuid4()) } add_db_to_json(path, entry) diff --git a/core/config.py b/core/config.py index e76625d..1fd209b 100644 --- a/core/config.py +++ b/core/config.py @@ -3,7 +3,7 @@ import uuid from pathlib import Path -TEMPLATE_BASE_URL = "https://portabase-cli.s3.fr-par.scw.cloud/templates/v1" +TEMPLATE_BASE_URL = "https://s3.eu-central-3.ionoscloud.com/portabase-software/cli/public/templates/" def write_file(path: Path, content: str): path.parent.mkdir(parents=True, exist_ok=True) diff --git a/core/network.py b/core/network.py index 9fd1d69..149760e 100644 --- a/core/network.py +++ b/core/network.py @@ -2,14 +2,21 @@ import typer from rich.console import Console from core.config import TEMPLATE_BASE_URL +from core.utils import current_version console = Console() def fetch_template(filename: str) -> str: - url = f"{TEMPLATE_BASE_URL}/{filename}" + version = current_version() + url = f"{TEMPLATE_BASE_URL}/{version if version != 'unknown' else 'latest'}/{filename}" + try: with console.status(f"[dim]Fetching template from {url}...[/dim]"): response = requests.get(url, timeout=10) + if response.status_code == 404 and version != "unknown": + url = f"{TEMPLATE_BASE_URL}/latest/{filename}" + response = requests.get(url, timeout=10) + response.raise_for_status() return response.text except requests.RequestException as e: diff --git a/core/updater.py b/core/updater.py new file mode 100644 index 0000000..e2e0012 --- /dev/null +++ b/core/updater.py @@ -0,0 +1,165 @@ +import requests +import subprocess +import time +import json +import os +import platform +import sys +import shutil +import typer +from pathlib import Path +from core.utils import current_version, console + +GITHUB_REPO = "Portabase/cli" +GITHUB_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest" +CACHE_FILE = Path.home() / ".portabase" / "update_cache.json" + +def get_platform_info(): + system = platform.system().lower() + if system == "darwin": + system = "macos" + machine = platform.machine().lower() + + arch = "amd64" + if machine in ["arm64", "aarch64"]: + arch = "arm64" + elif machine in ["x86_64", "amd64"]: + arch = "amd64" + + return system, arch + +def get_latest_release_data(): + try: + response = requests.get(GITHUB_API_URL, timeout=5) + response.raise_for_status() + return response.json() + except Exception: + return None + +def check_for_updates(force=False): + if not force and not getattr(sys, 'frozen', False) and platform.system().lower() != "windows": + return None + + current = current_version() + latest_tag = None + + try: + CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) + if not force and CACHE_FILE.exists(): + with open(CACHE_FILE, "r") as f: + cache = json.load(f) + if time.time() - cache.get("last_check", 0) < 86400: + latest_tag = cache.get("latest_version") + except Exception: + pass + + if latest_tag is None: + data = get_latest_release_data() + if data: + latest_tag = data.get("tag_name", "").lstrip('v') + try: + with open(CACHE_FILE, "w") as f: + json.dump({"last_check": time.time(), "latest_version": latest_tag}, f) + except Exception: + pass + + if not latest_tag or current == "unknown": + return None + + if latest_tag != current: + console.print(f"\n[warning]⚠ A new version of Portabase CLI is available: [bold]{latest_tag}[/bold] (current: {current})[/warning]") + console.print("[info]Run [bold]portabase update[/bold] to update.[/info]\n") + return latest_tag + return None + +def update_cli(): + if not getattr(sys, 'frozen', False) and platform.system().lower() != "windows": + console.print("[warning]⚠ The update command is only available for the binary version of Portabase CLI.[/warning]") + console.print("[info]If you installed via source, please use [bold]git pull[/bold] to update.[/info]") + return + + data = get_latest_release_data() + if not data: + console.print("[danger]✖ Could not fetch latest release data from GitHub.[/danger]") + return + + latest_tag = data.get("tag_name", "").lstrip('v') + current = current_version() + + if latest_tag == current: + console.print(f"[success]✔ Portabase CLI is already up to date ({current}).[/success]") + return + + try: + if latest_tag < current and not (".rc" in current and not ".rc" in latest_tag): + console.print(f"[warning]⚠ Latest remote version ({latest_tag}) appears to be older than current ({current}).[/warning]") + if not typer.confirm("Do you want to continue with the update (downgrade)?"): + return + except Exception: + pass + + system, arch = get_platform_info() + asset_name = f"portabase_{system}_{arch}" + if system == "windows": + asset_name += ".exe" + + asset = next((a for a in data.get("assets", []) if a["name"] == asset_name), None) + + if not asset: + console.print(f"[danger]✖ Could not find binary for your platform ({system}/{arch}) in the latest release.[/danger]") + return + + console.print(f"[info]Updating Portabase CLI from {current} to {latest_tag}...[/info]") + + try: + if system == "windows": + default_bin_path = Path(os.environ.get("APPDATA", "")) / "Portabase" / "portabase.exe" + else: + default_bin_path = Path("/usr/local/bin/portabase") + + if getattr(sys, 'frozen', False): + current_exe = Path(sys.executable) + else: + if default_bin_path.exists(): + current_exe = default_bin_path + else: + current_exe = Path.home() / ".local" / "bin" / ("portabase" if system != "windows" else "portabase.exe") + + console.print(f"[info]Target installation path: {current_exe}[/info]") + + download_url = asset["browser_download_url"] + temp_file = Path(f"/tmp/portabase_update") if system != "windows" else Path(f"{current_exe}.new") + + with console.status(f"[bold magenta]Downloading {asset_name}...[/bold magenta]"): + response = requests.get(download_url, stream=True) + response.raise_for_status() + with open(temp_file, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + if system != "windows": + temp_file.chmod(0o755) + + if system == "windows": + if current_exe.exists(): + old_exe = Path(f"{current_exe}.old") + if old_exe.exists(): old_exe.unlink() + current_exe.rename(old_exe) + temp_file.rename(current_exe) + else: + need_sudo = not os.access(current_exe.parent, os.W_OK) or (current_exe.exists() and not os.access(current_exe, os.W_OK)) + + if need_sudo: + console.print("[info]Permissions required to install to /usr/local/bin. Using sudo...[/info]") + subprocess.run(["sudo", "mv", str(temp_file), str(current_exe)], check=True) + subprocess.run(["sudo", "chmod", "+x", str(current_exe)], check=True) + else: + current_exe.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(temp_file), str(current_exe)) + + console.print(f"[success]✔ Successfully updated to {latest_tag}![/success]") + + except Exception as e: + console.print(f"[danger]✖ An error occurred during update: {e}[/danger]") + if 'temp_file' in locals() and temp_file.exists(): + temp_file.unlink() diff --git a/core/utils.py b/core/utils.py index 90fb7fd..62bd19f 100644 --- a/core/utils.py +++ b/core/utils.py @@ -57,4 +57,20 @@ 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 + return path + +def current_version() -> str: + + try: + import tomllib + import sys + from pathlib import Path + if getattr(sys, 'frozen', False): + base_path = Path(sys._MEIPASS) + else: + base_path = Path(__file__).parent.parent + with open(base_path / "pyproject.toml", "rb") as f: + __version__ = tomllib.load(f)["project"]["version"] + except (FileNotFoundError, KeyError, ImportError, AttributeError): + __version__ = "unknown" + return __version__ diff --git a/main.py b/main.py index 49b7acd..d8c4a36 100644 --- a/main.py +++ b/main.py @@ -1,26 +1,20 @@ import typer from typing import Optional from commands import agent, dashboard, common, db -from core.utils import console - - -try: - import tomllib - from pathlib import Path - with open(Path(__file__).parent / "pyproject.toml", "rb") as f: - __version__ = tomllib.load(f)["project"]["version"] -except (FileNotFoundError, KeyError, ImportError): - __version__ = "unknown" +from core.utils import console, current_version +from core.updater import check_for_updates, update_cli 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__}") + console.print(f"Portabase CLI version: {current_version()}") + check_for_updates(force=True) raise typer.Exit() @app.callback() def main( + ctx: typer.Context, version: Optional[bool] = typer.Option( None, "--version", @@ -29,7 +23,12 @@ def main( is_eager=True, ), ): - pass + if ctx.invoked_subcommand != "update": + check_for_updates() + +@app.command() +def update(): + update_cli() app.command()(agent.agent) app.command()(dashboard.dashboard) diff --git a/pyproject.toml b/pyproject.toml index 714882d..a60b05b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "portabase-cli" -version = "26.01.1b19" +version = "26.01.2b1" description = "The official command line interface (CLI) for managing and deploying Portabase instances with ease." readme = "README.md" requires-python = ">=3.12" From be68049b07589c4b3bba33e5ae1ab991a8d7be17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Thu, 15 Jan 2026 15:13:18 +0100 Subject: [PATCH 025/124] chore(release): 26.01.2b2 --- CITATION.cff | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 6be0aec..6bdd699 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -22,5 +22,5 @@ keywords: - management - integration license: Apache-2.0 -version: 26.01.2b1 +version: 26.01.2b2 date-released: "2026-01-15" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a60b05b..9a14a50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "portabase-cli" -version = "26.01.2b1" +version = "26.01.2b2" description = "The official command line interface (CLI) for managing and deploying Portabase instances with ease." readme = "README.md" requires-python = ">=3.12" From d69da038bd88f78d3b11fe7ce120aa1e00291e27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Thu, 15 Jan 2026 15:13:32 +0100 Subject: [PATCH 026/124] chore(release): 26.01.2b3 --- CITATION.cff | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 6bdd699..65a8186 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -22,5 +22,5 @@ keywords: - management - integration license: Apache-2.0 -version: 26.01.2b2 +version: 26.01.2b3 date-released: "2026-01-15" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 9a14a50..6952870 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "portabase-cli" -version = "26.01.2b2" +version = "26.01.2b3" description = "The official command line interface (CLI) for managing and deploying Portabase instances with ease." readme = "README.md" requires-python = ">=3.12" From 3f7638fb4ef02b5bd0bcf92152ee9259defc868a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Thu, 15 Jan 2026 15:26:25 +0100 Subject: [PATCH 027/124] chore(release): 26.01.2b4 --- CITATION.cff | 2 +- commands/config.py | 23 +++++++++++++++++++++ core/config.py | 25 +++++++++++++++++++++++ core/updater.py | 51 +++++++++++++++++++++++++++++++++++----------- main.py | 3 ++- pyproject.toml | 2 +- 6 files changed, 91 insertions(+), 15 deletions(-) create mode 100644 commands/config.py diff --git a/CITATION.cff b/CITATION.cff index 65a8186..320f250 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -22,5 +22,5 @@ keywords: - management - integration license: Apache-2.0 -version: 26.01.2b3 +version: 26.01.2b4 date-released: "2026-01-15" \ No newline at end of file diff --git a/commands/config.py b/commands/config.py new file mode 100644 index 0000000..4f7a4e9 --- /dev/null +++ b/commands/config.py @@ -0,0 +1,23 @@ +import typer +from core.utils import console +from core.config import set_config_value, get_config_value + +app = typer.Typer(help="Manage global CLI configuration.") + +@app.command() +def channel( + name: str = typer.Argument(..., help="Update channel name (stable or beta)") +): + name = name.lower() + if name not in ["stable", "beta"]: + console.print("[danger]✖ Invalid channel. Choose either 'stable' or 'beta'.[/danger]") + raise typer.Exit(1) + + set_config_value("update_channel", name) + console.print(f"[success]✔ Update channel set to: [bold]{name}[/bold][/success]") + +@app.command() +def show(): + channel = get_config_value("update_channel", "auto (based on current version)") + console.print(f"[info]Current Configuration:[/info]") + console.print(f" [bold]Update Channel:[/bold] {channel}") diff --git a/core/config.py b/core/config.py index 1fd209b..96e9cc1 100644 --- a/core/config.py +++ b/core/config.py @@ -4,6 +4,8 @@ from pathlib import Path TEMPLATE_BASE_URL = "https://s3.eu-central-3.ionoscloud.com/portabase-software/cli/public/templates/" +GLOBAL_CONFIG_DIR = Path.home() / ".portabase" +GLOBAL_CONFIG_FILE = GLOBAL_CONFIG_DIR / "config.json" def write_file(path: Path, content: str): path.parent.mkdir(parents=True, exist_ok=True) @@ -26,6 +28,29 @@ def write_env_file(work_dir: Path, env_vars: dict): content += f'{k}="{v}"\n' write_file(env_path, content) +def load_global_config() -> dict: + if not GLOBAL_CONFIG_FILE.exists(): + return {} + try: + with open(GLOBAL_CONFIG_FILE, "r") as f: + return json.load(f) + except: + return {} + +def save_global_config(config: dict): + GLOBAL_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + with open(GLOBAL_CONFIG_FILE, "w") as f: + json.dump(config, f, indent=2) + +def get_config_value(key: str, default=None): + config = load_global_config() + return config.get(key, default) + +def set_config_value(key: str, value): + config = load_global_config() + config[key] = value + save_global_config(config) + def load_db_config(path: Path) -> dict: json_path = path / "databases.json" if not json_path.exists(): diff --git a/core/updater.py b/core/updater.py index e2e0012..0acfea2 100644 --- a/core/updater.py +++ b/core/updater.py @@ -9,11 +9,16 @@ import typer from pathlib import Path from core.utils import current_version, console +from core.config import get_config_value GITHUB_REPO = "Portabase/cli" -GITHUB_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest" +GITHUB_API_BASE_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases" CACHE_FILE = Path.home() / ".portabase" / "update_cache.json" +def is_prerelease(version: str) -> bool: + v = version.lower() + return any(x in v for x in ['a', 'b', 'rc', 'alpha', 'beta']) + def get_platform_info(): system = platform.system().lower() if system == "darwin": @@ -28,11 +33,17 @@ def get_platform_info(): return system, arch -def get_latest_release_data(): +def get_latest_release_data(pre=False): try: - response = requests.get(GITHUB_API_URL, timeout=5) - response.raise_for_status() - return response.json() + if not pre: + response = requests.get(f"{GITHUB_API_BASE_URL}/latest", timeout=5) + response.raise_for_status() + return response.json() + else: + response = requests.get(GITHUB_API_BASE_URL, timeout=5) + response.raise_for_status() + releases = response.json() + return releases[0] if releases else None except Exception: return None @@ -41,6 +52,15 @@ def check_for_updates(force=False): return None current = current_version() + if current == "unknown": + return None + + channel = get_config_value("update_channel") + if channel: + include_pre = (channel == "beta") + else: + include_pre = is_prerelease(current) + latest_tag = None try: @@ -54,7 +74,7 @@ def check_for_updates(force=False): pass if latest_tag is None: - data = get_latest_release_data() + data = get_latest_release_data(pre=include_pre) if data: latest_tag = data.get("tag_name", "").lstrip('v') try: @@ -63,7 +83,7 @@ def check_for_updates(force=False): except Exception: pass - if not latest_tag or current == "unknown": + if not latest_tag: return None if latest_tag != current: @@ -78,20 +98,27 @@ def update_cli(): console.print("[info]If you installed via source, please use [bold]git pull[/bold] to update.[/info]") return - data = get_latest_release_data() + current = current_version() + + channel = get_config_value("update_channel") + if channel: + pre = (channel == "beta") + else: + pre = is_prerelease(current) if current != "unknown" else False + + data = get_latest_release_data(pre=pre) if not data: console.print("[danger]✖ Could not fetch latest release data from GitHub.[/danger]") return latest_tag = data.get("tag_name", "").lstrip('v') - current = current_version() if latest_tag == current: console.print(f"[success]✔ Portabase CLI is already up to date ({current}).[/success]") return try: - if latest_tag < current and not (".rc" in current and not ".rc" in latest_tag): + if latest_tag < current and not (is_prerelease(current) and not is_prerelease(latest_tag)): console.print(f"[warning]⚠ Latest remote version ({latest_tag}) appears to be older than current ({current}).[/warning]") if not typer.confirm("Do you want to continue with the update (downgrade)?"): return @@ -99,7 +126,7 @@ def update_cli(): pass system, arch = get_platform_info() - asset_name = f"portabase_{system}_{arch}" + asset_name = f"portabase_{{system}}_{{arch}}" if system == "windows": asset_name += ".exe" @@ -162,4 +189,4 @@ def update_cli(): except Exception as e: console.print(f"[danger]✖ An error occurred during update: {e}[/danger]") if 'temp_file' in locals() and temp_file.exists(): - temp_file.unlink() + temp_file.unlink() \ No newline at end of file diff --git a/main.py b/main.py index d8c4a36..f2a0afe 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,6 @@ import typer from typing import Optional -from commands import agent, dashboard, common, db +from commands import agent, dashboard, common, db, config from core.utils import console, current_version from core.updater import check_for_updates, update_cli @@ -39,6 +39,7 @@ def update(): app.command()(common.uninstall) app.add_typer(db.app, name="db") +app.add_typer(config.app, name="config") if __name__ == "__main__": app() diff --git a/pyproject.toml b/pyproject.toml index 6952870..31b8622 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "portabase-cli" -version = "26.01.2b3" +version = "26.01.2b4" description = "The official command line interface (CLI) for managing and deploying Portabase instances with ease." readme = "README.md" requires-python = ">=3.12" From 28f5a99fe9f81cf8bebea118900cbbed6b947234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Thu, 15 Jan 2026 15:27:19 +0100 Subject: [PATCH 028/124] chore(release): 26.01.2b5 --- CITATION.cff | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 320f250..9226b86 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -22,5 +22,5 @@ keywords: - management - integration license: Apache-2.0 -version: 26.01.2b4 +version: 26.01.2b5 date-released: "2026-01-15" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 31b8622..8ee8763 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "portabase-cli" -version = "26.01.2b4" +version = "26.01.2b5" description = "The official command line interface (CLI) for managing and deploying Portabase instances with ease." readme = "README.md" requires-python = ">=3.12" From 540116c1c6c9418390cc66b9108564966f6e3262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Thu, 15 Jan 2026 15:41:26 +0100 Subject: [PATCH 029/124] chore(release): 26.01.2b6 --- CITATION.cff | 2 +- core/config.py | 2 +- core/network.py | 2 +- pyproject.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 9226b86..5c15ffa 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -22,5 +22,5 @@ keywords: - management - integration license: Apache-2.0 -version: 26.01.2b5 +version: 26.01.2b6 date-released: "2026-01-15" \ No newline at end of file diff --git a/core/config.py b/core/config.py index 96e9cc1..1146ede 100644 --- a/core/config.py +++ b/core/config.py @@ -3,7 +3,7 @@ import uuid from pathlib import Path -TEMPLATE_BASE_URL = "https://s3.eu-central-3.ionoscloud.com/portabase-software/cli/public/templates/" +TEMPLATE_BASE_URL = "https://s3.eu-central-3.ionoscloud.com/portabase-software/cli/public/templates" GLOBAL_CONFIG_DIR = Path.home() / ".portabase" GLOBAL_CONFIG_FILE = GLOBAL_CONFIG_DIR / "config.json" diff --git a/core/network.py b/core/network.py index 149760e..fcfe850 100644 --- a/core/network.py +++ b/core/network.py @@ -13,7 +13,7 @@ def fetch_template(filename: str) -> str: try: with console.status(f"[dim]Fetching template from {url}...[/dim]"): response = requests.get(url, timeout=10) - if response.status_code == 404 and version != "unknown": + if response.status_code in [403, 404] and version != "unknown": url = f"{TEMPLATE_BASE_URL}/latest/{filename}" response = requests.get(url, timeout=10) diff --git a/pyproject.toml b/pyproject.toml index 8ee8763..3012522 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "portabase-cli" -version = "26.01.2b5" +version = "26.01.2b6" description = "The official command line interface (CLI) for managing and deploying Portabase instances with ease." readme = "README.md" requires-python = ">=3.12" From 5d4d6d7ee31bddbc80b70cdd49261e8f06a29185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Thu, 15 Jan 2026 15:49:25 +0100 Subject: [PATCH 030/124] chore(release): 26.01.2b7 --- .github/assets/templates/agent.yml | 26 +++++++++++++ .github/assets/templates/dashboard.yml | 39 +++++++++++++++++++ .github/workflows/release-candidate.yml | 12 ++++++ .github/workflows/release.yml | 12 ++++++ .github/workflows/templates-upload.yml | 50 +++++++++++++++++++++++++ CITATION.cff | 2 +- pyproject.toml | 2 +- 7 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 .github/assets/templates/agent.yml create mode 100644 .github/assets/templates/dashboard.yml create mode 100644 .github/workflows/templates-upload.yml diff --git a/.github/assets/templates/agent.yml b/.github/assets/templates/agent.yml new file mode 100644 index 0000000..5c851f7 --- /dev/null +++ b/.github/assets/templates/agent.yml @@ -0,0 +1,26 @@ +name: ${PROJECT_NAME} +services: + app: + container_name: ${PROJECT_NAME}-app + restart: always + image: portabase/agent:latest + volumes: + - ./databases.json:/config/config.json + extra_hosts: + - "localhost:host-gateway" + environment: + TZ: "Europe/Paris" + EDGE_KEY: "${EDGE_KEY}" + LOG: info + networks: + - portabase + +{{EXTRA_SERVICES}} + +volumes: +{{EXTRA_VOLUMES}} + +networks: + portabase: + name: portabase_network + external: true \ No newline at end of file diff --git a/.github/assets/templates/dashboard.yml b/.github/assets/templates/dashboard.yml new file mode 100644 index 0000000..f5f08bb --- /dev/null +++ b/.github/assets/templates/dashboard.yml @@ -0,0 +1,39 @@ +name: ${PROJECT_NAME} +services: + portabase: + container_name: ${PROJECT_NAME}-app + image: solucetechnologies/portabase:latest + env_file: + - .env + ports: + - "${PORT}:3000" + environment: + - TIME_ZONE=Europe/Paris + - HOSTNAME=0.0.0.0 + - PORT=3000 + volumes: + - portabase-private:/app/private + depends_on: + db: + condition: service_healthy + db: + container_name: ${PROJECT_NAME}-pg + image: postgres:17-alpine + ports: + - "${PG_PORT}:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - PROJECT_NAME="Portabase" + - PROJECT_URL=${PROJECT_URL} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 +volumes: + postgres-data: + portabase-private: diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 6e88579..c64c173 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -30,3 +30,15 @@ jobs: secrets: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + template_upload: + needs: python_build + uses: ./.github/workflows/templates-upload.yml + with: + version: ${{ github.ref_name }} + is_prerelease: true + secrets: + S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }} + S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} + S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} + S3_BUCKET: ${{ secrets.S3_BUCKET }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f26093f..1a9a422 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,3 +32,15 @@ jobs: secrets: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + template_upload: + needs: python_build + uses: ./.github/workflows/templates-upload.yml + with: + version: ${{ github.ref_name }} + is_prerelease: false + secrets: + S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }} + S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} + S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} + S3_BUCKET: ${{ secrets.S3_BUCKET }} diff --git a/.github/workflows/templates-upload.yml b/.github/workflows/templates-upload.yml new file mode 100644 index 0000000..3963b47 --- /dev/null +++ b/.github/workflows/templates-upload.yml @@ -0,0 +1,50 @@ +name: Upload Templates to S3 + +on: + workflow_call: + inputs: + version: + required: true + type: string + is_prerelease: + required: true + type: boolean + secrets: + S3_ENDPOINT: + required: true + S3_ACCESS_KEY: + required: true + S3_SECRET_KEY: + required: true + S3_BUCKET: + required: true + +jobs: + upload: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install S3cmd + run: sudo apt-get update && sudo apt-get install -y s3cmd + + - name: Configure S3cmd + run: | + cat < ~/.s3cfg + [default] + access_key = ${{ secrets.S3_ACCESS_KEY }} + secret_key = ${{ secrets.S3_SECRET_KEY }} + host_base = ${{ secrets.S3_ENDPOINT }} + host_bucket = %(bucket)s.${{ secrets.S3_ENDPOINT }} + use_https = True + EOF + + - name: Upload Versioned Templates + run: | + CLEAN_VERSION=$(echo "${{ inputs.version }}" | sed 's/^v//') + s3cmd sync .github/assets/templates/ s3://${{ secrets.S3_BUCKET }}/cli/public/templates/$CLEAN_VERSION/ --acl-public + + - name: Upload Latest Templates (Stable Only) + if: ${{ !inputs.is_prerelease }} + run: | + s3cmd sync .github/assets/templates/ s3://${{ secrets.S3_BUCKET }}/cli/public/templates/latest/ --acl-public diff --git a/CITATION.cff b/CITATION.cff index 5c15ffa..5d28c2d 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -22,5 +22,5 @@ keywords: - management - integration license: Apache-2.0 -version: 26.01.2b6 +version: 26.01.2b7 date-released: "2026-01-15" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 3012522..1cb69d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "portabase-cli" -version = "26.01.2b6" +version = "26.01.2b7" description = "The official command line interface (CLI) for managing and deploying Portabase instances with ease." readme = "README.md" requires-python = ">=3.12" From 3fac2bfdeea8a6dc982770c62ca9641ca5ea79fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Thu, 15 Jan 2026 15:55:09 +0100 Subject: [PATCH 031/124] chore(release): 26.01.2 --- CITATION.cff | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 5d28c2d..4e15bfb 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -22,5 +22,5 @@ keywords: - management - integration license: Apache-2.0 -version: 26.01.2b7 +version: 26.01.2 date-released: "2026-01-15" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 1cb69d0..c3f6b5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "portabase-cli" -version = "26.01.2b7" +version = "26.01.2" description = "The official command line interface (CLI) for managing and deploying Portabase instances with ease." readme = "README.md" requires-python = ">=3.12" From 43e537c00d3a7fcaf09f7452a88b45521e23e304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Thu, 15 Jan 2026 15:57:54 +0100 Subject: [PATCH 032/124] chore(release): 26.01.3 --- CITATION.cff | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 4e15bfb..a9202eb 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -22,5 +22,5 @@ keywords: - management - integration license: Apache-2.0 -version: 26.01.2 +version: 26.01.3 date-released: "2026-01-15" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index c3f6b5e..505cf14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "portabase-cli" -version = "26.01.2" +version = "26.01.3" description = "The official command line interface (CLI) for managing and deploying Portabase instances with ease." readme = "README.md" requires-python = ">=3.12" From 44ebc1e506fd2e56a08abb46878804f804c459b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Thu, 15 Jan 2026 16:07:38 +0100 Subject: [PATCH 033/124] chore(release): 26.01.4 --- CITATION.cff | 2 +- core/updater.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index a9202eb..4515c50 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -22,5 +22,5 @@ keywords: - management - integration license: Apache-2.0 -version: 26.01.3 +version: 26.01.4 date-released: "2026-01-15" \ No newline at end of file diff --git a/core/updater.py b/core/updater.py index 0acfea2..e291cfc 100644 --- a/core/updater.py +++ b/core/updater.py @@ -126,7 +126,7 @@ def update_cli(): pass system, arch = get_platform_info() - asset_name = f"portabase_{{system}}_{{arch}}" + asset_name = f"portabase_{system}_{arch}" if system == "windows": asset_name += ".exe" diff --git a/pyproject.toml b/pyproject.toml index 505cf14..0c6572b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "portabase-cli" -version = "26.01.3" +version = "26.01.4" description = "The official command line interface (CLI) for managing and deploying Portabase instances with ease." readme = "README.md" requires-python = ">=3.12" From 5f619915f313f40a8c3c0ba5449e256f8d2be32d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Thu, 15 Jan 2026 16:11:59 +0100 Subject: [PATCH 034/124] chore(release): 26.01.5 --- CITATION.cff | 2 +- core/updater.py | 3 +++ pyproject.toml | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 4515c50..e68dc91 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -22,5 +22,5 @@ keywords: - management - integration license: Apache-2.0 -version: 26.01.4 +version: 26.01.5 date-released: "2026-01-15" \ No newline at end of file diff --git a/core/updater.py b/core/updater.py index e291cfc..81f18d6 100644 --- a/core/updater.py +++ b/core/updater.py @@ -134,6 +134,9 @@ def update_cli(): if not asset: console.print(f"[danger]✖ Could not find binary for your platform ({system}/{arch}) in the latest release.[/danger]") + available_assets = [a["name"] for a in data.get("assets", [])] + console.print(f"[info]Target asset name: {asset_name}[/info]") + console.print(f"[info]Available assets: {', '.join(available_assets)}[/info]") return console.print(f"[info]Updating Portabase CLI from {current} to {latest_tag}...[/info]") diff --git a/pyproject.toml b/pyproject.toml index 0c6572b..a2fcf02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "portabase-cli" -version = "26.01.4" +version = "26.01.5" description = "The official command line interface (CLI) for managing and deploying Portabase instances with ease." readme = "README.md" requires-python = ">=3.12" From baf9f1604b62b596422390d74d6b8df23eb7022c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Thu, 15 Jan 2026 16:17:44 +0100 Subject: [PATCH 035/124] chore(release): 26.01.6 --- CITATION.cff | 2 +- core/updater.py | 34 ++++++++++++++++++++++++++++------ pyproject.toml | 2 +- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index e68dc91..5faaf87 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -22,5 +22,5 @@ keywords: - management - integration license: Apache-2.0 -version: 26.01.5 +version: 26.01.6 date-released: "2026-01-15" \ No newline at end of file diff --git a/core/updater.py b/core/updater.py index 81f18d6..6f61a1b 100644 --- a/core/updater.py +++ b/core/updater.py @@ -158,14 +158,36 @@ def update_cli(): console.print(f"[info]Target installation path: {current_exe}[/info]") download_url = asset["browser_download_url"] - temp_file = Path(f"/tmp/portabase_update") if system != "windows" else Path(f"{current_exe}.new") + import tempfile + # Create a temporary file that won't have permission issues or conflicts + fd, temp_path = tempfile.mkstemp(prefix="portabase_update_") + temp_file = Path(temp_path) + os.close(fd) - with console.status(f"[bold magenta]Downloading {asset_name}...[/bold magenta]"): - response = requests.get(download_url, stream=True) + try: + response = requests.get(download_url, stream=True, timeout=15) response.raise_for_status() - with open(temp_file, "wb") as f: - for chunk in response.iter_content(chunk_size=8192): - f.write(chunk) + total_size = int(response.headers.get('content-length', 0)) + + from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, DownloadColumn, TransferSpeedColumn + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + DownloadColumn(), + TransferSpeedColumn(), + console=console + ) as progress: + task = progress.add_task(f"Downloading {asset_name}...", total=total_size) + with open(temp_file, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + progress.update(task, advance=len(chunk)) + except Exception as e: + if temp_file.exists(): temp_file.unlink() + raise e if system != "windows": temp_file.chmod(0o755) diff --git a/pyproject.toml b/pyproject.toml index a2fcf02..9149709 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "portabase-cli" -version = "26.01.5" +version = "26.01.6" description = "The official command line interface (CLI) for managing and deploying Portabase instances with ease." readme = "README.md" requires-python = ">=3.12" From 88ce856faf8827e4bf77abc23346eb5691f547e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Thu, 15 Jan 2026 16:22:22 +0100 Subject: [PATCH 036/124] chore(release): 26.01.7 --- CITATION.cff | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 5faaf87..93c3da9 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -22,5 +22,5 @@ keywords: - management - integration license: Apache-2.0 -version: 26.01.6 +version: 26.01.7 date-released: "2026-01-15" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 9149709..899e210 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "portabase-cli" -version = "26.01.6" +version = "26.01.7" description = "The official command line interface (CLI) for managing and deploying Portabase instances with ease." readme = "README.md" requires-python = ">=3.12" From 8356aef68028a35b6b634fa3bc5c36089f12278a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Thu, 15 Jan 2026 16:24:21 +0100 Subject: [PATCH 037/124] chore(release): 26.01.8 --- CITATION.cff | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 93c3da9..0ca9e44 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -22,5 +22,5 @@ keywords: - management - integration license: Apache-2.0 -version: 26.01.7 +version: 26.01.8 date-released: "2026-01-15" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 899e210..4203497 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "portabase-cli" -version = "26.01.7" +version = "26.01.8" description = "The official command line interface (CLI) for managing and deploying Portabase instances with ease." readme = "README.md" requires-python = ">=3.12" From dabf6482dcfd03ac57ba09e0e1541574cac76a54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Thu, 15 Jan 2026 16:24:49 +0100 Subject: [PATCH 038/124] chore(release): 26.01.9 --- CITATION.cff | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 0ca9e44..6fd3a15 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -22,5 +22,5 @@ keywords: - management - integration license: Apache-2.0 -version: 26.01.8 +version: 26.01.9 date-released: "2026-01-15" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4203497..03e0124 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "portabase-cli" -version = "26.01.8" +version = "26.01.9" description = "The official command line interface (CLI) for managing and deploying Portabase instances with ease." readme = "README.md" requires-python = ">=3.12" From e442c074ca33b77ca3ecc4f851de07622c650f74 Mon Sep 17 00:00:00 2001 From: charlesgauthereau Date: Tue, 27 Jan 2026 18:59:35 +0100 Subject: [PATCH 039/124] fix: compose.py with snippets code for agent databases. --- .idea/portabase-cli.iml | 4 +++- templates/compose.py | 32 +++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) 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/templates/compose.py b/templates/compose.py index 529e9d9..03eaa43 100644 --- a/templates/compose.py +++ b/templates/compose.py @@ -28,4 +28,34 @@ - MYSQL_RANDOM_ROOT_PASSWORD=yes volumes: - ${VOL_NAME}:/var/lib/mysql -""" \ No newline at end of file +""" + +AGENT_MONGODB_AUTH_SNIPPET = """ + ${SERVICE_NAME}: + container_name: ${PROJECT_NAME}-${SERVICE_NAME} + image: mongo:latest + ports: + - "${PORT}:27017" + environment: + - MONGO_INITDB_ROOT_USERNAME=${USER} + - MONGO_INITDB_ROOT_PASSWORD=${PASSWORD} + - MONGO_INITDB_DATABASE=${DB_NAME} + command: mongod --auth + volumes: + - ${VOL_NAME}:/data/db +""" + +AGENT_MONGODB_SNIPPET = """ + ${SERVICE_NAME}: + container_name: ${PROJECT_NAME}-${SERVICE_NAME} + image: mongo:latest + ports: + - "${PORT}:27017" + environment: + - MONGO_INITDB_DATABASE=${DB_NAME} + volumes: + - ${VOL_NAME}:/data/db +""" + + + From fd71a17d324ad7f3636cc7af6d379112b552eb94 Mon Sep 17 00:00:00 2001 From: charlesgauthereau Date: Tue, 27 Jan 2026 19:40:19 +0100 Subject: [PATCH 040/124] fix: agent.py configuration --- .github/assets/templates/agent.yml | 1 - .github/assets/templates/dashboard.yml | 6 +- commands/agent.py | 112 ++++++++++++++++++++----- commands/dashboard.py | 2 +- 4 files changed, 93 insertions(+), 28 deletions(-) diff --git a/.github/assets/templates/agent.yml b/.github/assets/templates/agent.yml index 5c851f7..3a3cb1c 100644 --- a/.github/assets/templates/agent.yml +++ b/.github/assets/templates/agent.yml @@ -17,7 +17,6 @@ services: {{EXTRA_SERVICES}} -volumes: {{EXTRA_VOLUMES}} networks: diff --git a/.github/assets/templates/dashboard.yml b/.github/assets/templates/dashboard.yml index f5f08bb..4db0659 100644 --- a/.github/assets/templates/dashboard.yml +++ b/.github/assets/templates/dashboard.yml @@ -6,11 +6,9 @@ services: env_file: - .env ports: - - "${PORT}:3000" + - "${HOST_PORT}:80" environment: - TIME_ZONE=Europe/Paris - - HOSTNAME=0.0.0.0 - - PORT=3000 volumes: - portabase-private:/app/private depends_on: @@ -27,8 +25,6 @@ services: - POSTGRES_DB=${POSTGRES_DB} - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - - PROJECT_NAME="Portabase" - - PROJECT_URL=${PROJECT_URL} healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 10s diff --git a/commands/agent.py b/commands/agent.py index 5f292fc..57a9c3b 100644 --- a/commands/agent.py +++ b/commands/agent.py @@ -12,10 +12,11 @@ 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") + 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() @@ -26,7 +27,7 @@ def agent( 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(" ", "-") @@ -39,14 +40,14 @@ def agent( "EDGE_KEY": key, "PROJECT_NAME": project_name } - + extra_services = "" - extra_volumes = "" + extra_volumes = "volumes:\n" volumes_list = [] - + json_path = path / "databases.json" if not json_path.exists(): - write_file(json_path, '{"databases": []}') + write_file(json_path, '{"databases": []}') try: os.chmod(json_path, 0o666) except: @@ -57,17 +58,17 @@ def agent( 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") + db_type = Prompt.ask("Type", choices=["postgresql", "mysql", "mariadb", "mongodb-auth", "mongodb"], 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, @@ -83,14 +84,14 @@ def agent( 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 @@ -104,10 +105,10 @@ def agent( .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, @@ -126,7 +127,7 @@ def agent( 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 @@ -140,10 +141,10 @@ def agent( .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, @@ -156,15 +157,84 @@ def agent( }) console.print(f"[success]✔ Added MariaDB container (Port {mysql_port})[/success]") + elif db_engine == "mongodb-auth": + mongo_port = get_free_port() + db_user = "admin" + db_pass = secrets.token_hex(8) + db_name = f"mongo_{secrets.token_hex(4)}" + service_name = f"db-mongo-auth-{secrets.token_hex(2)}" + + var_prefix = service_name.upper().replace("-", "_") + env_vars[f"{var_prefix}_PORT"] = str(mongo_port) + env_vars[f"{var_prefix}_DB"] = db_name + env_vars[f"{var_prefix}_USER"] = db_user + env_vars[f"{var_prefix}_PASS"] = db_pass + + snippet = AGENT_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": "mongodb", + "username": db_user, + "password": db_pass, + "port": mongo_port, + "host": "localhost", + "generated_id": str(uuid.uuid4()) + }) + console.print(f"[success]✔ Added MongoDB Auth container (Port {mongo_port})[/success]") + + + elif db_engine == "mongodb": + mongo_port = get_free_port() + db_name = f"mongo_{secrets.token_hex(4)}" + service_name = f"db-mongo-{secrets.token_hex(2)}" + + var_prefix = service_name.upper().replace("-", "_") + env_vars[f"{var_prefix}_PORT"] = str(mongo_port) + env_vars[f"{var_prefix}_DB"] = db_name + + snippet = AGENT_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}}") \ + + extra_services += snippet + volumes_list.append(f"{service_name}-data") + + add_db_to_json(path, { + "name": db_name, + "database": db_name, + "type": "mongodb", + "username": "", + "password": "", + "port": mongo_port, + "host": "localhost", + "generated_id": str(uuid.uuid4()) + }) + console.print(f"[success]✔ Added MongoDB container (Port {mongo_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) @@ -175,4 +245,4 @@ def agent( 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 + console.print(f"[info]Run: portabase start {name}[/info]") diff --git a/commands/dashboard.py b/commands/dashboard.py index 42242e0..0245e7e 100644 --- a/commands/dashboard.py +++ b/commands/dashboard.py @@ -32,7 +32,7 @@ def dashboard( pg_port = get_free_port() env_vars = { - "PORT": port, + "HOST_PORT": port, "POSTGRES_DB": "portabase", "POSTGRES_USER": "portabase", "POSTGRES_PASSWORD": secrets.token_hex(16), From 246de2c2ff94cc6503781122a9d69072f150ccfc Mon Sep 17 00:00:00 2001 From: charlesgauthereau Date: Tue, 27 Jan 2026 19:50:45 +0100 Subject: [PATCH 041/124] chore(release): 26.01.27b1 --- .github/assets/templates/dashboard.yml | 2 +- .idea/misc.xml | 7 +++++++ CITATION.cff | 4 ++-- LICENSE | 2 +- README.md | 1 - pyproject.toml | 2 +- 6 files changed, 12 insertions(+), 6 deletions(-) create mode 100644 .idea/misc.xml diff --git a/.github/assets/templates/dashboard.yml b/.github/assets/templates/dashboard.yml index 4db0659..8d55fdf 100644 --- a/.github/assets/templates/dashboard.yml +++ b/.github/assets/templates/dashboard.yml @@ -2,7 +2,7 @@ name: ${PROJECT_NAME} services: portabase: container_name: ${PROJECT_NAME}-app - image: solucetechnologies/portabase:latest + image: portabase/portabase:latest env_file: - .env ports: diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..57822be --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/CITATION.cff b/CITATION.cff index 6fd3a15..31aaacd 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -22,5 +22,5 @@ keywords: - management - integration license: Apache-2.0 -version: 26.01.9 -date-released: "2026-01-15" \ No newline at end of file +version: 26.01.27b1 +date-released: "2026-01-27" \ No newline at end of file diff --git a/LICENSE b/LICENSE index 1851dee..545fb3e 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2024 Soluce Technologies + 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. diff --git a/README.md b/README.md index 9535c23..a5b1f67 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,6 @@ [![License: Apache](https://img.shields.io/badge/License-apache-yellow.svg)](LICENSE) -[![Docker Pulls](https://img.shields.io/docker/pulls/solucetechnologies/portabase?color=brightgreen)](https://hub.docker.com/r/solucetechnologies/portabase) [![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/) diff --git a/pyproject.toml b/pyproject.toml index 03e0124..17f5cb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "portabase-cli" -version = "26.01.9" +version = "26.01.27b1" description = "The official command line interface (CLI) for managing and deploying Portabase instances with ease." readme = "README.md" requires-python = ">=3.12" From f1a6f17b5aba177c275dff78b899dcbf907ae51e Mon Sep 17 00:00:00 2001 From: charlesgauthereau Date: Tue, 27 Jan 2026 19:58:28 +0100 Subject: [PATCH 042/124] chore(release): 26.01.27 --- CITATION.cff | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 31aaacd..a610511 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -22,5 +22,5 @@ keywords: - management - integration license: Apache-2.0 -version: 26.01.27b1 +version: 26.01.27 date-released: "2026-01-27" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 17f5cb3..1d72100 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "portabase-cli" -version = "26.01.27b1" +version = "26.01.27" description = "The official command line interface (CLI) for managing and deploying Portabase instances with ease." readme = "README.md" requires-python = ">=3.12" From ed1d8cffcce81fedc6fd97d43200374f85a3c8c7 Mon Sep 17 00:00:00 2001 From: charlesgauthereau Date: Tue, 27 Jan 2026 20:17:43 +0100 Subject: [PATCH 043/124] chore(release): 26.01.27 --- .github/workflows/python.yml | 2 +- core/updater.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index fbbb1c2..0444a71 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -31,7 +31,7 @@ jobs: - name: Build binary run: | - uv run pyinstaller --onefile --name portabase_${{ matrix.os }}_${{ matrix.arch }} --paths=. --add-data "pyproject.toml:." main.py + uv run pyinstaller --onefile --name portabase_${{ matrix.os }}_${{ matrix.arch }} --paths=. --collect-data rich --add-data "pyproject.toml:." main.py - name: Upload artifacts uses: actions/upload-artifact@v4 diff --git a/core/updater.py b/core/updater.py index 6f61a1b..bde312e 100644 --- a/core/updater.py +++ b/core/updater.py @@ -119,8 +119,8 @@ def update_cli(): try: if latest_tag < current and not (is_prerelease(current) and not is_prerelease(latest_tag)): - console.print(f"[warning]⚠ Latest remote version ({latest_tag}) appears to be older than current ({current}).[/warning]") - if not typer.confirm("Do you want to continue with the update (downgrade)?"): + console.print(f"[warning]⚠ Current version ({current}) appears to be older than the latest remote version ({latest_tag}).[/warning]") + if not typer.confirm("Do you want to continue with the update ?"): return except Exception: pass From 35890a2284bd3cc84914f7a9f44fe1376e245464 Mon Sep 17 00:00:00 2001 From: charlesgauthereau Date: Tue, 27 Jan 2026 20:36:15 +0100 Subject: [PATCH 044/124] fix: python.yml --- .github/workflows/python.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 0444a71..4c014f4 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -31,7 +31,17 @@ jobs: - name: Build binary run: | - uv run pyinstaller --onefile --name portabase_${{ matrix.os }}_${{ matrix.arch }} --paths=. --collect-data rich --add-data "pyproject.toml:." main.py + rm -rf build dist *.spec + uv run pyinstaller \ + --onefile \ + --name portabase_${{ matrix.os }}_${{ matrix.arch }} \ + --paths=. \ + --collect-all rich \ + --collect-all requests \ + --collect-data certifi \ + --add-data "pyproject.toml:." \ + main.py + - name: Upload artifacts uses: actions/upload-artifact@v4 From 6f9f4b83fca1b400cc6793d6751686e5ac6d0598 Mon Sep 17 00:00:00 2001 From: charlesgauthereau Date: Tue, 27 Jan 2026 20:49:21 +0100 Subject: [PATCH 045/124] chore(release): 26.01.27 --- commands/agent.py | 12 ++++++------ core/config.py | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/commands/agent.py b/commands/agent.py index 57a9c3b..550b338 100644 --- a/commands/agent.py +++ b/commands/agent.py @@ -57,11 +57,11 @@ def agent( 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") + mode = Prompt.ask("Configuration Mode", choices=["new", "existing"], default="docker") - if mode == "manual": + if mode == "new": console.print("[info]External/Existing Database Configuration[/info]") - db_type = Prompt.ask("Type", choices=["postgresql", "mysql", "mariadb", "mongodb-auth", "mongodb"], default="postgresql") + db_type = Prompt.ask("Type", choices=["postgresql", "mysql", "mariadb", "mongodb"], default="postgresql") friendly_name = Prompt.ask("Display Name", default="External DB") db_name = Prompt.ask("Database Name") host = Prompt.ask("Host", default="localhost") @@ -83,7 +83,7 @@ def agent( else: console.print("[info]New Local Docker Container[/info]") - db_engine = Prompt.ask("Engine", choices=["postgresql", "mariadb"], default="postgresql") + db_engine = Prompt.ask("Engine", choices=["postgresql", "mysql", "mariadb", "mongodb-auth", "mongodb"], default="postgresql") if db_engine == "postgresql": pg_port = get_free_port() @@ -121,7 +121,7 @@ def agent( }) console.print(f"[success]✔ Added Postgres container (Port {pg_port})[/success]") - elif db_engine == "mariadb": + elif db_engine == "mariadb" or db_engine == "mysql": mysql_port = get_free_port() db_user = "admin" db_pass = secrets.token_hex(8) @@ -148,7 +148,7 @@ def agent( add_db_to_json(path, { "name": db_name, "database": db_name, - "type": "mysql", + "type": db_engine, "username": db_user, "password": db_pass, "port": mysql_port, diff --git a/core/config.py b/core/config.py index 1146ede..6f47004 100644 --- a/core/config.py +++ b/core/config.py @@ -75,8 +75,8 @@ def add_db_to_json(path: Path, db_entry: dict): if "databases" not in config: config["databases"] = [] - if "generatedId" not in db_entry: - db_entry["generatedId"] = str(uuid.uuid4()) + if "generated_id" not in db_entry: + db_entry["generated_id"] = str(uuid.uuid4()) config["databases"].append(db_entry) save_db_config(path, config) \ No newline at end of file From c6e49c3ba3722993e87d08eb9212e9f6dffd8e32 Mon Sep 17 00:00:00 2001 From: charlesgauthereau Date: Tue, 27 Jan 2026 20:55:07 +0100 Subject: [PATCH 046/124] fix: agent.py --- commands/agent.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/commands/agent.py b/commands/agent.py index 550b338..047a1b2 100644 --- a/commands/agent.py +++ b/commands/agent.py @@ -57,9 +57,9 @@ def agent( 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=["new", "existing"], default="docker") + mode = Prompt.ask("Configuration Mode", choices=["new", "existing"], default="news") - if mode == "new": + if mode == "existing": console.print("[info]External/Existing Database Configuration[/info]") db_type = Prompt.ask("Type", choices=["postgresql", "mysql", "mariadb", "mongodb"], default="postgresql") friendly_name = Prompt.ask("Display Name", default="External DB") From d5004cd2ad16439253e41256d17b45a355fac81d Mon Sep 17 00:00:00 2001 From: charlesgauthereau Date: Wed, 28 Jan 2026 08:45:55 +0100 Subject: [PATCH 047/124] fix: agent.py --- .idea/misc.xml | 1 - commands/agent.py | 9 +++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.idea/misc.xml b/.idea/misc.xml index 57822be..76c43c7 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ -

- +[![Plumber Score](https://score.getplumber.io/github.com/Portabase/cli.svg)](https://score.getplumber.io/github.com/Portabase/cli) [![License: Apache](https://img.shields.io/badge/License-apache-yellow.svg)](LICENSE) [![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey)](https://github.com/Portabase/portabase) @@ -63,3 +63,14 @@ Distributed under the Apache License. See `LICENSE.txt` for more details. + +## 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 db add`, `db remove` or +`build`. The first time that happens on an older install, the existing file is +copied to `docker-compose.legacy.yml` first. + +- Preview the change before applying it: `portabase build --diff` +- Keep your own customisations in `docker-compose.override.yml`; Docker Compose + merges it automatically and the CLI never touches it. diff --git a/commands/agent.py b/commands/agent.py index b03c402..d06162e 100644 --- a/commands/agent.py +++ b/commands/agent.py @@ -1,949 +1,145 @@ -import os -import secrets -import uuid +from __future__ import annotations + from pathlib import Path -from typing import Optional +from typing import Annotated -import questionary import typer -from rich.panel import Panel -from rich.prompt import Confirm, IntPrompt, Prompt -from rich.table import Table - -from core.config import add_db_to_json, load_db_config, write_env_file, write_file -from core.docker import ensure_network, run_compose -from core.network import fetch_template -from core.utils import ( - check_system, - console, - generate_password, - get_free_port, - get_random_hint, - print_banner, - questionary_style, - validate_edge_key, -) -from templates.compose import ( - AGENT_FIREBIRD_SNIPPET, - AGENT_MARIADB_SNIPPET, - AGENT_MONGODB_AUTH_SNIPPET, - AGENT_MONGODB_SNIPPET, - AGENT_MSSQL_SNIPPET, - AGENT_POSTGRES_SNIPPET, - AGENT_REDIS_AUTH_SNIPPET, - AGENT_REDIS_SNIPPET, - AGENT_VALKEY_AUTH_SNIPPET, - AGENT_VALKEY_SNIPPET, -) - - -def agent( - name: str = typer.Argument(..., help="Name of the agent (creates a folder)"), - key: Optional[str] = typer.Option(None, "--key", "-k", help="Edge Key"), - tz: str = typer.Option("UTC", "--tz", help="Timezone"), - polling: int = typer.Option(5, "--polling", help="Polling frequency in seconds"), - start: bool = typer.Option(False, "--start", "-s", help="Start immediately"), -): - - print_banner() - check_system() - ensure_network("portabase_network") - - path = Path(name).resolve() - if path.exists(): - console.print(f"[warning]Directory '{name}' already exists.[/warning]") - if not Confirm.ask("Overwrite?"): - raise typer.Exit() - - path.mkdir(parents=True, exist_ok=True) - - if not key: - key = Prompt.ask("[key]Edge Key[/key]") - if not validate_edge_key(key): - console.print( - "[danger]✖ Invalid Edge Key. Please check the format (Base64 or JSON).[/danger]" +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 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 = docker + self.templates = templates + self.renderer = renderer + self.engines = engines + self.ports = 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", ) - raise typer.Exit(1) - - if not tz or tz == "UTC": - tz = Prompt.ask("Timezone", default="UTC") - - if polling == 5: - polling = IntPrompt.ask("Polling frequency (seconds)", default=5) - - add_host_gateway = Confirm.ask( - "Add extra_hosts mapping (localhost -> host-gateway)?", default=False - ) - - raw_template = fetch_template("agent.yml") - if "{{EXTRA_SERVICES}}" not in raw_template: - if "\nnetworks:" in raw_template: - raw_template = raw_template.replace( - "\nnetworks:", "\n\n{{EXTRA_SERVICES}}\n\nnetworks:" - ) - else: - raw_template += "\n\n{{EXTRA_SERVICES}}\n" + project = AgentProject.create(path, env_vars, host_gateway=gateway) + self._write(project) + self.ui.success(f"Agent '{name}' created in {path}") - if "{{EXTRA_VOLUMES}}" not in raw_template: - if "\nnetworks:" in raw_template: - raw_template = raw_template.replace( - "\nnetworks:", "\n\n{{EXTRA_VOLUMES}}\n\nnetworks:" + if self.ui.non_interactive: + self.ui.hint( + f"Add databases with: portabase db add {name} " + "--engine postgresql --mode new" ) else: - raw_template += "\n\n{{EXTRA_VOLUMES}}\n" - - env_vars = { - "EDGE_KEY": key, - "TZ": tz, - "POLLING": str(polling), - "LOG_LEVEL": "info", - } - - extra_services = "" - extra_volumes = "" - app_volumes = ["./databases.json:/config/config.json"] - volumes_list = [] - - json_path = path / "databases.json" - if not json_path.exists(): - write_file(json_path, '{"databases": []}') - try: - os.chmod(json_path, 0o666) - except: - pass - - console.print("") - console.print(Panel("[bold]Database Setup[/bold]", style="cyan")) - - while True: - storage_kind = questionary.select( - "What do you want to configure?", - choices=["done", "database", "docker-volume"], - default="database", - style=questionary_style, - ).ask() - - if storage_kind in (None, "done"): - break - - if storage_kind == "docker-volume": - console.print( - "[warning]⚠ Requires the Docker socket. It will be mounted " - "on the agent ([bold]/var/run/docker.sock[/bold]).[/warning]" - ) - friendly_name = Prompt.ask("Display Name", default="Docker Volume") - volume_name = Prompt.ask("Volume Name (e.g. databases_sqlite-data)").strip() - while not volume_name: - console.print("[danger]✖ Volume Name is required.[/danger]") - volume_name = Prompt.ask( - "Volume Name (e.g. databases_sqlite-data)" - ).strip() - container_name = Prompt.ask( - "Container Name (optional, enables auto-restart after restore)", - default="", - ) - dv_entry = { - "name": friendly_name, - "type": "docker-volume", - "volume_name": volume_name, - "generated_id": str(uuid.uuid4()), - } - if container_name: - dv_entry["container_name"] = container_name - - sock_mount = "/var/run/docker.sock:/var/run/docker.sock" - if sock_mount not in app_volumes: - app_volumes.append(sock_mount) - - add_db_to_json(path, dv_entry) - console.print("[success]✔ Added to config[/success]") - continue - - while True: - mode = Prompt.ask( - "Configuration Mode", choices=["new", "existing", "back"], default="new" - ) - - if mode == "back": - break - - if mode == "existing": - console.print("[info]External/Existing Database Configuration[/info]") - db_type = questionary.select( - "Select Database Type", - choices=[ - "back", - "postgresql", - "postgresql-cluster", - "mysql", - "mariadb", - "sqlite", - "firebird", - "mongodb", - "redis", - "valkey", - "mssql", - ], - style=questionary_style, - ).ask() - - if db_type == "back": - continue - - if not db_type: - raise typer.Exit() - - if db_type == "postgresql-cluster": - console.print( - "[warning]⚠ Postgres Cluster requires a superuser. " - "Cluster backup/restore uses pg_dumpall, which dumps all " - "databases and global objects (roles, tablespaces). " - "The provided user must be a Postgres superuser.[/warning]" - ) - - friendly_name = Prompt.ask("Display Name", default="External DB") - - if db_type == "sqlite": - db_name = Prompt.ask("Database Path (relative or absolute)") - if not db_name.startswith("/"): - app_volumes.append(f"./{db_name}:/config/{db_name}") - container_path = f"/config/{db_name}" - else: - container_path = db_name - - add_db_to_json( - path, - { - "name": friendly_name, - "database": container_path, - "type": db_type, - "generated_id": str(uuid.uuid4()), - }, - ) - else: - db_name = Prompt.ask("Database Name") - host = Prompt.ask("Host", default="localhost") - port = IntPrompt.ask( - "Port", - default=5432 - if db_type in ["postgresql", "postgresql-cluster"] - else ( - 3050 - if db_type == "firebird" - else ( - 1433 - if db_type == "mssql" - else ( - 3306 if db_type in ["mysql", "mariadb"] else 27017 - ) - ) - ), - ) - user = Prompt.ask("Username") - password = questionary.password( - "Password", style=questionary_style - ).ask() - if password is None: - raise typer.Exit() - - ext_entry = { - "name": friendly_name, - "database": db_name, - "type": db_type, - "username": user, - "password": password, - "port": port, - "host": host, - "generated_id": str(uuid.uuid4()), - } - if db_type == "postgresql": - console.print( - "[info]ℹ When enabled, omits [bold]--no-owner[/bold] and " - "[bold]--no-privileges[/bold] from the dump. Ownership and role " - "assignments are preserved in the output. By default, these flags " - "are applied to keep restores portable across different users and " - "environments, for example when migrating from one database " - "instance to another.[/info]" - ) - - keep_ownership = Confirm.ask("Keep ownership?", default=False) - - if keep_ownership is None: - raise typer.Exit() - - console.print( - "[info]ℹ Controls how the target database is cleaned before " - "a restore. [bold]pg_restore --clean[/bold] only drops " - "objects listed in the backup's own table of contents, so " - "anything already present in the target that the dump does " - "not know about survives and can make the restore " - "fail.[/info]" - ) - clean_mode = questionary.select( - "Clean mode", - choices=[ - questionary.Choice( - "clean - pg_restore --clean --if-exists (default)", - value="clean", - ), - questionary.Choice( - "none - no pre-clean, restore into an empty database", - value="none", - ), - questionary.Choice( - "drop_schemas - drop every non-system schema CASCADE " - "(recommended, works on managed Postgres)", - value="drop_schemas", - ), - questionary.Choice( - "drop_database - DROP DATABASE + CREATE DATABASE " - "(full reset)", - value="drop_database", - ), - ], - default="clean", - style=questionary_style, - ).ask() - if clean_mode is None: - raise typer.Exit() - if clean_mode == "drop_database": - console.print( - "[warning]⚠ drop_database drops the whole target " - "database before restoring. The user must have CREATEDB " - "and own the database, or be a superuser. Most managed " - "Postgres providers do not allow it.[/warning]" - ) - - pg_options = {} - if keep_ownership: - pg_options["keep_ownership"] = True - if clean_mode != "clean": - pg_options["clean_mode"] = clean_mode - if pg_options: - ext_entry["options"] = pg_options - - add_db_to_json(path, ext_entry) - console.print("[success]✔ Added to config[/success]") - break - - else: - console.print("[info]New Local Docker Container[/info]") - db_engine = questionary.select( - "Select Database Engine", - choices=[ - "back", - "postgresql", - "postgresql-cluster", - "mysql", - "mariadb", - "sqlite", - "firebird", - "mongodb", - "redis", - "valkey", - "mssql", - ], - style=questionary_style, - ).ask() - - if db_engine == "back": - continue - - if not db_engine: - raise typer.Exit() - - db_variant = "no-auth" - if db_engine in ["mongodb", "redis", "valkey"]: - engine_display = { - "mongodb": "MongoDB", - "redis": "Redis", - "valkey": "Valkey", - }[db_engine] - db_variant = questionary.select( - f"Select {engine_display} Variant", - choices=["back", "no-auth", "with-auth"], - style=questionary_style, - ).ask() - - if db_variant == "back": - continue - - if not db_variant: - raise typer.Exit() - - if db_engine == "sqlite": - db_name = Prompt.ask("Database Name", default="local") - if not db_name.endswith(".sqlite"): - db_name += ".sqlite" - - app_volumes.append(f"./{db_name}:/config/{db_name}") - - add_db_to_json( - path, - { - "name": db_name, - "database": f"/config/{db_name}", - "type": "sqlite", - "generated_id": str(uuid.uuid4()), - }, - ) - console.print( - f"[success]✔ Added SQLite database ({db_name})[/success]" - ) - - elif db_engine in ["postgresql", "postgresql-cluster"]: - pg_port = get_free_port() - db_user = "admin" - db_pass = generate_password(16) - db_name = f"pg_{secrets.token_hex(4)}" - service_name = f"db-pg-{secrets.token_hex(2)}" - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(pg_port) - env_vars[f"{var_prefix}_DB"] = db_name - env_vars[f"{var_prefix}_USER"] = db_user - env_vars[f"{var_prefix}_PASS"] = db_pass - - snippet = ( - AGENT_POSTGRES_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - .replace("${USER}", f"${{{var_prefix}_USER}}") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - pg_entry = { - "name": db_name, - "database": db_name, - "type": db_engine, - "username": db_user, - "password": db_pass, - "port": 5432, - "host": service_name, - "generated_id": str(uuid.uuid4()), - } - if db_engine == "postgresql": - console.print( - "[info]ℹ When enabled, omits [bold]--no-owner[/bold] and " - "[bold]--no-privileges[/bold] from the dump. Ownership and role " - "assignments are preserved in the output. By default, these flags " - "are applied to keep restores portable across different users and " - "environments, for example when migrating from one database " - "instance to another.[/info]" - ) - keep_ownership = Confirm.ask("Keep ownership?", default=False) - - if keep_ownership is None: - raise typer.Exit() - - console.print( - "[info]ℹ Controls how the target database is cleaned before " - "a restore. [bold]pg_restore --clean[/bold] only drops " - "objects listed in the backup's own table of contents, so " - "anything already present in the target that the dump does " - "not know about survives and can make the restore " - "fail.[/info]" - ) - clean_mode = questionary.select( - "Clean mode", - choices=[ - questionary.Choice( - "clean - pg_restore --clean --if-exists (default)", - value="clean", - ), - questionary.Choice( - "none - no pre-clean, restore into an empty database", - value="none", - ), - questionary.Choice( - "drop_schemas - drop every non-system schema CASCADE " - "(recommended, works on managed Postgres)", - value="drop_schemas", - ), - questionary.Choice( - "drop_database - DROP DATABASE + CREATE DATABASE " - "(full reset)", - value="drop_database", - ), - ], - default="clean", - style=questionary_style, - ).ask() - if clean_mode is None: - raise typer.Exit() - if clean_mode == "drop_database": - console.print( - "[warning]⚠ drop_database drops the whole target " - "database before restoring. The user must have CREATEDB " - "and own the database, or be a superuser. Most managed " - "Postgres providers do not allow it.[/warning]" - ) - - pg_options = {} - if keep_ownership: - pg_options["keep_ownership"] = True - if clean_mode != "clean": - pg_options["clean_mode"] = clean_mode - if pg_options: - pg_entry["options"] = pg_options - - add_db_to_json(path, pg_entry) - - label = ( - "Postgres Cluster" - if db_engine == "postgresql-cluster" - else "Postgres" - ) - console.print( - f"[success]✔ Added {label} container (Port {pg_port})[/success]" - ) - - elif db_engine == "mariadb" or db_engine == "mysql": - mysql_port = get_free_port() - db_user = "admin" - db_pass = generate_password(16) - db_name = f"mysql_{secrets.token_hex(4)}" - service_name = f"db-mariadb-{secrets.token_hex(2)}" - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(mysql_port) - env_vars[f"{var_prefix}_DB"] = db_name - env_vars[f"{var_prefix}_USER"] = db_user - env_vars[f"{var_prefix}_PASS"] = db_pass - - snippet = ( - AGENT_MARIADB_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - .replace("${USER}", f"${{{var_prefix}_USER}}") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - add_db_to_json( - path, - { - "name": db_name, - "database": db_name, - "type": db_engine, - "username": db_user, - "password": db_pass, - "port": 3306, - "host": service_name, - "generated_id": str(uuid.uuid4()), - }, - ) - console.print( - f"[success]✔ Added MariaDB container (Port {mysql_port})[/success]" - ) - - elif db_engine == "mongodb": - if db_variant == "with-auth": - mongo_port = get_free_port() - db_user = "admin" - db_pass = generate_password(16) - db_name = f"mongo_{secrets.token_hex(4)}" - service_name = f"db-mongo-auth-{secrets.token_hex(2)}" - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(mongo_port) - env_vars[f"{var_prefix}_DB"] = db_name - env_vars[f"{var_prefix}_USER"] = db_user - env_vars[f"{var_prefix}_PASS"] = db_pass - - snippet = ( - AGENT_MONGODB_AUTH_SNIPPET.replace( - "${SERVICE_NAME}", service_name - ) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - .replace("${USER}", f"${{{var_prefix}_USER}}") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - add_db_to_json( - path, - { - "name": db_name, - "database": db_name, - "type": "mongodb", - "username": db_user, - "password": db_pass, - "port": 27017, - "host": service_name, - "generated_id": str(uuid.uuid4()), - }, - ) - console.print( - f"[success]✔ Added MongoDB Auth container (Port {mongo_port})[/success]" - ) - else: - mongo_port = get_free_port() - db_name = f"mongo_{secrets.token_hex(4)}" - service_name = f"db-mongo-{secrets.token_hex(2)}" - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(mongo_port) - env_vars[f"{var_prefix}_DB"] = db_name - - snippet = ( - AGENT_MONGODB_SNIPPET.replace( - "${SERVICE_NAME}", service_name - ) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - ) - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - add_db_to_json( - path, - { - "name": db_name, - "database": db_name, - "type": "mongodb", - "username": "", - "password": "", - "port": 27017, - "host": service_name, - "generated_id": str(uuid.uuid4()), - }, - ) - - console.print( - f"[success]✔ Added MongoDB container (Port {mongo_port})[/success]" - ) - - elif db_engine == "redis": - if db_variant == "with-auth": - redis_port = get_free_port() - db_name = f"redis_{secrets.token_hex(4)}" - service_name = f"db-redis-auth-{secrets.token_hex(2)}" - db_pass = generate_password(16) - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(redis_port) - env_vars[f"{var_prefix}_PASS"] = db_pass - - snippet = ( - AGENT_REDIS_AUTH_SNIPPET.replace( - "${SERVICE_NAME}", service_name - ) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - add_db_to_json( - path, - { - "name": db_name, - "database": "0", - "type": "redis", - "username": "", - "password": db_pass, - "port": 6379, - "host": service_name, - "generated_id": str(uuid.uuid4()), - }, - ) - console.print( - f"[success]✔ Added Redis Auth container (Port {redis_port})[/success]" - ) - else: - redis_port = get_free_port() - db_name = f"redis_{secrets.token_hex(4)}" - service_name = f"db-redis-{secrets.token_hex(2)}" - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(redis_port) - - snippet = ( - AGENT_REDIS_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - ) - - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - add_db_to_json( - path, - { - "name": db_name, - "database": "0", - "type": "redis", - "username": "", - "password": "", - "port": 6379, - "host": service_name, - "generated_id": str(uuid.uuid4()), - }, - ) - console.print( - f"[success]✔ Added Redis container (Port {redis_port})[/success]" - ) - - elif db_engine == "valkey": - if db_variant == "with-auth": - valkey_port = get_free_port() - db_name = f"valkey_{secrets.token_hex(4)}" - service_name = f"db-valkey-auth-{secrets.token_hex(2)}" - db_pass = generate_password(16) - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(valkey_port) - env_vars[f"{var_prefix}_PASS"] = db_pass - - snippet = ( - AGENT_VALKEY_AUTH_SNIPPET.replace( - "${SERVICE_NAME}", service_name - ) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - add_db_to_json( - path, - { - "name": db_name, - "database": "0", - "type": "valkey", - "username": "", - "password": db_pass, - "port": 6379, - "host": service_name, - "generated_id": str(uuid.uuid4()), - }, - ) - console.print( - f"[success]✔ Added Valkey Auth container (Port {valkey_port})[/success]" - ) - else: - valkey_port = get_free_port() - db_name = f"valkey_{secrets.token_hex(4)}" - service_name = f"db-valkey-{secrets.token_hex(2)}" - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(valkey_port) - - snippet = ( - AGENT_VALKEY_SNIPPET.replace( - "${SERVICE_NAME}", service_name - ) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - ) - - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - add_db_to_json( - path, - { - "name": db_name, - "database": "0", - "type": "valkey", - "username": "", - "password": "", - "port": 6379, - "host": service_name, - "generated_id": str(uuid.uuid4()), - }, - ) - console.print( - f"[success]✔ Added Valkey container (Port {valkey_port})[/success]" - ) - - elif db_engine == "firebird": - fb_port = get_free_port() - db_user = "alice" - db_pass = generate_password(16) - db_root_pass = generate_password(16) - db_name = "mirror.fdb" - db_container_path = f"/var/lib/firebird/data/{db_name}" - service_name = f"db-firebird-{secrets.token_hex(2)}" - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(fb_port) - env_vars[f"{var_prefix}_DB"] = db_name - env_vars[f"{var_prefix}_USER"] = db_user - env_vars[f"{var_prefix}_PASS"] = db_pass - env_vars[f"{var_prefix}_ROOT_PASS"] = db_root_pass - - snippet = ( - AGENT_FIREBIRD_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - .replace("${USER}", f"${{{var_prefix}_USER}}") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - .replace("${ROOT_PASSWORD}", f"${{{var_prefix}_ROOT_PASS}}") - ) - - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - add_db_to_json( - path, - { - "name": db_name, - "database": db_container_path, - "type": "firebird", - "username": db_user, - "password": db_pass, - "port": 3050, - "host": service_name, - "generated_id": str(uuid.uuid4()), - }, - ) - console.print( - f"[success]✔ Added Firebird container (Port {fb_port})[/success]" - ) - - elif db_engine == "mssql": - mssql_port = get_free_port() - db_pass = generate_password(16) - db_name = "master" - service_name = f"db-mssql-{secrets.token_hex(2)}" - - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(mssql_port) - env_vars[f"{var_prefix}_PASS"] = db_pass - - snippet = ( - AGENT_MSSQL_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - - extra_services += snippet - volumes_list.append(f"{service_name}-data") - - add_db_to_json( - path, - { - "name": "MSSQL", - "database": db_name, - "type": "mssql", - "username": "sa", - "password": db_pass, - "port": 1433, - "host": service_name, - "generated_id": str(uuid.uuid4()), - }, - ) - console.print( - f"[success]✔ Added MSSQL container (Port {mssql_port})[/success]" - ) - break - - if volumes_list: - extra_volumes = "volumes:\n" - for vol in volumes_list: - extra_volumes += f" {vol}:\n" - - final_compose = raw_template.replace("{{EXTRA_SERVICES}}", extra_services) - final_compose = final_compose.replace("{{EXTRA_VOLUMES}}", extra_volumes) - - vols_str = "\n".join([f" - {v}" for v in app_volumes]) - final_compose = final_compose.replace( - " - ./databases.json:/config/config.json", vols_str - ) - - if add_host_gateway: - final_compose = final_compose.replace( - " image: portabase/agent:latest\n", - " image: portabase/agent:latest\n" - " extra_hosts:\n" - ' - "localhost:host-gateway"\n', - ) - - summary = Table(show_header=False, box=None, padding=(0, 2)) - summary.add_column("Property", style="bold cyan") - summary.add_column("Value", style="white") - - summary.add_row("Agent Name", name) - summary.add_row("Path", str(path)) - summary.add_row("Edge Key", f"{key[:10]}...{key[-10:]}" if len(key) > 20 else key) - summary.add_row("Timezone", tz) - summary.add_row("Polling", f"{polling}s") - summary.add_row("Host Gateway", "Yes" if add_host_gateway else "No") - - db_config = load_db_config(path) - dbs = db_config.get("databases", []) - if dbs: - db_details = [] - for db in dbs: - if db.get("type") == "sqlite": - db_details.append(f"• {db['name']} (sqlite: {db['database']})") - elif db.get("type") == "docker-volume": - db_details.append( - f"• {db['name']} (docker-volume: {db.get('volume_name', 'N/A')})" + 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: - db_details.append( - f"• {db['name']} ({db['type']} on port {db.get('port', 'N/A')})" - ) - - summary.add_row("Databases", "\n".join(db_details)) - else: - summary.add_row("Databases", "[dim]None configured[/dim]") - summary.add_row("Files to Create", "• docker-compose.yml\n• .env\n• databases.json") - - console.print("") - console.print( - Panel( - summary, - title="[bold white]PROPOSED CONFIGURATION[/bold white]", - border_style="bold blue", - expand=False, - ) - ) - console.print( - "[dim]The agent will be configured in the directory above and ready for deployment.[/dim]\n" - ) - - if not Confirm.ask( - "[bold]Apply this configuration and generate files?[/bold]", default=True - ): - console.print("[warning]Configuration cancelled.[/warning]") - raise typer.Exit() - - write_file(path / "docker-compose.yml", final_compose) - write_env_file(path, env_vars) - - console.print( - Panel(f"[bold white]AGENT READY: {name}[/bold white]", style="bold #5f00d7") - ) - - if start or Confirm.ask("Start agent now?", default=False): - status_msg = f"[bold magenta]Starting...[/bold magenta]\n{get_random_hint()}" - with console.status(status_msg, spinner="earth"): - run_compose(path, ["up", "-d"]) - console.print(f"[bold green]✔ Agent {name} is running[/bold green]") - else: - console.print(f"[info]Run: portabase start {name}[/info]") + 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) diff --git a/commands/base.py b/commands/base.py new file mode 100644 index 0000000..8aabba8 --- /dev/null +++ b/commands/base.py @@ -0,0 +1,105 @@ +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]: ... + + 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) + 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..4b5465e --- /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(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}") diff --git a/commands/common.py b/commands/common.py deleted file mode 100644 index 3a8fefd..0000000 --- a/commands/common.py +++ /dev/null @@ -1,68 +0,0 @@ -import typer -import subprocess -import shutil -from pathlib import Path -from rich.prompt import Confirm -from core.utils import console, validate_work_dir, get_random_hint, slugify_project_name -from core.docker import run_compose - -def start(path: Path = typer.Argument(..., help="Path to component folder")): - path = path.resolve() - validate_work_dir(path) - status_msg = f"[bold magenta]Starting {path.name}...[/bold magenta]\n{get_random_hint()}" - with console.status(status_msg): - run_compose(path, ["up", "-d"]) - console.print("[success]✔ Started[/success]") - -def stop(path: Path = typer.Argument(..., help="Path to component folder")): - path = path.resolve() - validate_work_dir(path) - status_msg = f"[bold magenta]Stopping {path.name}...[/bold magenta]\n{get_random_hint()}" - with console.status(status_msg): - run_compose(path, ["stop"]) - console.print("[success]✔ Stopped[/success]") - -def restart(path: Path = typer.Argument(..., help="Path to component folder")): - path = path.resolve() - validate_work_dir(path) - status_msg = f"[bold magenta]Restarting {path.name}...[/bold magenta]\n{get_random_hint()}" - with console.status(status_msg): - run_compose(path, ["restart"]) - console.print("[success]✔ Restarted[/success]") - -def logs( - path: Path = typer.Argument(..., help="Path to component folder"), - follow: bool = typer.Option(True, "--follow/--no-follow", "-f") -): - path = path.resolve() - validate_work_dir(path) - args = ["logs"] - if follow: - args.append("-f") - try: - project_name = slugify_project_name(path.name) - subprocess.run(["docker", "compose", "-p", project_name] + args, cwd=path) - except KeyboardInterrupt: - pass - -def uninstall( - path: Path = typer.Argument(..., help="Path to component folder"), - force: bool = typer.Option(False, "--force", "-f") -): - path = path.resolve() - validate_work_dir(path) - - if not force: - console.print(f"[danger]⚠ WARNING: This will delete containers and data in {path}.[/danger]") - if not Confirm.ask("Are you sure?"): - raise typer.Exit() - - status_msg = f"[bold red]Uninstalling...[/bold red]\n{get_random_hint()}" - with console.status(status_msg): - run_compose(path, ["down", "-v"]) - try: - shutil.rmtree(path) - except Exception as e: - console.print(f"[warning]Could not remove directory: {e}[/warning]") - - console.print(f"[success]✔ Uninstalled[/success]") \ No newline at end of file diff --git a/commands/config.py b/commands/config.py index 7660f52..c781241 100644 --- a/commands/config.py +++ b/commands/config.py @@ -1,28 +1,97 @@ +from __future__ import annotations + +from typing import Annotated + import typer -from core.config import get_config_value, set_config_value -from core.utils import console +from commands.base import Command, CommandGroup +from core.config import GlobalConfig +from core.errors import ValidationError +from services.telemetry import Telemetry +from ui import UI + +CHANNELS = ("stable", "beta") + + +class _ConfigCommand(Command): + panel = "Configuration" + + def __init__(self, ui: UI, telemetry: Telemetry, config: GlobalConfig) -> None: + super().__init__(ui, telemetry) + self.config = config + + +class ConfigShow(_ConfigCommand): + name, help = "show", "Show the current configuration." -app = typer.Typer(help="Manage global CLI configuration.") + def run(self) -> None: + data = self.config.all() + self.ui.info(f"Configuration file: {self.config.path}") + for key in GlobalConfig.KNOWN_KEYS: + value = data.get(key, "[hint]unset[/hint]") + self.ui.print(f" [key]{key}[/key]: {value}") + for key in sorted(set(data) - set(GlobalConfig.KNOWN_KEYS)): + self.ui.print( + f" [key]{key}[/key]: {data[key]} [hint](unknown key)[/hint]" + ) -@app.command() -def channel( - name: str = typer.Argument(..., help="Update channel name (stable or beta)"), -): - name = name.lower() - if name not in ["stable", "beta"]: - console.print( - "[danger]✖ Invalid channel. Choose either 'stable' or 'beta'.[/danger]" +class ConfigGet(_ConfigCommand): + name, help = "get", "Print one configuration value." + no_args_is_help = True + + def run( + self, key: Annotated[str, typer.Argument(help="Configuration key")] + ) -> None: + value = self.config.get(key) + if value is None: + raise ValidationError( + f"'{key}' is not set.", + hint="Known keys: " + ", ".join(GlobalConfig.KNOWN_KEYS), + ) + self.ui.print(str(value)) + + +class ConfigSet(_ConfigCommand): + name, help = "set", "Set a configuration value." + no_args_is_help = True + + def run( + self, + key: Annotated[str, typer.Argument(help="Configuration key")], + value: Annotated[str, typer.Argument(help="Value")], + ) -> None: + if key == "update_channel" and value not in CHANNELS: + raise ValidationError( + f"Invalid channel '{value}'.", hint="Choose 'stable' or 'beta'." + ) + self.config.set(key, value) + self.ui.success(f"{key} = {value}") + + +class ConfigChannel(_ConfigCommand): + name, help = "channel", "Set the update channel (stable or beta)." + no_args_is_help = True + + def run(self, name: Annotated[str, typer.Argument(help="stable or beta")]) -> None: + ConfigSet(self.ui, self.telemetry, self.config).run( + "update_channel", name.lower() ) - raise typer.Exit(1) - set_config_value("update_channel", name) - console.print(f"[success]✔ Update channel set to: [bold]{name}[/bold][/success]") +class ConfigCommands(CommandGroup): + name, help, panel = "config", "Manage global CLI configuration.", "Configuration" + + def __init__(self, ui: UI, telemetry: Telemetry, config: GlobalConfig) -> None: + super().__init__(ui, telemetry) + self.config = config -@app.command() -def show(): - channel = get_config_value("update_channel", "auto (based on current version)") - console.print(f"[info]Current Configuration:[/info]") - console.print(f" [bold]Update Channel:[/bold] {channel}") + @property + def commands(self) -> list[Command]: + deps = (self.ui, self.telemetry, self.config) + return [ + ConfigShow(*deps), + ConfigGet(*deps), + ConfigSet(*deps), + ConfigChannel(*deps), + ] diff --git a/commands/dashboard.py b/commands/dashboard.py index 050f719..0a58dcc 100644 --- a/commands/dashboard.py +++ b/commands/dashboard.py @@ -1,205 +1,188 @@ -import re +from __future__ import annotations + import secrets +import sys from pathlib import Path +from typing import Annotated from urllib.parse import quote -import questionary import typer -from rich.panel import Panel -from rich.prompt import Confirm, IntPrompt, Prompt -from rich.table import Table - -from core.config import write_env_file, write_file -from core.docker import run_compose -from core.network import fetch_template -from core.utils import ( - check_system, - console, - generate_password, - get_free_port, - get_random_hint, - print_banner, - questionary_style, - slugify_project_name, -) - - -def dashboard( - name: str = typer.Argument(..., help="Name of the dashboard (creates a folder)"), - port: str = typer.Option("8887", help="Web Port"), - start: bool = typer.Option(False, "--start", "-s", help="Start immediately"), -): - print_banner() - check_system() - - path = Path(name).resolve() - if path.exists(): - console.print(f"[warning]Directory '{name}' already exists.[/warning]") - if not Confirm.ask("Overwrite?"): - raise typer.Exit() - - path.mkdir(parents=True, exist_ok=True) - project_name = slugify_project_name(path.name) - - raw_template = fetch_template("dashboard.yml") - - auth_secret = secrets.token_hex(32) - base_url = f"http://localhost:{port}" - - env_vars = { - "HOST_PORT": port, - "PROJECT_SECRET": auth_secret, - "PROJECT_URL": base_url, - "PROJECT_NAME": project_name, - "TZ": "Europe/Paris", - "LOG_LEVEL": "info", - } - - mode = questionary.select( - "Database Setup", - choices=[ - questionary.Choice( - "external: create a dedicated container in the same docker-compose.yml (recommended)", - value="external", - ), - questionary.Choice( - "internal: use the database embedded in the Portabase container", - value="internal", - ), - questionary.Choice( - "custom: provide credentials of an existing database", - value="custom", - ), - ], - style=questionary_style, - ).ask() - - if not mode: - raise typer.Exit() - - if mode == "external": - pg_port = get_free_port() - pg_pass = generate_password(16) - env_vars.update( - { - "POSTGRES_DB": "portabase", - "POSTGRES_USER": "portabase", - "POSTGRES_PASSWORD": pg_pass, - "POSTGRES_HOST": "db", - "DATABASE_URL": f"postgresql://portabase:{quote(pg_pass, safe='')}@db:5432/portabase?schema=public", - "PG_PORT": str(pg_port), - } - ) - final_compose = raw_template.replace("${PROJECT_NAME}", project_name) - elif mode == "custom": - console.print("[info]External Database Configuration[/info]") - db_host = Prompt.ask("Host", default="localhost") - db_port = IntPrompt.ask("Port", default=5432) - db_name = Prompt.ask("Database Name", default="portabase") - db_user = Prompt.ask("Username") - db_pass = questionary.password("Password", style=questionary_style).ask() - if db_pass is None: - raise typer.Exit() - - env_vars.update( - { - "POSTGRES_DB": db_name, - "POSTGRES_USER": db_user, - "POSTGRES_PASSWORD": db_pass, - "POSTGRES_HOST": db_host, - "DATABASE_URL": f"postgresql://{quote(db_user, safe='')}:{quote(db_pass, safe='')}@{db_host}:{db_port}/{db_name}?schema=public", - "PG_PORT": str(db_port), - } - ) - final_compose = re.sub( - r"[ ]{4}depends_on:\n[ ]{6}db:\n[ ]{8}condition: service_healthy\n", - "", - raw_template, - ) - final_compose = re.sub( - r"[ ]{2}db:.*?retries: 5\n", "", final_compose, flags=re.DOTALL - ) - final_compose = re.sub(r"[ ]{2}postgres-data:\n", "", final_compose) - final_compose = final_compose.replace("${PROJECT_NAME}", project_name) - else: - final_compose = re.sub( - r"[ ]{4}depends_on:\n[ ]{6}db:\n[ ]{8}condition: service_healthy\n", - "", - raw_template, - ) - final_compose = re.sub( - r"[ ]{2}db:.*?retries: 5\n", "", final_compose, flags=re.DOTALL - ) - final_compose = re.sub(r"[ ]{2}postgres-data:\n", "", final_compose) - final_compose = final_compose.replace("${PROJECT_NAME}", project_name) - - summary = Table(show_header=False, box=None, padding=(0, 2)) - summary.add_column("Property", style="bold cyan") - summary.add_column("Value", style="white") - - summary.add_row("Dashboard Name", name) - summary.add_row("Path", str(path)) - summary.add_row("Access URL", f"[bold green]http://localhost:{port}[/bold green]") - - db_setup_label = { - "external": "Dedicated Docker Container (Recommended)", - "internal": "Embedded Database (In-container)", - "custom": "Custom/Existing Database", - } - summary.add_row("Database Setup", db_setup_label.get(mode)) - - if mode == "external": - summary.add_row("Internal Port", env_vars["PG_PORT"]) - elif mode == "custom": - summary.add_row("DB Host", env_vars["POSTGRES_HOST"]) - summary.add_row("DB Name", env_vars["POSTGRES_DB"]) - masked_url = re.sub(r":.*?@", ":****@", env_vars["DATABASE_URL"]) - summary.add_row("Connection URL", f"[dim]{masked_url}[/dim]") - - summary.add_row("Files to Create", "• docker-compose.yml\n• .env") - - console.print("") - console.print( - Panel( - summary, - title="[bold white]PROPOSED CONFIGURATION[/bold white]", - border_style="bold blue", - expand=False, +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 = "dashboard" + help = "Create a new Portabase Dashboard instance." + panel = "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 = docker + self.templates = templates + self.renderer = renderer + self.ports = 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", 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, + ) -> 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", ) - ) - console.print( - "[dim]The dashboard will be set up with the parameters above.[/dim]\n" - ) - - if not Confirm.ask( - "[bold]Apply this configuration and generate files?[/bold]", default=True - ): - console.print("[warning]Configuration cancelled.[/warning]") - raise typer.Exit() - - write_file(path / "docker-compose.yml", final_compose) - write_env_file(path, env_vars) - - db_info = "" - if mode == "external": - db_info = f"\n[dim]DB Port: {env_vars.get('PG_PORT')}[/dim]" - elif mode == "custom": - db_info = f"\n[dim]External DB: {env_vars.get('POSTGRES_HOST')}[/dim]" - else: - db_info = "\n[dim]Embedded Database[/dim]" - - console.print( - Panel( - f"[bold white]DASHBOARD CREATED: {name}[/bold white]\n[dim]Path: {path}[/dim]{db_info}", - style="bold #5f00d7", + 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: + 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]: + url = ( + f"postgresql://{quote(user, safe='')}:{quote(password, safe='')}" + f"@{host}:{port}/{db}?schema=public" ) - ) - - if start or Confirm.ask("Start dashboard now?", default=False): - status_msg = f"[bold magenta]Starting...[/bold magenta]\n{get_random_hint()}" - with console.status(status_msg, spinner="earth"): - run_compose(path, ["up", "-d"]) - console.print(f"[bold green]✔ Live at: http://localhost:{port}[/bold green]") - else: - console.print(f"[info]Run: portabase start {name}[/info]") + return { + "POSTGRES_DB": db, + "POSTGRES_USER": user, + "POSTGRES_PASSWORD": password, + "POSTGRES_HOST": host, + "DATABASE_URL": url, + "PG_PORT": str(host_port), + } diff --git a/commands/db.py b/commands/db.py index e670442..4c5118b 100644 --- a/commands/db.py +++ b/commands/db.py @@ -1,756 +1,282 @@ -import re -import secrets -import uuid +from __future__ import annotations + +import sys from pathlib import Path +from typing import Annotated -import questionary import typer -from rich.panel import Panel -from rich.prompt import IntPrompt, Prompt -from rich.table import Table - -from core.config import add_db_to_json, load_db_config, save_db_config, write_env_file -from core.docker import ensure_network -from core.utils import ( - console, - generate_password, - get_free_port, - questionary_style, - validate_work_dir, -) -from templates.compose import ( - AGENT_FIREBIRD_SNIPPET, - AGENT_MARIADB_SNIPPET, - AGENT_MONGODB_AUTH_SNIPPET, - AGENT_MONGODB_SNIPPET, - AGENT_MSSQL_SNIPPET, - AGENT_POSTGRES_SNIPPET, - AGENT_REDIS_AUTH_SNIPPET, - AGENT_REDIS_SNIPPET, - AGENT_VALKEY_AUTH_SNIPPET, - AGENT_VALKEY_SNIPPET, -) - -app = typer.Typer(help="Manage databases configuration.") - -DOCKER_SOCKET_MOUNT = "/var/run/docker.sock:/var/run/docker.sock" - -def ensure_docker_socket(path: Path): - """Mount the Docker socket on the agent's app service if not already present.""" - compose_path = path / "docker-compose.yml" - if not compose_path.exists(): - console.print( - "[warning]⚠ docker-compose.yml not found. Add " - f"[bold]{DOCKER_SOCKET_MOUNT}[/bold] to the agent volumes manually.[/warning]" +from commands.base import Command, CommandGroup +from commands.flows.add_database import AddDatabaseFlow +from engines import EngineRegistry +from services.docker import DockerRunner +from services.ports import PortAllocator +from services.project import AgentProject +from services.renderer import ComposeRenderer, WriteReport +from services.telemetry import Telemetry +from services.templates import TemplateRepository +from ui import UI + +NameArg = Annotated[Path, typer.Argument(help="Agent folder")] + + +def report_write(ui: UI, report: WriteReport) -> None: + if report.backed_up: + ui.warning( + f"Legacy compose backed up to {report.backed_up.name}. " + "Manual edits belong in docker-compose.override.yml." ) - return - content = compose_path.read_text() - if DOCKER_SOCKET_MOUNT in content: - return - anchor = "- ./databases.json:/config/config.json" - lines = content.splitlines(keepends=True) - new_lines = [] - inserted = False - for line in lines: - new_lines.append(line) - if not inserted and anchor in line: - indent = line[: len(line) - len(line.lstrip())] - new_lines.append(f"{indent}- {DOCKER_SOCKET_MOUNT}\n") - inserted = True +class _DbCommand(Command): + panel = "Configuration" + no_args_is_help = True + + def __init__( + self, + ui: UI, + telemetry: Telemetry, + engines: EngineRegistry, + ports: PortAllocator, + templates: TemplateRepository, + renderer: ComposeRenderer, + docker: DockerRunner, + ) -> None: + super().__init__(ui, telemetry) + self.engines = engines + self.ports = ports + self.templates = templates + self.renderer = renderer + self.docker = docker + + def render_and_write(self, project: AgentProject) -> None: + with self.ui.status("Rendering configuration..."): + result = self.renderer.render_agent(project) + project.save_state() + report = result.write(project.path) + report_write(self.ui, report) + + +class DbAddCommand(_DbCommand): + name, help = "add", "Add a database to an agent." + + def run( + self, + name: NameArg, + engine: Annotated[ + str | None, typer.Option("--engine", "-e", help="Database engine") + ] = None, + mode: Annotated[ + str | None, typer.Option("--mode", help="new (container) or existing") + ] = None, + auth: Annotated[ + bool | None, + typer.Option( + "--auth/--no-auth", help="Auth variant for mongodb/redis/valkey" + ), + ] = None, + label: Annotated[ + str | None, typer.Option("--label", help="Display name") + ] = None, + host: Annotated[ + str | None, typer.Option("--host", help="Host of an existing database") + ] = None, + port: Annotated[ + int | None, typer.Option("--port", help="Port of an existing database") + ] = None, + database: Annotated[ + str | None, typer.Option("--database", help="Database name") + ] = None, + user: Annotated[str | None, typer.Option("--user", help="Username")] = None, + password: Annotated[ + str | None, typer.Option("--password", help="Prefer --password-stdin") + ] = None, + password_stdin: Annotated[ + bool, typer.Option("--password-stdin", help="Read password from stdin") + ] = False, + path: Annotated[ + str | None, typer.Option("--path", help="SQLite file path (existing)") + ] = None, + db_name: Annotated[ + str | None, typer.Option("--name", help="SQLite file name (new)") + ] = None, + volume: Annotated[ + str | None, typer.Option("--volume", help="Docker volume name") + ] = None, + container: Annotated[ + str | None, + typer.Option("--container", help="Container to restart after restore"), + ] = None, + option: Annotated[ + list[str] | None, + typer.Option("--option", "-o", help="Engine option KEY=VALUE (repeatable)"), + ] = None, + ) -> None: + if password_stdin: + password = sys.stdin.readline().rstrip("\n") + elif password is not None: + self.ui.warning( + "--password is visible in shell history; prefer --password-stdin." + ) - if inserted: - compose_path.write_text("".join(new_lines)) - console.print( - f"[info]ℹ Mounted Docker socket ([bold]{DOCKER_SOCKET_MOUNT}[/bold]) " - "on the agent.[/info]" + project_path = self.require_project_dir(name) + self.templates.resolve() + project = AgentProject.load(project_path) + + flow = AddDatabaseFlow(self.ui, self.engines, self.ports) + values = { + "engine": engine, + "mode": mode, + "auth": auth, + "label": label, + "host": host, + "port": port, + "database": database, + "username": user, + "password": password, + "path": path, + "name": db_name, + "volume": volume, + "container": container, + "options": flow.parse_options(option), + } + spec, eng = flow.collect(values) + flow.apply(project, spec, eng) + self.render_and_write(project) + + self.ui.success( + f"Added {eng.display} database '{spec.name}' ({eng.describe(spec)})." ) - else: - console.print( - "[warning]⚠ Could not locate the agent volumes block. Add " - f"[bold]{DOCKER_SOCKET_MOUNT}[/bold] to docker-compose.yml manually.[/warning]" + self.ui.info( + f"Restart the agent to apply changes: portabase restart {project_path.name}" ) -@app.command("list") -def list_dbs(name: str = typer.Argument(..., help="Name of the agent")): - path = Path(name).resolve() - validate_work_dir(path) - - config = load_db_config(path) - dbs = config.get("databases", []) - - if not dbs: - console.print("[warning]No databases configured.[/warning]") - return - - table = Table(title=f"Databases for {name}") - table.add_column("Display Name", style="cyan") - table.add_column("Database", style="blue") - table.add_column("Type", style="magenta") - table.add_column("Host:Port", style="green") - table.add_column("User", style="white") - table.add_column("ID", style="dim") - - for db in dbs: - db_type = db.get("type", "N/A") - if db_type == "sqlite": - host_port = "Local File" - elif db_type == "docker-volume": - host_port = f"volume: {db.get('volume_name', 'N/A')}" +class DbRemoveCommand(_DbCommand): + name, help = "remove", "Remove a database from an agent." + + def run( + self, + name: NameArg, + target: Annotated[ + str | None, + typer.Option( + "--id", "--name", "-i", help="Database id (or prefix) or display name" + ), + ] = None, + purge_volume: Annotated[ + bool, + typer.Option( + "--purge-volume", + help="Also delete the Docker volume of a managed database", + ), + ] = False, + yes: Annotated[ + bool, typer.Option("--yes", "-y", help="Skip confirmation") + ] = False, + ) -> None: + project_path = self.require_project_dir(name) + self.templates.resolve() + project = AgentProject.load(project_path) + if not project.databases: + self.ui.warning("No databases to remove.") + return + + if target is None: + choices = [f"{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: - host_port = f"{db.get('host', 'N/A')}:{db.get('port', 'N/A')}" - username = ( - "N/A" - if db_type in ("sqlite", "docker-volume") - else db.get("username", "N/A") - ) - - table.add_row( - db.get("name", "N/A"), - db.get("database", db.get("name", "N/A")), - db_type, - host_port, - username, - db.get("generated_id", "")[:8] + "...", - ) - console.print(table) - + spec = project.find(target) + engine = self.engines.get(spec.engine) -@app.command("add") -def add_db(name: str = typer.Argument(..., help="Name of the agent")): - path = Path(name).resolve() - validate_work_dir(path) - ensure_network("portabase_network") - - console.print(Panel("Add Database to Agent", style="bold blue")) - - while True: - storage_kind = questionary.select( - "What do you want to configure?", - choices=["done", "database", "docker-volume"], - default="database", - style=questionary_style, - ).ask() - - if storage_kind in (None, "done"): - break - - if storage_kind == "docker-volume": - console.print( - "[warning]⚠ Requires the Docker socket mounted on the agent " - "([bold]/var/run/docker.sock[/bold]). It will be added to " - "docker-compose.yml automatically.[/warning]" - ) - friendly_name = Prompt.ask("Display Name", default="Docker Volume") - volume_name = Prompt.ask("Volume Name (e.g. databases_sqlite-data)").strip() - while not volume_name: - console.print("[danger]✖ Volume Name is required.[/danger]") - volume_name = Prompt.ask( - "Volume Name (e.g. databases_sqlite-data)" - ).strip() - container_name = Prompt.ask( - "Container Name (optional, enables auto-restart after restore)", - default="", + if not yes: + extra = " and its Docker volume" if (purge_volume and spec.managed) else "" + self.confirm_or_abort( + f"Remove '{spec.name}' ({engine.describe(spec)}){extra}?", default=False ) - entry = { - "name": friendly_name, - "type": "docker-volume", - "volume_name": volume_name, - "generated_id": str(uuid.uuid4()), - } - if container_name: - entry["container_name"] = container_name - ensure_docker_socket(path) - add_db_to_json(path, entry) - console.print("[success]✔ Added to config[/success]") - continue - mode = Prompt.ask( - "Configuration Mode", - choices=["new", "existing", "back"], - default="existing", - ) - - if mode == "back": - break - - if mode == "existing": - db_type = questionary.select( - "Select Database Type", - choices=[ - "back", - "postgresql", - "postgresql-cluster", - "mysql", - "mariadb", - "sqlite", - "firebird", - "mongodb", - "mssql", - ], - style=questionary_style, - ).ask() - - if db_type == "back": - continue - - if not db_type: - raise typer.Exit() - - if db_type == "postgresql-cluster": - console.print( - "[warning]⚠ Postgres Cluster requires a superuser. " - "Cluster backup/restore uses pg_dumpall, which dumps all " - "databases and global objects (roles, tablespaces). " - "The provided user must be a Postgres superuser.[/warning]" + project.remove(spec, engine) + self.render_and_write(project) + self.ui.success(f"Removed {spec.name}") + + if spec.managed: + volume_name = f"{self.docker.project_name(project_path)}_{spec.host}-data" + if purge_volume: + self.require_docker(self.docker) + removed = self.docker.remove_volume(volume_name) + self.ui.success( + f"Deleted volume {volume_name}" + if removed + else f"Volume {volume_name} did not exist" ) - - friendly_name = Prompt.ask("Display Name", default="External DB") - - if db_type == "sqlite": - db_name = Prompt.ask("Database Path (e.g. /data/db.sqlite)") - entry = { - "name": friendly_name, - "database": db_name, - "type": db_type, - "generated_id": str(uuid.uuid4()), - } else: - db_name = Prompt.ask("Database Name") - host = Prompt.ask("Host", default="localhost") - port = IntPrompt.ask( - "Port", - default=5432 - if db_type in ["postgresql", "postgresql-cluster"] - else ( - 3050 - if db_type == "firebird" - else ( - 1433 - if db_type == "mssql" - else (3306 if db_type in ["mysql", "mariadb"] else 27017) - ) - ), + self.ui.info( + f"Data volume kept: {volume_name}. " + f"Delete it with: docker volume rm {volume_name}" ) - user = Prompt.ask("Username") - password = questionary.password( - "Password", style=questionary_style - ).ask() - if password is None: - raise typer.Exit() - - entry = { - "name": friendly_name, - "database": db_name, - "type": db_type, - "username": user, - "password": password, - "port": port, - "host": host, - "generated_id": str(uuid.uuid4()), - } - - if db_type == "postgresql": - console.print( - "[info]ℹ When enabled, omits [bold]--no-owner[/bold] and " - "[bold]--no-privileges[/bold] from the dump. Ownership and role " - "assignments are preserved in the output. By default, these flags " - "are applied to keep restores portable across different users and " - "environments, for example when migrating from one database " - "instance to another.[/info]" - ) - keep_ownership = questionary.confirm( - "Keep ownership?", - default=False, - style=questionary_style, - ).ask() - if keep_ownership is None: - raise typer.Exit() - - console.print( - "[info]ℹ Controls how the target database is cleaned before a " - "restore. [bold]pg_restore --clean[/bold] only drops objects " - "listed in the backup's own table of contents, so anything " - "already present in the target that the dump does not know " - "about survives and can make the restore fail.[/info]" - ) - clean_mode = questionary.select( - "Clean mode", - choices=[ - questionary.Choice( - "clean - pg_restore --clean --if-exists (default)", - value="clean", - ), - questionary.Choice( - "none - no pre-clean, restore into an empty database", - value="none", - ), - questionary.Choice( - "drop_schemas - drop every non-system schema CASCADE " - "(recommended, works on managed Postgres)", - value="drop_schemas", - ), - questionary.Choice( - "drop_database - DROP DATABASE + CREATE DATABASE " - "(full reset)", - value="drop_database", - ), - ], - default="clean", - style=questionary_style, - ).ask() - if clean_mode is None: - raise typer.Exit() - if clean_mode == "drop_database": - console.print( - "[warning]⚠ drop_database drops the whole target database " - "before restoring. The user must have CREATEDB and own the " - "database, or be a superuser. Most managed Postgres " - "providers do not allow it.[/warning]" - ) - - pg_options = {} - if keep_ownership: - pg_options["keep_ownership"] = True - if clean_mode != "clean": - pg_options["clean_mode"] = clean_mode - if pg_options: - entry["options"] = pg_options - - add_db_to_json(path, entry) - break - else: - db_engine = questionary.select( - "Select Database Engine", - choices=[ - "back", - "postgresql", - "postgresql-cluster", - "mysql", - "mariadb", - "sqlite", - "firebird", - "mongodb", - "redis", - "valkey", - "mssql", - ], - style=questionary_style, - ).ask() - - if db_engine == "back": - continue - - if not db_engine: - raise typer.Exit() - - db_variant = "no-auth" - if db_engine in ["mongodb", "redis", "valkey"]: - engine_display = { - "mongodb": "MongoDB", - "redis": "Redis", - "valkey": "Valkey", - }[db_engine] - db_variant = questionary.select( - f"Select {engine_display} Variant", - choices=["back", "no-auth", "with-auth"], - default="no-auth", - style=questionary_style, - ).ask() - - if db_variant == "back": - continue - - if not db_variant: - raise typer.Exit() - - env_vars = {} - snippet = "" - service_name = "" - db_name = "" - db_container_path = "" - db_user = "" - db_pass = "" - db_port = 0 - pg_options = None - - if db_engine == "sqlite": - db_name = Prompt.ask("Database Name", default="local") - if not db_name.endswith(".sqlite"): - db_name += ".sqlite" - - compose_path = path / "docker-compose.yml" - if compose_path.exists(): - content = compose_path.read_text() - lines = content.splitlines(keepends=True) - new_lines = [] - in_app_service = False - inserted = False - - for line in lines: - new_lines.append(line) - if not inserted: - if re.search(r"^ app:", line): - in_app_service = True - elif in_app_service and re.search(r"^ volumes:", line): - new_lines.append( - f" - ./{db_name}:/config/{db_name}\n" - ) - in_app_service = False - inserted = True - elif in_app_service and re.search(r"^ [a-zA-Z]", line): - in_app_service = False - - with open(compose_path, "w") as f: - f.writelines(new_lines) - - add_db_to_json( - path, - { - "name": db_name, - "database": f"/config/{db_name}", - "type": "sqlite", - "generated_id": str(uuid.uuid4()), - }, - ) - console.print(f"[success]✔ Added SQLite database ({db_name})[/success]") - - elif db_engine in ["postgresql", "postgresql-cluster"]: - db_port = get_free_port() - db_user = "admin" - db_pass = generate_password(16) - db_name = f"pg_{secrets.token_hex(4)}" - service_name = f"db-pg-{secrets.token_hex(2)}" - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(db_port) - env_vars[f"{var_prefix}_DB"] = db_name - env_vars[f"{var_prefix}_USER"] = db_user - env_vars[f"{var_prefix}_PASS"] = db_pass - snippet = ( - AGENT_POSTGRES_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - .replace("${USER}", f"${{{var_prefix}_USER}}") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - - if db_engine == "postgresql": - console.print( - "[info]ℹ When enabled, omits [bold]--no-owner[/bold] and " - "[bold]--no-privileges[/bold] from the dump. Ownership and role " - "assignments are preserved in the output. By default, these flags " - "are applied to keep restores portable across different users and " - "environments, for example when migrating from one database " - "instance to another.[/info]" - ) - keep_ownership = questionary.confirm( - "Keep ownership?", - default=False, - style=questionary_style, - ).ask() - if keep_ownership is None: - raise typer.Exit() - - console.print( - "[info]ℹ Controls how the target database is cleaned before a " - "restore. [bold]pg_restore --clean[/bold] only drops objects " - "listed in the backup's own table of contents, so anything " - "already present in the target that the dump does not know " - "about survives and can make the restore fail.[/info]" - ) - clean_mode = questionary.select( - "Clean mode", - choices=[ - questionary.Choice( - "clean - pg_restore --clean --if-exists (default)", - value="clean", - ), - questionary.Choice( - "none - no pre-clean, restore into an empty database", - value="none", - ), - questionary.Choice( - "drop_schemas - drop every non-system schema CASCADE " - "(recommended, works on managed Postgres)", - value="drop_schemas", - ), - questionary.Choice( - "drop_database - DROP DATABASE + CREATE DATABASE " - "(full reset)", - value="drop_database", - ), - ], - default="clean", - style=questionary_style, - ).ask() - if clean_mode is None: - raise typer.Exit() - if clean_mode == "drop_database": - console.print( - "[warning]⚠ drop_database drops the whole target database " - "before restoring. The user must have CREATEDB and own the " - "database, or be a superuser. Most managed Postgres " - "providers do not allow it.[/warning]" - ) - - options = {} - if keep_ownership: - options["keep_ownership"] = True - if clean_mode != "clean": - options["clean_mode"] = clean_mode - if options: - pg_options = options - - elif db_engine in ["mysql", "mariadb"]: - db_port = get_free_port() - db_user = "admin" - db_pass = generate_password(16) - db_name = f"mysql_{secrets.token_hex(4)}" - service_name = f"db-mariadb-{secrets.token_hex(2)}" - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(db_port) - env_vars[f"{var_prefix}_DB"] = db_name - env_vars[f"{var_prefix}_USER"] = db_user - env_vars[f"{var_prefix}_PASS"] = db_pass - snippet = ( - AGENT_MARIADB_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - .replace("${USER}", f"${{{var_prefix}_USER}}") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - - elif db_engine == "mongodb": - db_port = get_free_port() - db_name = f"mongo_{secrets.token_hex(4)}" - if db_variant == "with-auth": - db_user = "admin" - db_pass = generate_password(16) - service_name = f"db-mongo-auth-{secrets.token_hex(2)}" - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(db_port) - env_vars[f"{var_prefix}_DB"] = db_name - env_vars[f"{var_prefix}_USER"] = db_user - env_vars[f"{var_prefix}_PASS"] = db_pass - snippet = ( - AGENT_MONGODB_AUTH_SNIPPET.replace( - "${SERVICE_NAME}", service_name - ) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - .replace("${USER}", f"${{{var_prefix}_USER}}") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - else: - service_name = f"db-mongo-{secrets.token_hex(2)}" - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(db_port) - env_vars[f"{var_prefix}_DB"] = db_name - snippet = ( - AGENT_MONGODB_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - ) - - elif db_engine == "firebird": - db_port = get_free_port() - db_user = "alice" - db_pass = generate_password(16) - db_root_pass = generate_password(16) - db_name = "mirror.fdb" - db_container_path = f"/var/lib/firebird/data/{db_name}" - service_name = f"db-firebird-{secrets.token_hex(2)}" - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(db_port) - env_vars[f"{var_prefix}_DB"] = db_name - env_vars[f"{var_prefix}_USER"] = db_user - env_vars[f"{var_prefix}_PASS"] = db_pass - env_vars[f"{var_prefix}_ROOT_PASS"] = db_root_pass - snippet = ( - AGENT_FIREBIRD_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${DB_NAME}", f"${{{var_prefix}_DB}}") - .replace("${USER}", f"${{{var_prefix}_USER}}") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - .replace("${ROOT_PASSWORD}", f"${{{var_prefix}_ROOT_PASS}}") - ) - - elif db_engine == "redis": - db_port = get_free_port() - db_name = f"redis_{secrets.token_hex(4)}" - if db_variant == "with-auth": - db_pass = generate_password(16) - service_name = f"db-redis-auth-{secrets.token_hex(2)}" - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(db_port) - env_vars[f"{var_prefix}_PASS"] = db_pass - snippet = ( - AGENT_REDIS_AUTH_SNIPPET.replace( - "${SERVICE_NAME}", service_name - ) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - else: - service_name = f"db-redis-{secrets.token_hex(2)}" - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(db_port) - snippet = ( - AGENT_REDIS_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - ) - - elif db_engine == "valkey": - db_port = get_free_port() - db_name = f"valkey_{secrets.token_hex(4)}" - if db_variant == "with-auth": - db_pass = generate_password(16) - service_name = f"db-valkey-auth-{secrets.token_hex(2)}" - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(db_port) - env_vars[f"{var_prefix}_PASS"] = db_pass - snippet = ( - AGENT_VALKEY_AUTH_SNIPPET.replace( - "${SERVICE_NAME}", service_name - ) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - else: - service_name = f"db-valkey-{secrets.token_hex(2)}" - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(db_port) - snippet = ( - AGENT_VALKEY_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - ) - - elif db_engine == "mssql": - db_port = get_free_port() - db_pass = generate_password(16) - db_name = "master" - service_name = f"db-mssql-{secrets.token_hex(2)}" - var_prefix = service_name.upper().replace("-", "_") - env_vars[f"{var_prefix}_PORT"] = str(db_port) - env_vars[f"{var_prefix}_PASS"] = db_pass - snippet = ( - AGENT_MSSQL_SNIPPET.replace("${SERVICE_NAME}", service_name) - .replace("${PORT}", f"${{{var_prefix}_PORT}}") - .replace("${VOL_NAME}", f"{service_name}-data") - .replace("${PASSWORD}", f"${{{var_prefix}_PASS}}") - ) - - compose_path = path / "docker-compose.yml" - if compose_path.exists(): - content = compose_path.read_text() - - vol_match = re.search(r"^volumes:", content, re.MULTILINE) - net_match = re.search(r"^networks:", content, re.MULTILINE) - - if vol_match: - insert_pos = vol_match.start() - elif net_match: - insert_pos = net_match.start() - else: - insert_pos = len(content) - - content = content[:insert_pos] + snippet + "\n" + content[insert_pos:] - - vol_match = re.search(r"^volumes:", content, re.MULTILINE) - net_match = re.search(r"^networks:", content, re.MULTILINE) - vol_entry = f" {service_name}-data:\n" - - if vol_match: - if net_match and net_match.start() > vol_match.start(): - content = ( - content[: net_match.start()] - + vol_entry - + content[net_match.start() :] - ) - else: - if not content.endswith("\n"): - content += "\n" - content += vol_entry - else: - if not content.endswith("\n"): - content += "\n" - content += "\nvolumes:\n" + vol_entry - - with open(compose_path, "w") as f: - f.write(content) - - if db_engine != "sqlite": - write_env_file(path, env_vars) - new_entry = { - "name": "mirror.fdb" if db_engine == "firebird" else db_name, - "database": db_container_path - if db_engine == "firebird" - else ("0" if db_engine in ["redis", "valkey"] else db_name), - "type": db_engine, - "username": "sa" if db_engine == "mssql" else db_user, - "password": db_pass, - "port": 5432 - if db_engine in ["postgresql", "postgresql-cluster"] - else ( - 3050 - if db_engine == "firebird" - else ( - 3306 - if db_engine in ["mysql", "mariadb"] - else ( - 1433 - if db_engine == "mssql" - else (6379 if db_engine in ["redis", "valkey"] else 27017) - ) - ) - ), - "host": service_name, - "generated_id": str(uuid.uuid4()), - } - if pg_options is not None: - new_entry["options"] = pg_options - add_db_to_json(path, new_entry) - break - - console.print("[success]✔ Database added to configuration.[/success]") - console.print( - "[info]Restart the agent to apply changes: [/info]" - + f"portabase restart {name}" - ) - - -@app.command("remove") -def remove_db(name: str = typer.Argument(..., help="Name of the agent")): - path = Path(name).resolve() - validate_work_dir(path) - - config = load_db_config(path) - dbs = config.get("databases", []) + self.ui.info( + f"Restart the agent to apply changes: portabase restart {project_path.name}" + ) - 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) +class DbListCommand(_DbCommand): + name, help = "list", "List an agent's databases." - index = options.index(choice) - removed = dbs.pop(index) + 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() + ) + user = ( + "N/A" if d.engine in ("sqlite", "docker-volume") else (d.username or "") + ) + rows.append( + [ + d.name, + d.database or "", + d.engine, + engine.describe(d), + user, + opts, + d.id[:8] + "...", + ] + ) + self.ui.table( + ["Display Name", "Database", "Type", "Host:Port", "User", "Options", "ID"], + rows, + title=f"Databases for {project.path.name}", + ) - config["databases"] = dbs - save_db_config(path, config) - console.print(f"[success]✔ Removed {removed['name']}[/success]") - console.print("[info]Restart the agent to apply changes.[/info]") +class DbCommands(CommandGroup): + name, help, panel = "db", "Manage an agent's databases.", "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), + ] diff --git a/commands/decrypt.py b/commands/decrypt.py index d14ddfc..0b06a9b 100644 --- a/commands/decrypt.py +++ b/commands/decrypt.py @@ -1,8 +1,11 @@ +from __future__ import annotations + from pathlib import Path -from typing import Optional +from typing import Annotated import typer +from commands.base import Command from core.crypto import ( ENC_SUFFIX, DecryptionError, @@ -10,124 +13,102 @@ default_output_for, load_master_key, ) -from core.utils import console +from core.errors import ConfigError, ValidationError def _looks_like_dir(path: Path) -> bool: - """True when ``path`` is an existing directory or clearly names one.""" if path.exists(): return path.is_dir() - # A trailing separator or no suffix is treated as a directory hint. return str(path).endswith(("/", "\\")) or path.suffix == "" -def decrypt( - input_path: Path = typer.Argument( - ..., - help="A .enc file, or a folder containing .enc files.", - ), - output_path: Optional[Path] = typer.Argument( - None, - help="Output file or folder (must match the input type). " - "Defaults to the same directory as the input.", - ), - key: Optional[Path] = typer.Option( - None, - "--key", - "-k", - help="Path to the master key file. Defaults to 'master_key.bin' in the " - "current directory.", - ), -): - """Decrypt Portabase AES-256-GCM ``.enc`` backup files.""" - input_path = input_path.resolve() - - if not input_path.exists(): - console.print(f"[danger]✖ Input path not found: {input_path}[/danger]") - raise typer.Exit(1) - - try: +class DecryptCommand(Command): + name = "decrypt" + help = "Decrypt Portabase .enc backup files (single file or folder)." + panel = "Configuration" + no_args_is_help = True + + def run( + self, + input_path: Annotated[ + Path, + typer.Argument(help="A .enc file, or a folder containing .enc files."), + ], + output_path: Annotated[ + Path | None, + typer.Argument( + help="Output file or folder (must match the input type). " + "Defaults to the input directory." + ), + ] = None, + key: Annotated[ + Path | None, + typer.Option( + "--key", "-k", help="Master key file. Defaults to ./master_key.bin" + ), + ] = None, + ) -> None: + input_path = input_path.resolve() + if not input_path.exists(): + raise ConfigError(f"Input path not found: {input_path}") master_key = load_master_key(key.resolve() if key else None) - except DecryptionError as exc: - console.print(f"[danger]✖ {exc}[/danger]") - raise typer.Exit(1) - - if input_path.is_dir(): - _decrypt_folder(input_path, output_path, master_key) - else: - _decrypt_single(input_path, output_path, master_key) - - -def _decrypt_single( - enc_path: Path, output_path: Optional[Path], master_key: bytes -) -> None: - if enc_path.suffix != ENC_SUFFIX: - console.print( - f"[warning]⚠ {enc_path.name} does not end with {ENC_SUFFIX}; " - "decrypting anyway.[/warning]" + if input_path.is_dir(): + self._folder(input_path, output_path, master_key) + else: + self._single(input_path, output_path, master_key) + + def _single( + self, enc_path: Path, output_path: Path | None, master_key: bytes + ) -> None: + if enc_path.suffix != ENC_SUFFIX: + self.ui.warning( + f"{enc_path.name} does not end with {ENC_SUFFIX}; decrypting anyway." + ) + if output_path is None: + out = enc_path.parent / default_output_for(enc_path) + elif _looks_like_dir(output_path): + out = output_path.resolve() / default_output_for(enc_path) + else: + out = output_path.resolve() + try: + decrypt_enc_file(enc_path, out, master_key) + except OSError as e: + raise DecryptionError(f"I/O error on {enc_path.name}: {e}", cause=e) from e + 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( + p for p in in_dir.iterdir() if p.is_file() and p.suffix == ENC_SUFFIX ) - - if output_path is None: - out_path = enc_path.parent / default_output_for(enc_path) - elif _looks_like_dir(output_path): - out_path = output_path.resolve() / default_output_for(enc_path) - else: - out_path = output_path.resolve() - - try: - decrypt_enc_file(enc_path, out_path, master_key) - except DecryptionError as exc: - console.print(f"[danger]✖ Failed to decrypt {enc_path.name}: {exc}[/danger]") - raise typer.Exit(1) - except OSError as exc: - console.print(f"[danger]✖ I/O error on {enc_path.name}: {exc}[/danger]") - raise typer.Exit(1) - - console.print(f"[success]✔ Decrypted[/success] {enc_path.name} → {out_path}") - - -def _decrypt_folder( - in_dir: Path, output_path: Optional[Path], master_key: bytes -) -> None: - enc_files = sorted(p for p in in_dir.iterdir() if p.is_file() and p.suffix == ENC_SUFFIX) - - if not enc_files: - console.print(f"[warning]No {ENC_SUFFIX} files found in {in_dir}.[/warning]") - raise typer.Exit() - - if output_path is None: - out_dir = in_dir - elif _looks_like_dir(output_path): - out_dir = output_path.resolve() - else: - console.print( - "[danger]✖ Input is a folder, so the output must be a folder too.[/danger]" + if not enc_files: + self.ui.warning(f"No {ENC_SUFFIX} files found in {in_dir}.") + return + if output_path is None: + out_dir = in_dir + elif _looks_like_dir(output_path): + out_dir = output_path.resolve() + else: + raise ValidationError( + "Input is a folder, so the output must be a folder too." + ) + out_dir.mkdir(parents=True, exist_ok=True) + + failures: list[tuple[str, str]] = [] + with self.ui.status(f"Decrypting {len(enc_files)} file(s)..."): + for enc_path in enc_files: + out = out_dir / default_output_for(enc_path) + try: + decrypt_enc_file(enc_path, out, master_key) + except (DecryptionError, OSError) as e: + failures.append((enc_path.name, str(e))) + succeeded = len(enc_files) - len(failures) + self.ui.info( + f"Done: {succeeded} succeeded, {len(failures)} failed " + f"of {len(enc_files)} file(s)." ) - raise typer.Exit(1) - - out_dir.mkdir(parents=True, exist_ok=True) - - succeeded = 0 - failures: list[tuple[str, str]] = [] - - with console.status(f"[bold magenta]Decrypting {len(enc_files)} file(s)...[/bold magenta]"): - for enc_path in enc_files: - out_path = out_dir / default_output_for(enc_path) - try: - decrypt_enc_file(enc_path, out_path, master_key) - except (DecryptionError, OSError) as exc: - failures.append((enc_path.name, str(exc))) - console.print(f"[danger]✖ {enc_path.name}: {exc}[/danger]") - continue - succeeded += 1 - console.print(f"[success]✔[/success] {enc_path.name} → {out_path.name}") - - console.print( - f"\n[info]Done: {succeeded} succeeded, {len(failures)} failed " - f"of {len(enc_files)} file(s).[/info]" - ) - if failures: - console.print("[warning]Failed files:[/warning]") - for name, reason in failures: - console.print(f" [danger]•[/danger] {name}: {reason}") - raise typer.Exit(1) + if failures: + for name, reason in failures: + self.ui.print(f" [danger]•[/danger] {name}: {reason}") + raise DecryptionError(f"{len(failures)} file(s) failed to decrypt.") diff --git a/templates/__init__.py b/commands/flows/__init__.py similarity index 100% rename from templates/__init__.py rename to commands/flows/__init__.py diff --git a/commands/flows/add_database.py b/commands/flows/add_database.py new file mode 100644 index 0000000..878c4eb --- /dev/null +++ b/commands/flows/add_database.py @@ -0,0 +1,131 @@ +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 = {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 not extra: + return + flags = ", ".join("--" + k.replace("_", "-") for k in extra) + applicable = ", ".join("--" + f.name.replace("_", "-") for f 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..dc2adab --- /dev/null +++ b/commands/lifecycle.py @@ -0,0 +1,104 @@ +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." + verb, compose_args, done = "Restarting", ["restart"], "Restarted" + + +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 e: + self.ui.warning(f"Could not remove directory: {e}") + self.ui.success("Uninstalled") diff --git a/commands/update.py b/commands/update.py new file mode 100644 index 0000000..dd6cafb --- /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 e: + 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 diff --git a/core/config.py b/core/config.py index 6f47004..1e4c418 100644 --- a/core/config.py +++ b/core/config.py @@ -1,82 +1,40 @@ import json import os -import uuid from pathlib import Path -TEMPLATE_BASE_URL = "https://s3.eu-central-3.ionoscloud.com/portabase-software/cli/public/templates" GLOBAL_CONFIG_DIR = Path.home() / ".portabase" GLOBAL_CONFIG_FILE = GLOBAL_CONFIG_DIR / "config.json" -def write_file(path: Path, content: str): - path.parent.mkdir(parents=True, exist_ok=True) - with open(path, "w") as f: - f.write(content) -def write_env_file(work_dir: Path, env_vars: dict): - existing = {} - env_path = work_dir / ".env" - if env_path.exists(): - with open(env_path, "r") as f: - for line in f: - if "=" in line: - k, v = line.strip().split("=", 1) - existing[k] = v.strip('"') - - existing.update(env_vars) - content = "" - for k, v in existing.items(): - content += f'{k}="{v}"\n' - write_file(env_path, content) - -def load_global_config() -> dict: - if not GLOBAL_CONFIG_FILE.exists(): - return {} - try: - with open(GLOBAL_CONFIG_FILE, "r") as f: - return json.load(f) - except: - return {} - -def save_global_config(config: dict): - GLOBAL_CONFIG_DIR.mkdir(parents=True, exist_ok=True) - with open(GLOBAL_CONFIG_FILE, "w") as f: - json.dump(config, f, indent=2) - -def get_config_value(key: str, default=None): - config = load_global_config() - return config.get(key, default) - -def set_config_value(key: str, value): - config = load_global_config() - config[key] = value - save_global_config(config) - -def load_db_config(path: Path) -> dict: - json_path = path / "databases.json" - if not json_path.exists(): - return {"databases": []} - try: - with open(json_path, "r") as f: - return json.load(f) - except: - return {"databases": []} - -def save_db_config(path: Path, config: dict): - json_path = path / "databases.json" - with open(json_path, "w") as f: - json.dump(config, f, indent=2) - try: - os.chmod(json_path, 0o666) - except: - pass - -def add_db_to_json(path: Path, db_entry: dict): - config = load_db_config(path) - if "databases" not in config: - config["databases"] = [] - - if "generated_id" not in db_entry: - db_entry["generated_id"] = str(uuid.uuid4()) - - config["databases"].append(db_entry) - save_db_config(path, config) \ No newline at end of file +class GlobalConfig: + KNOWN_KEYS = ("update_channel",) + + def __init__(self, path: Path = GLOBAL_CONFIG_FILE) -> None: + self.path = path + self.cache_dir = path.parent / "cache" + + def all(self) -> dict: + if not self.path.exists(): + return {} + try: + with open(self.path, encoding="utf-8") as 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") diff --git a/core/crypto.py b/core/crypto.py index 016eee2..dfb4b2d 100644 --- a/core/crypto.py +++ b/core/crypto.py @@ -1,22 +1,5 @@ -"""AES-256-GCM decryption for Portabase ``.enc`` backup files. - -The ``.enc`` format is produced by the Rust encryption pipeline. Layout:: - - {"version":1,"cipher":"AES-256-GCM","chunk_size":16777216,"base_nonce":[...8 bytes...]}\n - [u32 big-endian ciphertext length][ciphertext + 16-byte GCM tag] # chunk 0 - [u32 big-endian ciphertext length][ciphertext + 16-byte GCM tag] # chunk 1 - ... - -* First line is a compact JSON header terminated by ``\n``. -* Each subsequent chunk is a 4-byte big-endian length prefix followed by the - AES-256-GCM ciphertext with the authentication tag appended (as emitted by - the Rust ``aes-gcm`` crate). -* The 12-byte nonce for chunk ``i`` is ``base_nonce (8 bytes) || i (u32 BE)``. -* The master key is the raw 32-byte AES-256 key. No key derivation is applied; - the Rust side base64-decodes ``masterKeyB64`` to obtain these same 32 bytes. -""" - import base64 +import contextlib import json import os import struct @@ -25,6 +8,8 @@ from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from core.errors import PortabaseError + ENC_SUFFIX = ".enc" DEFAULT_KEY_FILENAME = "master_key.bin" CIPHER_NAME = "AES-256-GCM" @@ -32,26 +17,16 @@ _LEN_PREFIX_LEN = 4 _AES_256_KEY_LEN = 32 _TAG_LEN = 16 -# Hard ceiling on a single chunk's plaintext, used when the header's declared -# chunk_size is missing or implausible. Bounds per-chunk allocation so a corrupt -# length prefix can never force an arbitrarily large read on a multi-GB file. _MAX_CHUNK_PLAINTEXT = 256 * 1024 * 1024 -# Copy plaintext to disk in bounded slices so a large chunk is never handed to -# the OS write path as one giant buffer. _WRITE_SLICE = 4 * 1024 * 1024 -class DecryptionError(Exception): - """Raised when a ``.enc`` file cannot be decrypted.""" +class DecryptionError(PortabaseError): + code = "E_CRYPTO" + exit_code = 8 def load_master_key(key_path: Path | None) -> bytes: - """Load the raw 32-byte AES-256 key from ``key_path``. - - When ``key_path`` is ``None`` the key file is looked up as - ``master_key.bin`` in the current working directory. The file may hold - either the raw 32 key bytes or the STANDARD base64 encoding of them. - """ if key_path is None: key_path = Path.cwd() / DEFAULT_KEY_FILENAME @@ -62,14 +37,12 @@ def load_master_key(key_path: Path | None) -> bytes: raw = key_path.read_bytes() - # Raw 32-byte key (as produced by the test/backup tooling). if len(raw) == _AES_256_KEY_LEN: return raw - # Otherwise try to interpret the file as base64 text (masterKeyB64). try: decoded = base64.standard_b64decode(raw.strip()) - except Exception: # binascii.Error / ValueError on malformed base64 + except ValueError: decoded = b"" if len(decoded) == _AES_256_KEY_LEN: return decoded @@ -81,12 +54,6 @@ def load_master_key(key_path: Path | None) -> bytes: def _read_header(handle) -> tuple[bytes, int]: - """Read and validate the JSON header line. - - Returns ``(base_nonce, chunk_size)`` where ``chunk_size`` is the declared - plaintext chunk size, clamped to a safe ceiling. It bounds how many bytes a - single chunk read may allocate regardless of total file size. - """ header_line = handle.readline() if not header_line: raise DecryptionError("File is empty: missing header.") @@ -97,7 +64,9 @@ def _read_header(handle) -> tuple[bytes, int]: cipher = header.get("cipher") if cipher != CIPHER_NAME: - raise DecryptionError(f"Unsupported cipher: {cipher!r} (expected {CIPHER_NAME}).") + raise DecryptionError( + f"Unsupported cipher: {cipher!r} (expected {CIPHER_NAME})." + ) base_nonce = bytes(header.get("base_nonce", [])) if len(base_nonce) != _BASE_NONCE_LEN: @@ -107,19 +76,11 @@ def _read_header(handle) -> tuple[bytes, int]: chunk_size = header.get("chunk_size") if not isinstance(chunk_size, int) or not 0 < chunk_size <= _MAX_CHUNK_PLAINTEXT: - # Unknown or implausible declared size: fall back to the hard ceiling as - # the per-chunk allocation limit rather than trusting the file. chunk_size = _MAX_CHUNK_PLAINTEXT return base_nonce, chunk_size def decrypt_enc_file(enc_path: Path, out_path: Path, key: bytes) -> None: - """Decrypt a single ``.enc`` file to ``out_path``. - - The plaintext is written to a temporary sibling file first and atomically - renamed on success, so a failure never leaves a partial output behind. - Raises :class:`DecryptionError` on any format or authentication failure. - """ aesgcm = AESGCM(key) tmp_path = out_path.with_name(out_path.name + ".part") @@ -134,13 +95,11 @@ def decrypt_enc_file(enc_path: Path, out_path: Path, key: bytes) -> None: while True: len_buf = src.read(_LEN_PREFIX_LEN) if not len_buf: - break # clean end of stream + break if len(len_buf) != _LEN_PREFIX_LEN: raise DecryptionError("Truncated chunk length prefix.") chunk_len = struct.unpack(">I", len_buf)[0] - # Bound the allocation before reading: a corrupt prefix must - # not be able to trigger a multi-GB read on a large file. if chunk_len < _TAG_LEN: raise DecryptionError( f"Chunk {chunk_index} length {chunk_len} is smaller than " @@ -168,8 +127,6 @@ def decrypt_enc_file(enc_path: Path, out_path: Path, key: bytes) -> None: "(wrong key or corrupt data)." ) from exc - # Release the ciphertext buffer before writing the plaintext, - # then write in bounded slices to keep the footprint flat. del ciphertext for start in range(0, len(plaintext), _WRITE_SLICE): dst.write(plaintext[start : start + _WRITE_SLICE]) @@ -177,15 +134,12 @@ def decrypt_enc_file(enc_path: Path, out_path: Path, key: bytes) -> None: os.replace(tmp_path, out_path) except BaseException: - try: + with contextlib.suppress(OSError): tmp_path.unlink(missing_ok=True) - except OSError: - pass raise def default_output_for(enc_path: Path) -> str: - """Return the plaintext filename for ``enc_path`` (strips a trailing ``.enc``).""" name = enc_path.name if name.endswith(ENC_SUFFIX): return name[: -len(ENC_SUFFIX)] diff --git a/core/docker.py b/core/docker.py deleted file mode 100644 index b66c1e0..0000000 --- a/core/docker.py +++ /dev/null @@ -1,19 +0,0 @@ -import subprocess -import typer -from core.utils import console, slugify_project_name -from pathlib import Path - -def ensure_network(name: str): - try: - subprocess.run(["docker", "network", "inspect", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) - except subprocess.CalledProcessError: - subprocess.run(["docker", "network", "create", name], stdout=subprocess.DEVNULL, check=True) - -def run_compose(cwd: Path, args: list): - try: - project_name = slugify_project_name(cwd.resolve().name) - cmd = ["docker", "compose", "-p", project_name] + args - subprocess.run(cmd, cwd=cwd, check=True) - except subprocess.CalledProcessError: - console.print("[danger]Command failed.[/danger]") - raise typer.Exit(1) \ No newline at end of file diff --git a/core/errors.py b/core/errors.py new file mode 100644 index 0000000..8d0612d --- /dev/null +++ b/core/errors.py @@ -0,0 +1,61 @@ +from __future__ import annotations + + +class PortabaseError(Exception): + code: str = "E_GENERIC" + exit_code: int = 1 + + def __init__( + self, + message: str, + *, + hint: str | None = None, + cause: BaseException | None = None, + ) -> None: + super().__init__(message) + self.message = message + self.hint = hint + self.cause = cause + if cause is not None: + self.__cause__ = cause + + def __str__(self) -> str: + return self.message + + +class UserAbort(PortabaseError): + code = "E_ABORT" + exit_code = 130 + + def __init__(self, message: str = "Canceled.", **kwargs) -> None: + super().__init__(message, **kwargs) + + +class ValidationError(PortabaseError): + code = "E_VALIDATION" + exit_code = 2 + + +class ConfigError(PortabaseError): + code = "E_CONFIG" + exit_code = 3 + + +class DockerError(PortabaseError): + code = "E_DOCKER" + exit_code = 4 + + +class TemplateError(PortabaseError): + code = "E_TEMPLATE" + exit_code = 5 + + +class NetworkError(PortabaseError): + code = "E_NETWORK" + exit_code = 6 + + +class UpdateError(PortabaseError): + code = "E_UPDATE" + exit_code = 7 diff --git a/core/fields.py b/core/fields.py new file mode 100644 index 0000000..cd01959 --- /dev/null +++ b/core/fields.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Literal + +FieldKind = Literal["text", "int", "secret", "bool", "choice", "path"] + + +@dataclass(frozen=True) +class Field: + name: str + prompt: str + kind: FieldKind = "text" + default: Any = None + choices: tuple[str, ...] = () + help: str | None = None + validator: Callable[[Any], Any] | None = None + + @property + def flag(self) -> str: + return "--" + self.name.replace("_", "-") diff --git a/core/network.py b/core/network.py deleted file mode 100644 index 9eeeb84..0000000 --- a/core/network.py +++ /dev/null @@ -1,26 +0,0 @@ -import requests -import typer -from rich.console import Console -from core.config import TEMPLATE_BASE_URL -from core.utils import current_version, get_random_hint - -console = Console() - -def fetch_template(filename: str) -> str: - version = current_version() - url = f"{TEMPLATE_BASE_URL}/{version if version != 'unknown' else 'latest'}/{filename}" - - try: - status_msg = f"[dim]Fetching template...[/dim]\n{get_random_hint()}" - with console.status(status_msg): - response = requests.get(url, timeout=10) - if response.status_code in [403, 404] and version != "unknown": - url = f"{TEMPLATE_BASE_URL}/latest/{filename}" - response = requests.get(url, timeout=10) - - response.raise_for_status() - return response.text - except requests.RequestException as e: - console.print(f"[bold red] Error fetching template:[/bold red] {e}") - console.print("[dim]Check your internet connection or the template URL.[/dim]") - raise typer.Exit(1) \ No newline at end of file diff --git a/core/specs.py b/core/specs.py new file mode 100644 index 0000000..82ec84c --- /dev/null +++ b/core/specs.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from typing import Any + + +@dataclass(frozen=True) +class DatabaseSpec: + id: str + engine: str + name: str + managed: bool = False + host: str | None = None + port: int | None = None + host_port: int | None = None + database: str | None = None + username: str | None = None + password: str | None = None + root_password: str | None = None + path: str | None = None + volume: str | None = None + container: str | None = None + options: dict[str, Any] = field(default_factory=dict) + + @property + def env_prefix(self) -> str: + if not self.host: + raise ValueError("env_prefix requires a host/service name") + return self.host.upper().replace("-", "_") + + @property + def auth(self) -> bool: + return bool(self.password) + + def with_options(self, options: dict[str, Any]) -> DatabaseSpec: + return replace(self, options=dict(options)) diff --git a/core/updater.py b/core/updater.py deleted file mode 100644 index be187fe..0000000 --- a/core/updater.py +++ /dev/null @@ -1,291 +0,0 @@ -import json -import os -import platform -import shutil -import subprocess -import sys -import tempfile -import time -from pathlib import Path - -import requests -import typer - -from core.config import get_config_value -from core.utils import console, current_version, get_random_hint - -GITHUB_REPO = "Portabase/cli" -GITHUB_API_BASE_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases" -CACHE_FILE = Path.home() / ".portabase" / "update_cache.json" - - -def is_prerelease(version: str) -> bool: - v = version.lower() - return any(x in v for x in ["a", "b", "rc", "alpha", "beta"]) - - -def get_platform_info(): - system = platform.system().lower() - if system == "darwin": - system = "macos" - machine = platform.machine().lower() - - arch = "amd64" - if machine in ["arm64", "aarch64"]: - arch = "arm64" - elif machine in ["x86_64", "amd64"]: - arch = "amd64" - - return system, arch - - -def get_latest_release_data(pre=False): - try: - if not pre: - response = requests.get(f"{GITHUB_API_BASE_URL}/latest", timeout=5) - response.raise_for_status() - return response.json() - else: - response = requests.get(GITHUB_API_BASE_URL, timeout=5) - response.raise_for_status() - releases = response.json() - return releases[0] if releases else None - except Exception: - return None - - -def check_for_updates(force=False): - if ( - not force - and not getattr(sys, "frozen", False) - and platform.system().lower() != "windows" - ): - return None - - current = current_version() - if current == "unknown": - return None - - channel = get_config_value("update_channel") - if channel: - include_pre = channel == "beta" - else: - include_pre = is_prerelease(current) - - latest_tag = None - - try: - CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) - if not force and CACHE_FILE.exists(): - with open(CACHE_FILE, "r") as f: - cache = json.load(f) - if time.time() - cache.get("last_check", 0) < 86400: - latest_tag = cache.get("latest_version") - except Exception: - pass - - if latest_tag is None: - data = get_latest_release_data(pre=include_pre) - if data: - latest_tag = data.get("tag_name", "").lstrip("v") - try: - with open(CACHE_FILE, "w") as f: - json.dump( - {"last_check": time.time(), "latest_version": latest_tag}, f - ) - except Exception: - pass - - if not latest_tag: - return None - - if latest_tag != current: - console.print( - f"\n[warning]⚠ A new version of Portabase CLI is available: [bold]{latest_tag}[/bold] (current: {current})[/warning]" - ) - console.print("[info]Run [bold]portabase update[/bold] to update.[/info]\n") - return latest_tag - return None - - -def update_cli(): - if not getattr(sys, "frozen", False) and platform.system().lower() != "windows": - console.print( - "[warning]⚠ The update command is only available for the binary version of Portabase CLI.[/warning]" - ) - console.print( - "[info]If you installed via source, please use [bold]git pull[/bold] to update.[/info]" - ) - return - - current = current_version() - - channel = get_config_value("update_channel") - if channel: - pre = channel == "beta" - else: - pre = is_prerelease(current) if current != "unknown" else False - - data = get_latest_release_data(pre=pre) - if not data: - console.print( - "[danger]✖ Could not fetch latest release data from GitHub.[/danger]" - ) - return - - latest_tag = data.get("tag_name", "").lstrip("v") - - if latest_tag == current: - console.print( - f"[success]✔ Portabase CLI is already up to date ({current}).[/success]" - ) - return - - try: - if latest_tag < current and not ( - is_prerelease(current) and not is_prerelease(latest_tag) - ): - console.print( - f"[warning]⚠ Current version ({current}) appears to be older than the latest remote version ({latest_tag}).[/warning]" - ) - if not typer.confirm("Do you want to continue with the update ?"): - return - except Exception: - pass - - system, arch = get_platform_info() - asset_name = f"portabase_{system}_{arch}" - if system == "windows": - asset_name += ".exe" - - asset = next((a for a in data.get("assets", []) if a["name"] == asset_name), None) - - if not asset: - console.print( - f"[danger]✖ Could not find binary for your platform ({system}/{arch}) in the latest release.[/danger]" - ) - available_assets = [a["name"] for a in data.get("assets", [])] - console.print(f"[info]Target asset name: {asset_name}[/info]") - console.print(f"[info]Available assets: {', '.join(available_assets)}[/info]") - return - - console.print( - f"[info]Updating Portabase CLI from {current} to {latest_tag}...[/info]" - ) - - try: - if system == "windows": - default_bin_path = ( - Path(os.environ.get("APPDATA", "")) / "Portabase" / "portabase.exe" - ) - else: - default_bin_path = Path("/usr/local/bin/portabase") - - if getattr(sys, "frozen", False): - current_exe = Path(sys.executable) - else: - if default_bin_path.exists(): - current_exe = default_bin_path - else: - current_exe = ( - Path.home() - / ".local" - / "bin" - / ("portabase" if system != "windows" else "portabase.exe") - ) - - console.print(f"[info]Target installation path: {current_exe}[/info]") - - download_url = asset["browser_download_url"] - fd, temp_path = tempfile.mkstemp(prefix="portabase_update_") - temp_file = Path(temp_path) - os.close(fd) - - try: - response = requests.get(download_url, stream=True, timeout=15) - response.raise_for_status() - total_size = int(response.headers.get("content-length", 0)) - - from rich.progress import ( - BarColumn, - DownloadColumn, - Progress, - SpinnerColumn, - TextColumn, - TransferSpeedColumn, - ) - - with Progress( - SpinnerColumn(), - TextColumn( - "[progress.description]{task.description}\n[hint]" - + get_random_hint() - + "[/hint]" - ), - BarColumn(), - DownloadColumn(), - TransferSpeedColumn(), - console=console, - ) as progress: - task = progress.add_task( - f"Downloading {asset_name}...", total=total_size - ) - with open(temp_file, "wb") as f: - for chunk in response.iter_content(chunk_size=8192): - if chunk: - f.write(chunk) - progress.update(task, advance=len(chunk)) - except Exception as e: - if temp_file.exists(): - temp_file.unlink() - raise e - - if system != "windows": - temp_file.chmod(0o755) - - if system == "windows": - if current_exe.exists(): - old_exe = Path(f"{current_exe}.old") - if old_exe.exists(): - old_exe.unlink() - current_exe.rename(old_exe) - temp_file.rename(current_exe) - else: - need_sudo = not os.access(current_exe.parent, os.W_OK) or ( - current_exe.exists() and not os.access(current_exe, os.W_OK) - ) - - if need_sudo: - console.print( - "[info]Permissions required to install to /usr/local/bin. Using sudo...[/info]" - ) - if current_exe.exists(): - subprocess.run( - ["sudo", "mv", str(current_exe), f"{current_exe}.old"], - check=False, - ) - subprocess.run( - ["sudo", "mv", str(temp_file), str(current_exe)], check=True - ) - subprocess.run(["sudo", "chmod", "+x", str(current_exe)], check=True) - else: - if current_exe.exists(): - old_exe = Path(f"{current_exe}.old") - if old_exe.exists(): - old_exe.unlink() - current_exe.rename(old_exe) - current_exe.parent.mkdir(parents=True, exist_ok=True) - shutil.move(str(temp_file), str(current_exe)) - - try: - console.print(f"[success]✔ Successfully updated to {latest_tag}![/success]") - except Exception: - print(f"Successfully updated to {latest_tag}!") - - except Exception as e: - try: - console.print(f"[danger]✖ An error occurred during update: {e}[/danger]") - except Exception: - print(f"An error occurred during update: {e}") - if "temp_file" in locals() and temp_file.exists(): - temp_file.unlink() diff --git a/core/utils.py b/core/utils.py index a6ec75e..c676f18 100644 --- a/core/utils.py +++ b/core/utils.py @@ -1,69 +1,9 @@ import base64 import binascii import json -import platform -import random import re import secrets -import shutil -import socket import string -import subprocess -import time -from pathlib import Path - -import typer -from questionary import Style -from rich.align import Align -from rich.console import Console, Theme -from rich.prompt import Confirm - -questionary_style = Style( - [ - ("pointer", "fg:#ff8800 bold"), - ("highlighted", "fg:black bg:#ff8800 bold"), - ("selected", "fg:#ff8800 bold"), - ] -) - -custom_theme = Theme( - { - "info": "dim cyan", - "warning": "magenta", - "danger": "bold red", - "success": "bold green", - "title": "bold white on #5f00d7", - "key": "bold #ff6600", - "value": "white", - "hint": "italic dim white", - } -) - -HINTS = [ - "The Edge Key contains the connection details for dashboard and agent communication.", - "Portabase uses Docker Compose to isolate your databases.", - "You can list all configured databases using 'portabase db list '.", - "Running 'portabase stop' will gracefully shut down your containers.", - "The agent polls the github for configuration updates.", - "Logs can be viewed in real-time with 'portabase logs '.", - "Custom environment variables can be added to the generated .env file.", - "Need to update? Use 'portabase update' to get the latest version.", - "You can add multiple databases to a single agent during setup.", - "Portabase Dashboard provides a web interface to manage your infrastructure.", - "Is Docker not running? The CLI will offer to start it for you!", - "All configurations are stored locally in the component's folder.", - "The 'portabase restart' command is useful after manual .env modifications.", - "Portabase is open-source! Check our GitHub to contribute.", - "Using the --start flag with 'agent' or 'dashboard' skips the final prompt.", - "Internal databases are automatically backed up when using volumes.", - "The dashboard requires a PostgreSQL database to store its own data.", - "You can change the update channel to 'beta' in the config for early features.", - "Portabase network ensures secure communication between your containers.", - "Lost your Edge Key? You can find it in the dashboard.", - "The 'portabase uninstall' command safely removes containers and their data.", - "Use 'portabase --version' to check your current installation details.", - "The 'databases.json' file keeps track of all managed database instances.", -] def generate_password(length: int = 16) -> str: @@ -74,7 +14,7 @@ def generate_password(length: int = 16) -> str: lower = string.ascii_lowercase upper = string.ascii_uppercase digits = string.digits - symbols = "!@#$%^&*()-_=+[]{}|;:,.<>?" + symbols = "!@#%^&*()-_=+[]{}|;:,.<>?" password = [ secrets.choice(lower), @@ -99,99 +39,7 @@ def slugify_project_name(value: str, fallback: str = "portabase") -> str: return slug or fallback -def get_random_hint(): - return f"[hint]{random.choice(HINTS)}[/hint]" - - -console = Console(theme=custom_theme) - -BANNER = """ -[bold #ff6600]█▀█ █▀█ █▀█ ▀█▀ ▄▀█ █▄▄ ▄▀█ █▀ █▀▀[/bold #ff6600] -[bold #ff6600]█▀▀ █▄█ █▀▄  █  █▀█ █▄█ █▀█ ▄█ ██▄[/bold #ff6600] -[dim]Deploy your infrastructure anywhere.[/dim] -""" - - -def print_banner(): - console.print(Align.center(BANNER)) - console.print(Align.center(get_random_hint() + "\n")) - - -def get_free_port(): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("", 0)) - return s.getsockname()[1] - - -def start_docker(): - """Attempts to start the Docker daemon based on the OS.""" - os_type = platform.system() - - try: - if os_type == "Linux": - subprocess.run(["sudo", "systemctl", "start", "docker"], check=True) - elif os_type == "Darwin": - subprocess.run(["open", "--background", "-a", "Docker"], check=True) - elif os_type == "Windows": - subprocess.run(["start", "docker"], shell=True, check=True) - - console.print("[info]Waiting for Docker to start...[/info]") - for _ in range(10): - try: - subprocess.run( - ["docker", "info"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=True, - ) - console.print("[success]✔ Docker started successfully.[/success]") - return True - except: - time.sleep(2) - except Exception as e: - console.print(f"[danger]✖ Failed to start Docker:[/danger] {e}") - - return False - - -def check_system(): - docker_path = shutil.which("docker") - - if docker_path is None: - console.print("[danger]✖ Docker not found (binary missing).[/danger]") - raise typer.Exit(1) - - try: - subprocess.run( - [docker_path, "info"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=True, - ) - except subprocess.CalledProcessError: - console.print( - "[warning]⚠ Docker is installed but the Daemon is not running.[/warning]" - ) - if Confirm.ask("Do you want to try starting Docker?"): - if start_docker(): - return - - console.print("[danger]✖ Docker is required to continue.[/danger]") - raise typer.Exit(1) - except Exception as e: - console.print(f"[danger]✖ Critical Error executing Docker:[/danger] {e}") - raise typer.Exit(1) - - -def validate_work_dir(path: Path): - if not (path / "docker-compose.yml").exists(): - console.print(f"[danger]No Portabase configuration found in: {path}[/danger]") - raise typer.Exit(1) - return path - - def validate_edge_key(key: str) -> bool: - """Validates the integrity of the EDGE_KEY (Base64 or JSON).""" try: try: decoded_bytes = base64.b64decode(key, validate=True) @@ -205,23 +53,5 @@ def validate_edge_key(key: str) -> bool: required_fields = ["serverUrl", "agentId", "masterKeyB64"] return all(field in data for field in required_fields) - except Exception: + except TypeError: return False - - -def current_version() -> str: - - try: - import sys - import tomllib - from pathlib import Path - - if getattr(sys, "frozen", False): - base_path = Path(sys._MEIPASS) - else: - base_path = Path(__file__).parent.parent - with open(base_path / "pyproject.toml", "rb") as f: - __version__ = tomllib.load(f)["project"]["version"] - except (FileNotFoundError, KeyError, ImportError, AttributeError): - __version__ = "unknown" - return __version__ diff --git a/core/version.py b/core/version.py new file mode 100644 index 0000000..0ea8299 --- /dev/null +++ b/core/version.py @@ -0,0 +1,37 @@ +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: + bundled = getattr(sys, "_MEIPASS", None) + base = Path(bundled) if bundled 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]: + 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) 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..51f8cae --- /dev/null +++ b/docs/superpowers/plans/2026-09-11-plan-2-foundations-lifecycle.md @@ -0,0 +1,2385 @@ +# 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`, `commands/decrypt.py`, `core/crypto.py`, `core/network.py`, `core/docker.py`, `templates/compose.py` (réécrits ou supprimés au Plan 4). `decrypt` (ajouté en 26.08.12) est enregistré via `LegacyCommand` comme `agent`/`dashboard`. `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 import decrypt as legacy_decrypt +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"), + LegacyCommand(ui, telemetry, legacy_decrypt.decrypt, name="decrypt", help="Decrypt Portabase .enc backup files (single file or folder).", panel="Configuration"), + 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"] +"commands/decrypt.py" = ["B904", "TID251"] +"core/crypto.py" = ["BLE001", "SIM105"] +"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` (decrypt, 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..e93ca1c --- /dev/null +++ b/docs/superpowers/plans/2026-09-11-plan-4-render-commands.md @@ -0,0 +1,1935 @@ +# 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/decrypt.py` | réécrire | `DecryptCommand` | +| `core/crypto.py` | modifier | `DecryptionError(PortabaseError)` | +| `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 8b : `commands/decrypt.py` en classe + +**Files:** +- Modify: `core/crypto.py` (`DecryptionError` hérite de `PortabaseError`) +- Modify: `commands/decrypt.py` (réécriture) + +**Interfaces:** +- Produces: `DecryptCommand(ui, telemetry)` ; `core.crypto.DecryptionError(PortabaseError)` avec `code = "E_CRYPTO"`, `exit_code = 8`. + +- [ ] **Step 1: `core/crypto.py`** + +Remplacer la définition de `DecryptionError` par : + +```python +from core.errors import PortabaseError + + +class DecryptionError(PortabaseError): + """Raised when a ``.enc`` file cannot be decrypted.""" + + code = "E_CRYPTO" + exit_code = 8 +``` + +Le reste du module (fonctions pures de déchiffrement) est inchangé. + +- [ ] **Step 2: `commands/decrypt.py`** + +```python +"""portabase decrypt INPUT [OUTPUT] — decrypt .enc backups (file or folder).""" + +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, help, panel = "decrypt", "Decrypt Portabase .enc backup files (single file or folder).", "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) # raises DecryptionError + 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 e: + raise DecryptionError(f"I/O error on {enc_path.name}: {e}", cause=e) from e + 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(p for p in in_dir.iterdir() if p.is_file() and p.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 e: + failures.append((enc_path.name, str(e))) + continue + succeeded = len(enc_files) - len(failures) + self.ui.info(f"Done: {succeeded} succeeded, {len(failures)} failed 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érence assumée : les succès individuels ne sont plus imprimés un par un sous le spinner (un `console.print` sous `status` est autorisé mais bruyant) ; le résumé final reste. `main.py` remplace le `LegacyCommand` decrypt par `DecryptCommand(ui, telemetry)` (Task 9). + +- [ ] **Step 3: Vérifier** + +Run: `uv run python main.py decrypt /tmp/nope; echo "exit=$?"` → `E_CONFIG`, exit 3. `uv run python main.py decrypt . ; echo "exit=$?"` sans `master_key.bin` → `E_CRYPTO`, exit 8. Avec un vrai `.enc` et sa clé si disponible : déchiffrement identique à 26.08.12. + +- [ ] **Step 4: Commit** + +```bash +git add core/crypto.py commands/decrypt.py +git commit -m "refactor(commands): rewrite decrypt as a Command class" +``` + +--- + +### 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 commands.decrypt import DecryptCommand +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`, `decrypt as legacy_decrypt`, `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), + DecryptCommand(ui, telemetry), + 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..cb62728 --- /dev/null +++ b/docs/superpowers/specs/2026-09-11-cli-refactor-design.md @@ -0,0 +1,592 @@ +# 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. | +| `decrypt INPUT [OUTPUT]` | `--key` | déchiffre des sauvegardes `.enc` (ajouté en 26.08.12) ; `DecryptCommand`, `DecryptionError(PortabaseError)` code `E_CRYPTO` exit 8. `core/crypto.py` reste un module de fonctions pures. | + +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 | +| `DecryptionError` (`core/crypto.py`) | `E_CRYPTO` | 8 | + +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..5107c10 --- /dev/null +++ b/engines/base.py @@ -0,0 +1,175 @@ +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 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 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 [] + + @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]: + 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]: + 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 = {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: + if inline: + return 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..fca6a4b --- /dev/null +++ b/engines/mongodb.py @@ -0,0 +1,40 @@ +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 diff --git a/engines/mssql.py b/engines/mssql.py new file mode 100644 index 0000000..2d110bc --- /dev/null +++ b/engines/mssql.py @@ -0,0 +1,33 @@ +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]: + p = spec.env_prefix + return {f"{p}_PORT": str(spec.host_port), f"{p}_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..0a872a8 --- /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]: + 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" 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..c9a95c6 --- /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]: + 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" diff --git a/main.py b/main.py index 320a703..fe5da03 100644 --- a/main.py +++ b/main.py @@ -1,91 +1,215 @@ -from typing import Optional +import os +import platform +import sys +from dataclasses import dataclass +from typing import Annotated +import click import typer -from commands import agent, common, config, dashboard, db, decrypt -from core.updater import check_for_updates, update_cli -from core.utils import console, current_version - -app = typer.Typer( - no_args_is_help=True, - add_completion=False, +from commands.agent import AgentCommand +from commands.build import BuildCommand +from commands.config import ConfigCommands +from commands.dashboard import DashboardCommand +from commands.db import DbCommands +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: + # Typer renders --help with its own Rich console, which only honors + # the NO_COLOR convention; --help is handled before any callback runs. + 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() + + 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), + DecryptCommand(ui, telemetry), + 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) + 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 + 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((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()): + result = app(standalone_mode=False) + if isinstance(result, int): + exit_code = result + 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 + except click.exceptions.Exit as e: + exit_code = e.exit_code + 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.print("") + ui.warning("Canceled.") + 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) -def version_callback(value: bool): - if value: - console.print(f"Portabase CLI version: {current_version()}") - check_for_updates(force=True) - raise typer.Exit() - - -@app.callback() -def main( - ctx: typer.Context, - _: Optional[bool] = typer.Option( - None, - "--version", - help="Show the version and exit.", - callback=version_callback, - is_eager=True, - ), -): - """ - Portabase CLI to manage agents, dashboards and databases. - """ - if ctx.invoked_subcommand != "update": - check_for_updates() - - -@app.command(help="Update the CLI to the latest version.", rich_help_panel="System") -def update(): - update_cli() - - -app.command( - help="Create a new Portabase Agent instance.", - rich_help_panel="Creation", - no_args_is_help=True, -)(agent.agent) -app.command( - help="Create a new Portabase Dashboard instance.", - rich_help_panel="Creation", - no_args_is_help=True, -)(dashboard.dashboard) -app.command( - help="Start a Portabase component.", - rich_help_panel="Lifecycle", - no_args_is_help=True, -)(common.start) -app.command( - help="Stop a Portabase component.", - rich_help_panel="Lifecycle", - no_args_is_help=True, -)(common.stop) -app.command( - help="Restart a Portabase component.", - rich_help_panel="Lifecycle", - no_args_is_help=True, -)(common.restart) -app.command( - help="View logs of a Portabase component.", - rich_help_panel="Lifecycle", - no_args_is_help=True, -)(common.logs) -app.command( - help="Uninstall and delete a Portabase component.", - rich_help_panel="Lifecycle", - no_args_is_help=True, -)(common.uninstall) - -app.command( - help="Decrypt Portabase .enc backup files (single file or folder).", - rich_help_panel="Configuration", - no_args_is_help=True, -)(decrypt.decrypt) - -app.add_typer(db.app, name="db", rich_help_panel="Configuration") -app.add_typer(config.app, name="config", rich_help_panel="Configuration") if __name__ == "__main__": - app() + main() diff --git a/portabase.spec b/portabase.spec new file mode 100644 index 0000000..2d67e20 --- /dev/null +++ b/portabase.spec @@ -0,0 +1,49 @@ +# PyInstaller build definition. Declarative, so the workflows do not carry +# build flags and `uv run pyinstaller portabase.spec` reproduces CI locally. +# +# The binary name comes from PORTABASE_BINARY_NAME (default: portabase); the +# release matrix sets it to portabase__. +import os + +from PyInstaller.utils.hooks import collect_all, collect_data_files + +NAME = os.environ.get("PORTABASE_BINARY_NAME", "portabase") + +datas = [ + ("pyproject.toml", "."), + ("templates", "templates"), +] +binaries = [] +hiddenimports = [] + +for package in ("rich", "requests"): + pkg_datas, pkg_binaries, pkg_hidden = collect_all(package) + datas += pkg_datas + binaries += pkg_binaries + hiddenimports += pkg_hidden + +datas += collect_data_files("certifi") + +a = Analysis( + ["main.py"], + pathex=["."], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + noarchive=False, +) + +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name=NAME, + debug=False, + strip=False, + upx=False, + console=True, +) diff --git a/pyproject.toml b/pyproject.toml index 8657581..5ae4b14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,12 +5,66 @@ description = "The official command line interface (CLI) for managing and deploy readme = "README.md" requires-python = ">=3.12" dependencies = [ - "rich>=14.2.0", "typer>=0.20.0", - "pyinstaller>=6.17.0", - "requests>=2.32.5", - "pyyaml >=6.0.0", + "rich>=14.2.0", "questionary>=2.1.0", + "requests>=2.32.5", "pyyaml>=6.0.3", - "cryptography>=44.0.0" + "cryptography>=44.0.0", + "jinja2>=3.1", +] + +[dependency-groups] +dev = [ + "pyinstaller>=6.17.0", + "ruff>=0.16.0", + "pytest>=8.3", + "mypy>=1.15", +] + +[tool.ruff] +target-version = "py312" +line-length = 88 +extend-exclude = [".venv", "build", "dist", "docs", "*.md", "*.spec"] + +[tool.ruff.lint] +select = [ + "E", "F", "W", + "I", + "UP", + "B", + "BLE", + "S110", + "E722", + "TID251", + "SIM", + "TRY201", + "PLW1510", +] +ignore = [ + "B008", + "E501", ] + +[tool.ruff.lint.per-file-ignores] +"ui/**" = ["TID251"] + +[tool.ruff.lint.flake8-tidy-imports.banned-api] +"rich.prompt".msg = "Use ui.form() / ui.confirm() instead." +"rich.console".msg = "Only ui/ may build a Console. Use the UI facade." +"typer.prompt".msg = "Use ui.form() instead." +"typer.confirm".msg = "Use ui.confirm() instead." + +[tool.ruff.lint.isort] +known-first-party = ["commands", "core", "engines", "services", "ui"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" + +[tool.mypy] +python_version = "3.12" +files = ["commands", "core", "engines", "services", "ui", "scripts", "main.py"] +ignore_missing_imports = true +warn_unused_ignores = true +warn_redundant_casts = true diff --git a/release b/release deleted file mode 100755 index 09d0903..0000000 --- a/release +++ /dev/null @@ -1,96 +0,0 @@ -#!/bin/bash - -set -e - -if [ -z "$1" ]; then - echo "Usage: ./release " - echo "Example: ./release v1.0.0" - exit 1 -fi - -VERSION=$1 -CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) - -if [ "$CURRENT_BRANCH" = "main" ]; then - if [[ ! "$VERSION" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Error: On 'main' branch, only release tags (vX.Y.Z) are allowed." - exit 1 - fi -else - if [[ ! "$VERSION" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+([-.]?(rc|alpha|beta|a|b)[0-9]*(\.[0-9]+)?)?$ ]]; then - echo "Error: On branch '$CURRENT_BRANCH', only pre-release tags matching vX.Y.Z[-.](rc|a|b|...)W are allowed." - echo "Examples: 1.0.0-rc.1, 26.01.1a, 26.01.1b, 26.01.1rc1" - exit 1 - fi -fi - -CLEAN_VERSION=${VERSION#v} -CURRENT_DATE=$(date +%Y-%m-%d) - -echo "Preparing release $VERSION..." - - -# package.json -if [ -f package.json ]; then - echo "Updating package.json..." - if sed --version >/dev/null 2>&1; then - sed -i "s/\"version\": \".*\"/\"version\": \"$CLEAN_VERSION\"/" package.json - else - sed -i '' "s/\"version\": \".*\"/\"version\": \"$CLEAN_VERSION\"/" package.json - fi -fi - -# pyproject.toml -if [ -f pyproject.toml ]; then - echo "Updating pyproject.toml..." - if sed --version >/dev/null 2>&1; then - sed -i "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" pyproject.toml - else - sed -i '' "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" pyproject.toml - fi -fi - -# Cargo.toml -if [ -f Cargo.toml ]; then - echo "Updating Cargo.toml..." - if sed --version >/dev/null 2>&1; then - sed -i "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" Cargo.toml - else - sed -i '' "s/^version = \".*\"/version = \"$CLEAN_VERSION\"/" Cargo.toml - fi -fi - -# CITATION.cff -if [ -f CITATION.cff ]; then - echo "Updating CITATION.cff..." - if sed --version >/dev/null 2>&1; then - sed -i "s/^version: .*/version: $CLEAN_VERSION/" CITATION.cff - sed -i "s/^date-released: .*/date-released: \"$CURRENT_DATE\"/" CITATION.cff - else - sed -i '' "s/^version: .*/version: $CLEAN_VERSION/" CITATION.cff - sed -i '' "s/^date-released: .*/date-released: \"$CURRENT_DATE\"/" CITATION.cff - fi -fi - -git add . - -if ! git diff-index --quiet HEAD --; then - echo "Committing changes..." - git commit -m "chore(release): $VERSION" -else - echo "No changes to commit. Proceeding to tag..." -fi - -if git rev-parse "$VERSION" >/dev/null 2>&1; then - echo "Tag $VERSION already exists. Aborting." - exit 1 -fi - -echo "Creating tag $VERSION..." -git tag -a "$VERSION" -m "Release $VERSION" - -echo "Pushing changes and tags to remote..." -git push -git push origin "$VERSION" - -echo "Successfully released $VERSION!" diff --git a/scripts/render_check.py b/scripts/render_check.py new file mode 100644 index 0000000..68c7952 --- /dev/null +++ b/scripts/render_check.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 + +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.specs import DatabaseSpec # noqa: E402 +from engines import registry # noqa: E402 +from engines.base import DbEngine # noqa: E402 +from services.envfile import EnvFile # noqa: E402 +from services.ports import FixedPortAllocator # noqa: E402 +from services.project import AgentProject, DashboardProject # noqa: E402 +from services.renderer import ComposeRenderer # noqa: E402 +from services.templates import TemplateRepository # noqa: E402 + +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", +} + + +class Failure(Exception): + pass + + +def env_text(project: AgentProject | DashboardProject) -> str: + return "".join(f'{k}="{v}"\n' for k, v in project.env.as_dict().items()) + + +def validate(label: str, compose: str, env: 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, 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_project( + tmp: Path, + pairs: list[tuple[DatabaseSpec, DbEngine]], + *, + host_gateway: bool = False, + sqlite: bool = False, + docker_volume: bool = False, +) -> AgentProject: + env = EnvFile(tmp / ".env") + env.merge(AGENT_ENV) + project = AgentProject(tmp, env, [], host_gateway) + for spec, engine in pairs: + project.add(spec, engine) + if sqlite: + sq = registry.get("sqlite") + project.add( + sq.generate(auth=False, ports=FixedPortAllocator(), answers={"name": "x"}), + sq, + ) + if docker_volume: + dv = registry.get("docker-volume") + project.add(dv.from_existing({"volume": "v"}), dv) + return project + + +def agent_cases(renderer: ComposeRenderer) -> list[tuple[str, str, str]]: + ports = FixedPortAllocator() + tmp = Path(tempfile.mkdtemp()) + cases: list[tuple[str, str, str]] = [] + + 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, docker_volume=True) + cases.append( + ("agent/toggles", renderer.render_agent(toggles).compose, env_text(toggles)) + ) + + everything: list[tuple[DatabaseSpec, DbEngine]] = [] + 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)]) + variant = ("/auth" if auth else "/noauth") if engine.auth_variants else "" + cases.append( + ( + f"agent/{engine.key}{variant}", + renderer.render_agent(one).compose, + env_text(one), + ) + ) + everything.append((spec, engine)) + + combined = agent_project(tmp, everything, host_gateway=True, sqlite=True) + cases.append( + ("agent/all", renderer.render_agent(combined).compose, env_text(combined)) + ) + return cases + + +def dashboard_cases(renderer: ComposeRenderer) -> list[tuple[str, str, str]]: + tmp = Path(tempfile.mkdtemp()) + variants = { + "external": {**DASHBOARD_BASE, **DASHBOARD_PG, "POSTGRES_HOST": "db"}, + "internal": DASHBOARD_BASE, + "custom": {**DASHBOARD_BASE, **DASHBOARD_PG, "POSTGRES_HOST": "remote"}, + } + cases = [] + for mode, values in variants.items(): + env = EnvFile(tmp / f".env.{mode}") + env.merge(values) + project = DashboardProject(tmp, env) + if project.db_mode != mode: + raise Failure(f"db_mode mismatch: {project.db_mode} != {mode}") + cases.append( + ( + f"dashboard/{mode}", + renderer.render_dashboard(project).compose, + env_text(project), + ) + ) + return cases + + +def engines_check(repo: TemplateRepository) -> None: + shipped = set(repo.names()) + used = set() + for engine in registry: + if engine.template is None: + continue + if engine.template not in shipped: + raise Failure( + f"{engine.key}: template {engine.template} not found in {repo.root}" + ) + used.add(engine.template) + orphans = { + name for name in shipped if name.startswith("engines/") and name not in used + } + if orphans: + raise Failure( + f"template(s) not used by any engine: {', '.join(sorted(orphans))}" + ) + print(f"ok engines-check ({len(used)} templates, {len(registry.keys())} engines)") + + +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(Path(args.templates)) + renderer = ComposeRenderer(repo, registry, "render-check") + try: + engines_check(repo) + for label, compose, env in agent_cases(renderer) + dashboard_cases(renderer): + validate(label, compose, env, use_compose) + except Failure as e: + print(f"FAIL {e}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/compose_facts.py b/services/compose_facts.py new file mode 100644 index 0000000..f54bd7f --- /dev/null +++ b/services/compose_facts.py @@ -0,0 +1,43 @@ +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 diff --git a/services/docker.py b/services/docker.py new file mode 100644 index 0000000..8dd3491 --- /dev/null +++ b/services/docker.py @@ -0,0 +1,126 @@ +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 + + 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 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 + except OSError as e: + raise DockerError(f"Could not run docker: {e}", cause=e) from e diff --git a/services/envfile.py b/services/envfile.py new file mode 100644 index 0000000..2cc9f3e --- /dev/null +++ b/services/envfile.py @@ -0,0 +1,85 @@ +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 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_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..03661e4 --- /dev/null +++ b/services/http.py @@ -0,0 +1,79 @@ +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: + 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 diff --git a/services/ports.py b/services/ports.py new file mode 100644 index 0000000..7bff3fa --- /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 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): + 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..9436beb --- /dev/null +++ b/services/project.py @@ -0,0 +1,198 @@ +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 _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 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)] + facts = ComposeFacts(path / COMPOSE_FILE) + project = cls(path, env, databases, facts.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) + + @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: list[tuple[str, str]] = [] + 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 "") + + 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() diff --git a/services/renderer.py b/services/renderer.py new file mode 100644 index 0000000..57d748b --- /dev/null +++ b/services/renderer.py @@ -0,0 +1,167 @@ +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 engines 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" + ) + 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: + return (env.get(key) or "") if inline else 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": 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) + 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 e: + raise TemplateError(f"Template rendering failed: {e}", cause=e) from e 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..dbd5b6c --- /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( + p.relative_to(self.root).as_posix() for p 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 e: + raise TemplateError( + f"Template '{name}' is missing from {root}.", cause=e + ) from e + except jinja2.TemplateError as e: + raise TemplateError( + f"Template '{name}' failed to load: {e}", cause=e + ) from e diff --git a/services/updater.py b/services/updater.py new file mode 100644 index 0000000..708ec6b --- /dev/null +++ b/services/updater.py @@ -0,0 +1,222 @@ +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={ + 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: + 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 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") + 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 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 diff --git a/templates/agent.yml.j2 b/templates/agent.yml.j2 new file mode 100644 index 0000000..d865063 --- /dev/null +++ b/templates/agent.yml.j2 @@ -0,0 +1,36 @@ +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 diff --git a/templates/compose.py b/templates/compose.py deleted file mode 100644 index d2bc011..0000000 --- a/templates/compose.py +++ /dev/null @@ -1,203 +0,0 @@ -AGENT_POSTGRES_SNIPPET = """ - ${SERVICE_NAME}: - image: postgres:17-alpine - restart: unless-stopped - networks: - - portabase - ports: - - "${PORT}:5432" - volumes: - - ${VOL_NAME}:/var/lib/postgresql/data - environment: - - POSTGRES_DB=${DB_NAME} - - POSTGRES_USER=${USER} - - POSTGRES_PASSWORD=${PASSWORD} - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${USER} -d ${DB_NAME}"] - interval: 10s - timeout: 5s - retries: 5 -""" - -AGENT_MARIADB_SNIPPET = """ - ${SERVICE_NAME}: - image: mariadb:latest - restart: unless-stopped - networks: - - portabase - ports: - - "${PORT}:3306" - environment: - - MYSQL_DATABASE=${DB_NAME} - - MYSQL_USER=${USER} - - MYSQL_PASSWORD=${PASSWORD} - - MYSQL_RANDOM_ROOT_PASSWORD=yes - volumes: - - ${VOL_NAME}:/var/lib/mysql - healthcheck: - test: ["CMD-SHELL", "mariadb-admin ping -h localhost -u ${USER} -p${PASSWORD}"] - interval: 10s - timeout: 5s - retries: 5 -""" - -AGENT_MONGODB_AUTH_SNIPPET = """ - ${SERVICE_NAME}: - image: mongo:latest - restart: unless-stopped - networks: - - portabase - ports: - - "${PORT}:27017" - environment: - - MONGO_INITDB_ROOT_USERNAME=${USER} - - MONGO_INITDB_ROOT_PASSWORD=${PASSWORD} - - MONGO_INITDB_DATABASE=${DB_NAME} - command: mongod --auth - volumes: - - ${VOL_NAME}:/data/db - healthcheck: - test: ["CMD-SHELL", "mongosh --eval 'db.runCommand({ping:1})' --quiet"] - interval: 10s - timeout: 5s - retries: 5 -""" - -AGENT_MONGODB_SNIPPET = """ - ${SERVICE_NAME}: - image: mongo:latest - restart: unless-stopped - networks: - - portabase - ports: - - "${PORT}:27017" - environment: - - MONGO_INITDB_DATABASE=${DB_NAME} - volumes: - - ${VOL_NAME}:/data/db - healthcheck: - test: ["CMD-SHELL", "mongosh --eval 'db.runCommand({ping:1})' --quiet"] - interval: 10s - timeout: 5s - retries: 5 -""" - -AGENT_FIREBIRD_SNIPPET = """ - ${SERVICE_NAME}: - image: firebirdsql/firebird - restart: unless-stopped - networks: - - portabase - ports: - - "${PORT}:3050" - volumes: - - ${VOL_NAME}:/var/lib/firebird/data - environment: - - FIREBIRD_DATABASE=${DB_NAME} - - FIREBIRD_USER=${USER} - - FIREBIRD_PASSWORD=${PASSWORD} - - FIREBIRD_ROOT_PASSWORD=${ROOT_PASSWORD} - - FIREBIRD_DATABASE_DEFAULT_CHARSET=UTF8 - healthcheck: - test: ["CMD-SHELL", "nc -z localhost 3050"] - interval: 10s - timeout: 5s - retries: 5 -""" - - -AGENT_REDIS_SNIPPET = """ - ${SERVICE_NAME}: - image: redis:latest - ports: - - "${PORT}:6379" - volumes: - - ${VOL_NAME}:/data - command: [ "redis-server", "--appendonly", "yes" ] - networks: - - portabase - - default - healthcheck: - test: ["CMD-SHELL", "redis-cli ping | grep PONG"] - interval: 10s - timeout: 5s - retries: 5 -""" - -AGENT_REDIS_AUTH_SNIPPET = """ - ${SERVICE_NAME}: - image: redis:latest - ports: - - "${PORT}:6379" - volumes: - - ${VOL_NAME}:/data - environment: - - REDIS_PASSWORD=${PASSWORD} - command: [ "redis-server", "--requirepass", "${PASSWORD}", "--appendonly", "yes" ] - networks: - - portabase - - default - healthcheck: - test: ["CMD-SHELL", "redis-cli -a ${PASSWORD} ping | grep PONG"] - interval: 10s - timeout: 5s - retries: 5 -""" - - -AGENT_VALKEY_SNIPPET = """ - ${SERVICE_NAME}: - image: valkey/valkey:latest - environment: - - ALLOW_EMPTY_PASSWORD=yes - ports: - - "${PORT}:6379" - volumes: - - ${VOL_NAME}:/data - networks: - - portabase - - default - healthcheck: - test: ["CMD-SHELL", "valkey-cli ping | grep PONG"] - interval: 10s - timeout: 5s - retries: 5 -""" - -AGENT_VALKEY_AUTH_SNIPPET = """ - ${SERVICE_NAME}: - image: valkey/valkey:latest - command: --requirepass "${PASSWORD}" - ports: - - "${PORT}:6379" - volumes: - - ${VOL_NAME}:/data - networks: - - portabase - - default - healthcheck: - test: ["CMD-SHELL", "valkey-cli -a ${PASSWORD} ping | grep PONG"] - interval: 10s - timeout: 5s - retries: 5 -""" - -AGENT_MSSQL_SNIPPET = """ - ${SERVICE_NAME}: - image: mcr.microsoft.com/azure-sql-edge:latest - restart: unless-stopped - networks: - - portabase - ports: - - "${PORT}:1433" - environment: - - ACCEPT_EULA=Y - - MSSQL_SA_PASSWORD=${PASSWORD} - volumes: - - ${VOL_NAME}:/var/opt/mssql - healthcheck: - test: ["CMD-SHELL", "cat /proc/net/tcp6 | grep -q '059901' || exit 1"] - interval: 10s - timeout: 5s - retries: 20 -""" diff --git a/templates/dashboard.yml.j2 b/templates/dashboard.yml.j2 new file mode 100644 index 0000000..aa20be5 --- /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..f11f488 --- /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..31c6c22 --- /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..c1e0e6e --- /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..2855ea7 --- /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..31c6c22 --- /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..6dcf870 --- /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..6dcf870 --- /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..52a18ec --- /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..885d123 --- /dev/null +++ b/templates/engines/valkey.yml.j2 @@ -0,0 +1,21 @@ + {{ 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 --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..2b6e834 --- /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 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..3082328 --- /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 n: progress.update(task, advance=n) diff --git a/ui/components/prompt.py b/ui/components/prompt.py new file mode 100644 index 0000000..28a926f --- /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 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() 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..193fc91 --- /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 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) diff --git a/ui/form.py b/ui/form.py new file mode 100644 index 0000000..3f2003a --- /dev/null +++ b/ui/form.py @@ -0,0 +1,119 @@ +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), + } + + 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} + + 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 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) + 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 diff --git a/ui/theme.py b/ui/theme.py new file mode 100644 index 0000000..816576f --- /dev/null +++ b/ui/theme.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from questionary import Style +from rich.theme import Theme + +PALETTE = { + "brand": "#ff6600", + "accent": "#5f00d7", + "info": "cyan", + "warning": "magenta", + "danger": "red", + "success": "green", + "muted": "grey50", +} + +RICH_THEME = Theme( + { + "info": f"dim {PALETTE['info']}", + "warning": PALETTE["warning"], + "danger": f"bold {PALETTE['danger']}", + "success": f"bold {PALETTE['success']}", + "title": f"bold white on {PALETTE['accent']}", + "key": f"bold {PALETTE['brand']}", + "value": "white", + "hint": f"italic {PALETTE['muted']}", + "brand": f"bold {PALETTE['brand']}", + } +) + +QUESTIONARY_STYLE = Style( + [ + ("qmark", f"fg:{PALETTE['brand']} bold"), + ("question", "bold"), + ("pointer", f"fg:{PALETTE['brand']} bold"), + ("highlighted", f"fg:black bg:{PALETTE['brand']} bold"), + ("selected", f"fg:{PALETTE['brand']} bold"), + ("answer", f"fg:{PALETTE['brand']}"), + ] +) + +QUESTIONARY_STYLE_PLAIN = Style([]) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..19cda1b --- /dev/null +++ b/uv.lock @@ -0,0 +1,961 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "altgraph" +version = "0.17.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/a2/04a9383e7512c91c54b2f34b3ff86dc7d2610506f588c2bda36e952a68f7/ast_serialize-0.11.1.tar.gz", hash = "sha256:cc5db2983805f6be786488aac8c5998d5b71965488d1b18c44d435a2205a5cb4", size = 953785, upload-time = "2026-09-09T16:05:27.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/cb/3577c66278e4ea4ff6968aee04327447335ecfc58c76123f68cf7f76d75e/ast_serialize-0.11.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f7ca1557553fdab313999ad69156de154ff87152cae963698be2e765164bb6c0", size = 897021, upload-time = "2026-09-09T16:03:59.316Z" }, + { url = "https://files.pythonhosted.org/packages/47/db/9b4eed53ab0bb63653f56ef71062fa91693c8c26b0b648c58d98365c990c/ast_serialize-0.11.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8b54e44763a851c336ca137c60a2434511f0d155e0b924ef374bfbc8d8db8927", size = 1235148, upload-time = "2026-09-09T16:04:01.201Z" }, + { url = "https://files.pythonhosted.org/packages/fd/97/1ebe323015c3cf530f08e88df9929bfd9f5cf8165842bd4b37a7c958565c/ast_serialize-0.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:753afabcc4abf295f515ea33185cf997a1990b88a29d3a3336a9acf62ea85950", size = 1216082, upload-time = "2026-09-09T16:04:02.87Z" }, + { url = "https://files.pythonhosted.org/packages/ac/72/0d3a368edc2d1e0e188c0d3f23821f4e20a6a2291b23d9df482a6b11277f/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98b8aba6bb682b9e8859c628382b4881c0600b28056d63c3168c227c449f3284", size = 1282848, upload-time = "2026-09-09T16:04:04.316Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/2a546a57d7caa708d739c1747bbdf45cc3aa9dac9407a11d9d67e8a0e255/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:651a7890558896c3e08089e51ab3b3a43710eab15b72ad861c400bc9a51923ba", size = 1285337, upload-time = "2026-09-09T16:04:05.899Z" }, + { url = "https://files.pythonhosted.org/packages/bf/16/4ff4179584f8b8bec348787bf721cb7ce3d4c707c7878778065249b59b9a/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff8ddc1453bfe7934292409c7b8e9f68b0a6cff4e4c12535e4b4a66fd83aca0c", size = 1554906, upload-time = "2026-09-09T16:04:07.42Z" }, + { url = "https://files.pythonhosted.org/packages/df/e8/e54a54676ca8965e1ae3cfe065dc26c75158e0b3a3ce35af283da106fbf9/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:daec16b026c5741950a00c708acdf698c4f17babbe4829dd8232324822e9514f", size = 1301472, upload-time = "2026-09-09T16:04:08.782Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ab/618835a2479868a0d4ac807dd04cf2f89d24afe22c4171538d9779c2772e/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9961abcb93bf03652ab09c00862a2396abacd57551c769ad34490a2ae508a61d", size = 1301293, upload-time = "2026-09-09T16:04:10.17Z" }, + { url = "https://files.pythonhosted.org/packages/61/cb/8acc29a1b279ccdfb30dcbdf099e1d8d34c4ee77be9be751c48061433129/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:8ca9e48edf246f09fd9bdb64fbd9cd8d18cd2a69ffc24d44891466e17595094f", size = 1307807, upload-time = "2026-09-09T16:04:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/da/74/bada875132f452cb5c1179e3156df43cee6df6cb51f773220688be3d4bb4/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6a57e0e025e9dcee48e466b3cf909178e298d238148664e796fc0f60ded5e52e", size = 1356265, upload-time = "2026-09-09T16:04:13.429Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ae/720a8042fc8c1d170f4cc7ed46295ef078f8198ed3476f58210a1e675e0c/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:110386eebccf200446d5f4dcc215f80866a5d3d3d054d3ced6ec1bf386cd3f02", size = 1459957, upload-time = "2026-09-09T16:04:14.878Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2c/ce2ff1ffd4d70376073393c154033728c8535c17211b7710fee7b0f04474/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ef3950747f3a588f692cf4d6f2937eedd1a2d5e199f863af29e8ec7da78a4f36", size = 1562369, upload-time = "2026-09-09T16:04:16.563Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3e/d7a5dd5f609bd0bac45617ce9425e4bd1e369b998c8678edfd70c985408c/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0bc94b81878a3f999fe4cdb606bf00ef7dff6b4f66d0e089b2cf7bfc00d59beb", size = 1556466, upload-time = "2026-09-09T16:04:18.396Z" }, + { url = "https://files.pythonhosted.org/packages/99/cd/a45179802a8637f2be252163ec4e4948534fa8d88798dd391c9cc54e096e/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:341dfc9e6b4e47e5c37e1b7257a77d39f407cf49a371ee90225ab624c2fe8b36", size = 1687190, upload-time = "2026-09-09T16:04:19.965Z" }, + { url = "https://files.pythonhosted.org/packages/01/eb/ef206d764ec0554368501b8b5268f5468819dc0588815acb0575465643df/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7b524cb1b08e15db0f116075c39ab51674b6d3c8103ec1a59c8ca464be83e28a", size = 1481188, upload-time = "2026-09-09T16:04:21.368Z" }, + { url = "https://files.pythonhosted.org/packages/8b/37/9a724c523af7c8b97a659bf4dc28a8e60f97d0cf4d19be277db4067ca734/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c2358b341aca08bd2e1005713bc480bc06eee9f345b67fcb826829aa7b8d0d3", size = 1500731, upload-time = "2026-09-09T16:04:22.905Z" }, + { url = "https://files.pythonhosted.org/packages/48/10/890d5fcaf568088de7989e8cdb4ca525f485bd6cb25eea49d45f017500af/ast_serialize-0.11.1-cp314-cp314t-win32.whl", hash = "sha256:b9e61143a5904f46daa0cde74ba60d3b7c0e3e000752ee138e2bc68e126a5803", size = 1119009, upload-time = "2026-09-09T16:04:24.469Z" }, + { url = "https://files.pythonhosted.org/packages/9d/61/2bd32bd16b5e2ee07badc8c32a73c545dc5a9f087ed19c07eee1243d5a1b/ast_serialize-0.11.1-cp314-cp314t-win_amd64.whl", hash = "sha256:31df8649761d5cb6eb0de2209bd4a167c7477c0516147c70c0b2f5bddc7839bb", size = 1155665, upload-time = "2026-09-09T16:04:26.638Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b6/84bb5e1dc418e660524b873c5bac9be2bf13d38676c96feef4cd4e7d03f3/ast_serialize-0.11.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2f8f33e416a4c7aad12ad757e895c676e953b4027b80b90718dccea83d45111a", size = 1131854, upload-time = "2026-09-09T16:04:28.099Z" }, + { url = "https://files.pythonhosted.org/packages/8c/dd/16de2c0d23a6b298c735d4e4b56d86fa70476eef98e2c66ceaa65503b62f/ast_serialize-0.11.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:a015ede631eeb098a23de8ec0cfe11a1dd02159ab7a26bdc7c6bde2befbbc7b4", size = 1235495, upload-time = "2026-09-09T16:04:29.654Z" }, + { url = "https://files.pythonhosted.org/packages/27/f7/302d2251e6298bbeabc8a1815c9147127031517b305001a02f34fd46b6b5/ast_serialize-0.11.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:57b7d52d1f5c92905cd648bda9efc83cac33097de1e5bb68de8feca9b5f7e87a", size = 1215642, upload-time = "2026-09-09T16:04:31.186Z" }, + { url = "https://files.pythonhosted.org/packages/93/06/93b6527646613502f364cebfb23783fa6701cdbe90761ee26144f1fa20b9/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e47b9b028efbc0486263a49f782ad0ae3e879855fe5a2897b59feefb78e1c40c", size = 1283526, upload-time = "2026-09-09T16:04:32.597Z" }, + { url = "https://files.pythonhosted.org/packages/2f/ac/ffb216262c9a582d039d846081b8c3017dc368cbed5a2520deca5cb7f8cb/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:00adb748034c7b1938ec56925f9f6230ffcd9c56d47355fe32485df29b90b977", size = 1287526, upload-time = "2026-09-09T16:04:34.214Z" }, + { url = "https://files.pythonhosted.org/packages/a2/06/19a4c4837c4d5e73b982938d6da3c9ae10513b61527575b3e1b8396f9298/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:82359d00ea808c260c327e43955c0190bb7baef152a01ae0b5c74d05387b9552", size = 1558354, upload-time = "2026-09-09T16:04:35.669Z" }, + { url = "https://files.pythonhosted.org/packages/e5/29/2373199907b2d97feed176115308dfb3dd0446566b047009828f266d2d66/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f35bb9ddeed4008c7d3e272dd1999885d712e1ce623b0a65ba8a8ed1d51c35cf", size = 1302731, upload-time = "2026-09-09T16:04:37.423Z" }, + { url = "https://files.pythonhosted.org/packages/33/55/ab6805a1457dbe565c84f8d36e2aa4207ef22408adfd01d225823cd07484/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74093ed682ba58456f0d4450da7fb62b826fddd97824d44fc4a81ca6e0bf9e23", size = 1301594, upload-time = "2026-09-09T16:04:38.861Z" }, + { url = "https://files.pythonhosted.org/packages/52/e2/4f54eab2201bb420d39bf61423abcb5d4daffd28e2270e1c5eee2bf3c0f1/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:4dd7218870c203eff4533cfc04a39f29077d6789b20f5b746e7648fe5762a548", size = 1309355, upload-time = "2026-09-09T16:04:40.543Z" }, + { url = "https://files.pythonhosted.org/packages/10/07/755dd98664e2374080b72ccb5fea63d9f11b4bec2409a05b2a4ebc97618d/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e8d20700766171a8a17f89cf53d7bd9712c509460d28a51f7f98340577b78aa", size = 1356645, upload-time = "2026-09-09T16:04:42.299Z" }, + { url = "https://files.pythonhosted.org/packages/34/a0/fadbde3e108064236e2679728ce6d8247aefc81bac5fd82505b59cf69172/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:20b3371c0403099cd55d59f4bcedb6d3994d9db3fa41711c648760a89ef2a575", size = 1459696, upload-time = "2026-09-09T16:04:44.069Z" }, + { url = "https://files.pythonhosted.org/packages/02/fd/b4f95249bd895368ea8290668e27d1f0a5c4ad888bd72a34c7beb2a500d6/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:cc1a78b913f8665dda145999b1b4805ad2ef442ecbe3b819512cf7f95e70498a", size = 1562517, upload-time = "2026-09-09T16:04:45.461Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/6afac594380410710d9d863b9ef41e9c6ca89bd901bda1464ee9e99180a8/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:38f7141881e783bccc362d201d7fd7437934216b669b9fef39febc2f63fd2d9b", size = 1556951, upload-time = "2026-09-09T16:04:47.12Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/5a81b5cbf1731f4722bc90adf4b4ace7dda69ca29837844147063aeafb22/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:13903a3f212ab9e5d05a6c3f2337288f4f8f4c18e6ab3817417ae7a3d46dee14", size = 1691039, upload-time = "2026-09-09T16:04:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/d9b903206cbd6d5143838367d385fa88ec49eaae2cc12f284327c6e0af05/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:e5a9c53e64732118ae8235d0e57eb90e2333e90ff749a1a7dca6f8bfdfa5200e", size = 1483297, upload-time = "2026-09-09T16:04:50.136Z" }, + { url = "https://files.pythonhosted.org/packages/ed/48/ee13b4079ec67e9b333a87e5bb8eee643ab1b49f7173b3fca71158c6cdf6/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:aecb606f67f21fd1c0ffba1826470d2ee400e41b23881ad9a1b2157306865b1a", size = 1501686, upload-time = "2026-09-09T16:04:51.734Z" }, + { url = "https://files.pythonhosted.org/packages/15/67/2175b79e1ee6b042e4bf8ed6871e60cafea7e9cf3fc69e87d7a9d520ae77/ast_serialize-0.11.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:d1ef9c478d8c8ca83499704d13e5ac32c840ed7ca7884aaaf716166aca5a2806", size = 1119522, upload-time = "2026-09-09T16:04:53.248Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c3/8c96aa5f121e9bdda0346b2df3919185eb1ec9a2cce2d20e311b4575ca0b/ast_serialize-0.11.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:27eef0739e0110f5db1ff5dfa38b3dadfcb6f0c31975a2b412f9f2bd72139cea", size = 1157260, upload-time = "2026-09-09T16:04:54.944Z" }, + { url = "https://files.pythonhosted.org/packages/10/bc/8aa663209e335eca73c9e1006197222b6b8dafa6665e513cdd975aa65496/ast_serialize-0.11.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:f9454960bbf185d33c669ccd5c9b76c96709a4542a95a65e27342c4d10dd8e20", size = 1132060, upload-time = "2026-09-09T16:04:56.501Z" }, + { url = "https://files.pythonhosted.org/packages/aa/52/185edf155ff422744acfb2869a8adff84b7dc308027852feb97287b8688b/ast_serialize-0.11.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:2db9df3bca1431da25946ee8f2c50df01212e34e6029532bc90b92d05fa8d7a0", size = 897219, upload-time = "2026-09-09T16:04:58.217Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a7/e738887429de70350d9e25f84a95efbdd086b79579a811509dee8b02d7d5/ast_serialize-0.11.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7b9a6089f1337838492b217707e9a55d0b9b407fe9169a960fa12282abf2234c", size = 1240585, upload-time = "2026-09-09T16:04:59.616Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/46b64fa883f3c8b8cf51629978f16d8a2da3d5c5c02f64add40864907703/ast_serialize-0.11.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:68929da7fb1c7375f69641baac9519d5c41ee441cd09a591ea5dfc83107ffe6f", size = 1228038, upload-time = "2026-09-09T16:05:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/ad/89/fe75c8d0f104a4be11cd0e23acecf129484c9ba06c7130033752d63a78c5/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8945557ed3015173dbf8d44184da9621add9162720acbc0ac4832b5a091b4e8a", size = 1292388, upload-time = "2026-09-09T16:05:02.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/56/3f21c881900d8254a35195e1e962355b4d570ccd9cc193b0ba08abb81952/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9cbed53bb8992b72e587dd47d0ca71703f5f715420f48ed57587c06c2fcd213", size = 1294413, upload-time = "2026-09-09T16:05:04.212Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b2/8d77be3dad1139158c59e370391f4990ea73f7f102983392272856bd3a63/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65c836d1ab7af65e64a1dd73de82572566e838dd27529b1124eac0ac86f35e76", size = 1565921, upload-time = "2026-09-09T16:05:05.753Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/0675b5796c897f957fbdac6af0ebeeaa9a63cee866c1c54f1e5136ef5bf6/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ab6df9898600ff45149b86c12bb5aa3359cc71843ad99ad0c924d11392469ab", size = 1312160, upload-time = "2026-09-09T16:05:07.269Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/1749fa4efae0dc98aad3c3d29f0178e2609208c3ba34700b4551082e40d5/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3eebd3dd25a81839ac7d25b5f3fb3527f6141b35eca29f63d2613fe6254307fd", size = 1312405, upload-time = "2026-09-09T16:05:08.753Z" }, + { url = "https://files.pythonhosted.org/packages/00/14/2afc9f9d7db551f805b4d5e496946dc3dc2a703d27294ccc200b6ff07a7a/ast_serialize-0.11.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:311333c5f58f55fc04121be2c5adffa42a19e1038a43a2cef862fdf58c45bf4f", size = 1319458, upload-time = "2026-09-09T16:05:10.552Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4a/8b2866d9d6d5d0b037d7bf2b74db6e75a66695c503248632116f24009a8d/ast_serialize-0.11.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:aa8d0a9f6d02e7626cdf540555bc2f5313eabe803dcca04854fc628ddf4b1058", size = 1365055, upload-time = "2026-09-09T16:05:12.069Z" }, + { url = "https://files.pythonhosted.org/packages/47/17/73119504574f9b46610ffb718501b254c8adb727d4e43c5babc3c62d4529/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e209a797fdf8680d58d2e969a9ddb25058ea682f6cff59dbabc98d8aef4e0db1", size = 1467771, upload-time = "2026-09-09T16:05:13.458Z" }, + { url = "https://files.pythonhosted.org/packages/56/f5/776bfb7a856ba0bb9bd373b76a90230fb77b1a10c987810b621ed244d5ba/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b391becd31e9889da438d388aac0362e3dfd222affbe45d920580c351486c787", size = 1571459, upload-time = "2026-09-09T16:05:15.15Z" }, + { url = "https://files.pythonhosted.org/packages/c7/43/86a655170ee36a8fdce65a922b7e34040bb319eb4ec251161032343c1fc3/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:021afb3482d27d1dace9c8999724a13083e96c7ece785e53e418bf5df3b50e0a", size = 1568959, upload-time = "2026-09-09T16:05:16.677Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b1/015a3a156dc9bd46e9c4e4f24a939eb0b87179b1c53c9495ac6f088f13b4/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:c1dc60251d93beff32d4147ecd4ccc518b0f022a900093f6f3021c2012416f0f", size = 1698173, upload-time = "2026-09-09T16:05:18.248Z" }, + { url = "https://files.pythonhosted.org/packages/0e/ea/a988b70980aca3a8a3fb731901dc911fe73a456cebbc225b767bbe5490ff/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:b77d6a2267227d68d8221cfca177f8c6d9f68019f3250401d0355d2638db9fb9", size = 1493298, upload-time = "2026-09-09T16:05:19.806Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/e049b2701ddab9087d7f62448f4c2c1b1463e676baa471345cf3e4a31e1b/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e68ac7d7fd1a5d42a3a6d726d7cc8a7e0c9987934f11c6a5162aa2cf68e81e24", size = 1510213, upload-time = "2026-09-09T16:05:21.402Z" }, + { url = "https://files.pythonhosted.org/packages/46/a2/23d555eb842d1397dce54fac31851e6f151e03a0ae74db389d9b39718c33/ast_serialize-0.11.1-cp39-abi3-win32.whl", hash = "sha256:ed449d786b9032a7b85fcd6c2f7f54c4cd0e1e1ad254d396805ff922096bf08d", size = 1125239, upload-time = "2026-09-09T16:05:22.796Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4a/251f3fd1b8a5549edaedf8f3ba0b9fb5060e194d3c0ed208b593fccaeff1/ast_serialize-0.11.1-cp39-abi3-win_amd64.whl", hash = "sha256:6b43f5a9b9a8dd20ba3124914e63aa7d7427de761ac849081cda403c2112fda0", size = 1164260, upload-time = "2026-09-09T16:05:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b5/08e2f643feb9e4d72d42a484949ad51238b18262a01c7036196c201cc330/ast_serialize-0.11.1-cp39-abi3-win_arm64.whl", hash = "sha256:a579ea6f473aab3958a64734194b22fbaec108ec45d994c28d3bd721e1e1da32", size = 1137533, upload-time = "2026-09-09T16:05:26.095Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, +] + +[[package]] +name = "macholib" +version = "1.16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" }, + { url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" }, + { url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" }, + { url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" }, + { url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" }, + { url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, + { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, + { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" }, + { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" }, + { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" }, + { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pefile" +version = "2024.8.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "portabase-cli" +version = "26.8.12" +source = { virtual = "." } +dependencies = [ + { name = "cryptography" }, + { name = "jinja2" }, + { name = "pyyaml" }, + { name = "questionary" }, + { name = "requests" }, + { name = "rich" }, + { name = "typer" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pyinstaller" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "cryptography", specifier = ">=44.0.0" }, + { name = "jinja2", specifier = ">=3.1" }, + { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "questionary", specifier = ">=2.1.0" }, + { name = "requests", specifier = ">=2.32.5" }, + { name = "rich", specifier = ">=14.2.0" }, + { name = "typer", specifier = ">=0.20.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.15" }, + { name = "pyinstaller", specifier = ">=6.17.0" }, + { name = "pytest", specifier = ">=8.3" }, + { name = "ruff", specifier = ">=0.16.0" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyinstaller" +version = "6.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "packaging" }, + { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "pyinstaller-hooks-contrib" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/60/d03d52e6690d4e9caf333dcd14550cde634ce6c118b3bc8fa3112c3186fd/pyinstaller-6.20.0.tar.gz", hash = "sha256:95c5c7e03d5d61e9dfb8ef259c699cf492bb1041beb6dbe83696608cec07347a", size = 4048728, upload-time = "2026-04-22T20:59:36.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/e4/e228d6d1bbb7fd62dc660a8fb202a583b023d3a3624ca95d1a9290ee4d6a/pyinstaller-6.20.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:bf3be4e1284ee78ddccba5e29f99443a12a7b4673168288ffc4c9d38c6f7b90e", size = 1047642, upload-time = "2026-04-22T20:58:32.006Z" }, + { url = "https://files.pythonhosted.org/packages/ce/bd/afb631bcb3f9040efebd4f6d067f0828b51710818f69fb41a2d4b7787f52/pyinstaller-6.20.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:72ae9c1fdea134afa791f58bdc9a1934d5c7609753c111e0026bfc272b32b712", size = 742494, upload-time = "2026-04-22T20:58:36.285Z" }, + { url = "https://files.pythonhosted.org/packages/76/08/0729a5bac14754150e5d83b39d87d842eb42b0bffcaa03dbad6252e23a39/pyinstaller-6.20.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1031bcc307f3fbeffd4e162723e64d46dbf591c82dd0997413afb2a07328b941", size = 754191, upload-time = "2026-04-22T20:58:40.603Z" }, + { url = "https://files.pythonhosted.org/packages/e6/82/bc0ee4c7b97db1958eb651e0da9fb1e672e5ae53ca8867fd97701de52906/pyinstaller-6.20.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:8df3b3f347659fa2562d8d193a98ad4600133b8b8d07c268df89e4154376750e", size = 751902, upload-time = "2026-04-22T20:58:44.7Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/770002d6aaa54173881cb2c49bb195ba67b97bf39bac1cdf320f28401629/pyinstaller-6.20.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b0d3cc9dd8120d448459bd3880a12e2f9774c51443af49047801446377999a59", size = 748634, upload-time = "2026-04-22T20:58:48.579Z" }, + { url = "https://files.pythonhosted.org/packages/fe/db/68ba1fccb71278b2124fb90b37b7c8c0bc4c1173fba45b94466df3d9cb7f/pyinstaller-6.20.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:03696bb6350177c6bc23bcaf78e71a33c4a89b6754dd90d1be2f318e978c918b", size = 748490, upload-time = "2026-04-22T20:58:52.749Z" }, + { url = "https://files.pythonhosted.org/packages/03/0f/ac77ffa996a56be3d5c8f85734a007f8347240691657f9704e7de2527fa3/pyinstaller-6.20.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:6357f1699f6af84f37e7367f031d4f68abdba65543b83990c9e8f5a4cebed0b7", size = 747650, upload-time = "2026-04-22T20:58:57.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/56/1ee91c3a2bc10ca1f36da10a6fd55ff7efc4dec367171eb25992a827874f/pyinstaller-6.20.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0ab39c690abad26ba148e8f664f0478acc82a733997f4f22e757774832802da9", size = 747413, upload-time = "2026-04-22T20:59:01.174Z" }, + { url = "https://files.pythonhosted.org/packages/d7/55/ae264339996953c4cdf9d89d916a0a8fa26a83cf917a742fff8b9d5f3fe8/pyinstaller-6.20.0-py3-none-win32.whl", hash = "sha256:9a7637e8e44b4387b13667fdcaac86ab6b29c446c16d34d8401539b81838759c", size = 1331584, upload-time = "2026-04-22T20:59:07.201Z" }, + { url = "https://files.pythonhosted.org/packages/76/8c/300f57578882cce259bfb5ae56fda3b69caa3fe9df40a176c719920ea6e2/pyinstaller-6.20.0-py3-none-win_amd64.whl", hash = "sha256:d588844e890ee80c4365867f98146636e1849bbca8e4284bbf0c809aff0f161a", size = 1391851, upload-time = "2026-04-22T20:59:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ea/b2f8e1642aecda78c0b75c7321f708e49e10bb3c00dd4f148c40761a1527/pyinstaller-6.20.0-py3-none-win_arm64.whl", hash = "sha256:bd53282c0a73e5c95573e1ddc8e5d564d4932bec91efbaed4dc5fdff9c2ae7f2", size = 1332259, upload-time = "2026-04-22T20:59:20.509Z" }, +] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2026.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/67/f4452d68793fb15beba4f19ef39a38a8822f0da7452b503c400d5a21f5c1/pyinstaller_hooks_contrib-2026.5.tar.gz", hash = "sha256:f066dfca8f7c45ff6336c9cf9fe25b4e48bfeb322a1aa24faaedfb8a8d1b0b08", size = 173689, upload-time = "2026-05-04T22:36:55.124Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/5c/fd465d11da4d12b50d7eb5d2ee2ceb780d8d049dbb489f3828d131e387af/pyinstaller_hooks_contrib-2026.5-py3-none-any.whl", hash = "sha256:ea1535783fbdac4626351709e83f3ea80b681d3a4745763ebb407b5e27342eb9", size = 457314, upload-time = "2026-05-04T22:36:53.598Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, + { url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, + { url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, + { url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "typer" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, +] From 17b56fec7c7b95414bc654866c605413e0772e45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Fri, 11 Sep 2026 14:26:36 +0200 Subject: [PATCH 100/124] feat(agent): show the proposed configuration and confirm before writing The dashboard kept its summary and confirmation through the refactor; the agent lost them when database setup moved to a loop that writes as it goes. Restored for the agent itself, before its files are written, with --yes to skip the prompt like the dashboard. The Edge Key is masked in the summary. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CbemoDqsmMdRq6PS5Vaup8 --- commands/agent.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/commands/agent.py b/commands/agent.py index d06162e..845a333 100644 --- a/commands/agent.py +++ b/commands/agent.py @@ -73,6 +73,10 @@ def run( 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, ) -> None: self.ui.banner() self.require_docker(self.docker) @@ -107,6 +111,23 @@ def run( name="host_gateway", ) + self.ui.summary( + [ + ("Agent Name", name), + ("Path", str(path)), + ("Edge Key", env_vars["EDGE_KEY"]), + ("Timezone", env_vars["TZ"]), + ("Polling", f"{env_vars['POLLING']}s"), + ("Host Gateway", "Yes" if gateway else "No"), + ("Files to Create", "docker-compose.yml, .env, databases.json"), + ], + title="PROPOSED CONFIGURATION", + ) + if not yes: + self.confirm_or_abort( + "Apply this configuration and generate files?", default=True + ) + project = AgentProject.create(path, env_vars, host_gateway=gateway) self._write(project) self.ui.success(f"Agent '{name}' created in {path}") From d823cde7f24967228db779e2e9b44e2022626919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Fri, 11 Sep 2026 14:44:05 +0200 Subject: [PATCH 101/124] docs: dashboard settings and auth providers design Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CbemoDqsmMdRq6PS5Vaup8 --- .../2026-09-11-dashboard-settings-design.md | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-11-dashboard-settings-design.md diff --git a/docs/superpowers/specs/2026-09-11-dashboard-settings-design.md b/docs/superpowers/specs/2026-09-11-dashboard-settings-design.md new file mode 100644 index 0000000..559f793 --- /dev/null +++ b/docs/superpowers/specs/2026-09-11-dashboard-settings-design.md @@ -0,0 +1,198 @@ +# Dashboard settings and authentication — Design + +Date : 2026-09-11 +Dépend de : `2026-09-11-cli-refactor-design.md` (état déclaratif, `EnvFile`, `Field`, `Form`, `CommandGroup`). + +## 1. Objectif + +Configurer depuis le CLI ce que le dashboard lit dans son environnement : API et MCP, onboarding, authentification par mot de passe, providers OIDC et OAuth2. À la création et après coup, en interactif et par flags. + +Au passage, une rupture de surface décidée pour être cohérente : un namespace par composant. + +## 2. Surface CLI + +### 2.1 Avant / après + +| Avant | Après | +|---|---| +| `agent NAME` | `agent create NAME` | +| `db add\|remove\|list NAME` | `agent db add\|remove\|list NAME` | +| `dashboard NAME` | `dashboard create NAME` | +| — | `dashboard show NAME` | +| — | `dashboard set NAME KEY VALUE [KEY VALUE…]` | +| — | `dashboard auth add\|remove\|list NAME` | +| `start\|stop\|restart\|logs\|uninstall\|build PATH` | inchangés | +| `config`, `update`, `decrypt` | inchangés | + +Un groupe Typer ne peut pas porter à la fois un argument positionnel et des sous-commandes (vérifié : `dashboard auth` créerait un dashboard nommé `auth`). D'où `create`. + +### 2.2 Compatibilité + +- `db` reste à la racine **une version**, alias de `agent db`, avec un avertissement à chaque appel : `'portabase db' is deprecated, use 'portabase agent db'`. Retiré à la version suivante. +- `portabase agent my-agent` et `portabase dashboard my-dash` produisent une `UsageError` « No such command ». Le catcher de `main.py` la reconnaît (token précédent = `agent` ou `dashboard`) et ajoute le hint `Did you mean: portabase agent create my-agent?`. +- README, `CONTRIBUTING.md`, doc portabase.io et script d'installation à mettre à jour dans la même release. + +### 2.3 Implémentation + +`CommandGroup` gagne `groups: list[CommandGroup]` pour imbriquer (`agent` contient `db`). `AgentCommand` devient `AgentCreateCommand` dans un `AgentCommands(CommandGroup)` ; idem `DashboardCreateCommand` dans `DashboardCommands`. + +## 3. Registre de settings + +Le cœur : une liste déclarative, un endroit à toucher pour ajouter un réglage. + +```python +@dataclass(frozen=True) +class Setting: + field: Field # name, prompt, kind, default, choices, help, validator + env: str # variable écrite dans .env + section: str # "network" | "api" | "onboarding" | "auth" + secret: bool = False # jamais en flag visible sans avertissement, masqué à l'affichage +``` + +`services/dashboard_settings.py` : + +| Section | `field.name` | `env` | kind | défaut dashboard | +|---|---|---|---|---| +| network | `url` | `PROJECT_URL` | text | `http://localhost:` | +| network | `behind_proxy` | `TUSD_BEHIND_PROXY` | bool | false | +| network | `trusted_domains` | `TRUSTED_DOMAINS` | text | — | +| api | `api` | `API_ENABLED` | bool | false | +| api | `openapi` | `OPENAPI_ENABLED` | bool | false | +| api | `mcp` | `MCP_ENABLED` | bool | false | +| onboarding | `skip_onboarding` | `SKIP_ONBOARDING` | bool | false | +| onboarding | `admin_name` | `AUTH_DEFAULT_USER_NAME` | text | — | +| onboarding | `admin_email` | `AUTH_DEFAULT_USER` | text | — | +| onboarding | `admin_password` | `AUTH_DEFAULT_PASSWORD` | secret | — | +| auth | `password_auth` | `AUTH_EMAIL_PASSWORD_ENABLED` | bool | true | +| auth | `signup` | `AUTH_SIGNUP_ENABLED` | bool | — | +| auth | `passkey` | `AUTH_PASSKEY_ENABLED` | bool | — | +| auth | `account_linking` | `AUTH_ALLOW_LINKING` | bool | — | +| auth | `account_unlinking` | `AUTH_ALLOW_UNLINKING` | bool | — | +| auth | `sync_oidc_roles` | `AUTH_SYNC_OIDC_ROLES_ON_LOGIN` | bool | — | +| auth | `role_map` | `AUTH_ROLE_MAP` | text | — | +| auth | `allowed_group` | `ALLOWED_GROUP` | text | — | + +Ce que le registre produit, sans code par réglage : + +- les flags de `dashboard create` : `--api/--no-api` pour un bool, `--url` pour un texte, `--admin-password-stdin` pour un secret ; +- les prompts interactifs, groupés par section ; +- la validation de `dashboard set` : `KEY` doit être un `field.name` du registre, `VALUE` est coercé par `Form` (bool `true/false/yes/no/1/0`, choix, validateur) ; +- l'affichage de `dashboard show`, section par section, secrets masqués. + +Écriture dans `.env` : booléens en `true`/`false`. À la création, seuls les réglages fournis ou différents du défaut sont écrits (le `.env` reste lisible). `set` écrit toujours la valeur demandée. + +`admin_password` porte le validateur documenté : 8 caractères, majuscule, minuscule, chiffre, spécial. + +## 4. Providers d'authentification + +Répétables, donc en sous-commande, comme `agent db`. + +### 4.1 Modèle + +```python +@dataclass(frozen=True) +class AuthProvider: + kind: Literal["oidc", "oauth"] + id: str # providerId : "keycloak", "github" + values: dict[str, str] # champs → valeurs, sans le préfixe +``` + +Stockage dans `.env` par préfixe, ce qui est exactement le mécanisme des bases managées : + +| kind | préfixe | champs | +|---|---|---| +| oidc | `AUTH_OIDC__` | `ID`, `TITLE`, `DESC`, `ICON`, `ISSUER_URL`, `CLIENT`, `SECRET`, `SCOPES`, `PKCE`, `HOST` | +| oauth | `AUTH_SOCIAL__` | `CLIENT`, `SECRET`, `TITLE` | + +`` est l'identifiant en majuscules avec `-` → `_` ; `AUTH_OIDC__ID` reçoit l'identifiant tel que saisi (c'est le `providerId` du callback). Les providers OAuth sont limités aux noms connus du dashboard : `google`, `github`, `discord`, `apple`, `linkedin`, `x`, `reddit`. + +Lecture : `DashboardProject.providers` scanne les clés du `.env` par préfixe et reconstruit la liste. Aucun autre état. + +### 4.2 Commandes + +``` +dashboard auth add NAME oidc ID --issuer URL --client CLIENT (--secret S | --secret-stdin) + [--title T] [--scopes "openid profile email"] [--pkce] [--host H] +dashboard auth add NAME oauth PROVIDER --client CLIENT (--secret S | --secret-stdin) [--title T] +dashboard auth list NAME +dashboard auth remove NAME ID [--yes] +``` + +- `add` sur un `ID` existant → `ValidationError`, hint « remove it first ». +- `remove` fait `env.remove_prefix(...)` puis re-rend. +- `list` affiche kind, id, titre, issuer/provider, et le callback à déclarer chez le fournisseur : `/api/auth/sso/callback/`. +- `--secret` visible accepté avec avertissement, `--secret-stdin` recommandé — même règle que `agent db add`. + +En interactif, `add` sans flags pose les champs du kind via `Form`. + +## 5. Validations croisées + +Dans `DashboardProject.validate()`, appelé avant toute écriture. Ce sont des refus, pas des avertissements : chacune laisse une instance inaccessible. + +| Condition | Erreur | +|---|---| +| `skip_onboarding` sans `admin_email` **et** `admin_password` | « Skipping onboarding needs an initial account: set admin_email and admin_password. » | +| `password_auth = false` et aucun provider | « Disabling password login with no OIDC or OAuth provider would lock everyone out. » | +| au moins un provider et `url` sur `localhost` | « Providers need a public URL for their callback; set url (currently http://localhost:8887). » | +| `auth remove` du dernier provider alors que `password_auth = false` | même refus que la ligne 2 | + +`admin_password` faible → refus par le validateur du champ. + +## 6. Interactif — `dashboard create` + +Le flux actuel reste : port, mode base, timezone, résumé, confirmation. Entre le résumé et la confirmation, une question : + +``` +Configure API, MCP and authentication now? [y/N] +``` + +Non par défaut. Si oui, trois sections courtes, chacune précédée de `ui.section(...)` : + +1. **API** — `api`, `openapi`, `mcp` +2. **Onboarding** — `skip_onboarding` ; si oui, `admin_name`, `admin_email`, `admin_password` (masqué) +3. **Authentication** — `password_auth`, `signup`, `passkey` + +Les providers ne sont pas dans le wizard : hint `Add a login provider with: portabase dashboard auth add NAME oidc …`, comme `agent create` renvoie vers `db add`. + +Le résumé inclut les réglages non-défaut. `--yes` saute la confirmation, `--non-interactive` prend les défauts et ne pose pas la question des sections. + +## 7. Application des changements + +Toute commande mutante (`create`, `set`, `auth add`, `auth remove`) termine par `project.save_state()` puis `renderer.render_dashboard(project).write(path)`. Le template a `env_file: .env`, donc le compose ne change pas — mais `write` est appelé quand même pour garder un seul chemin. + +Puis le message : `Apply with: portabase start NAME`. + +**`restart` est corrigé dans cette spec** : `docker compose restart` ne relit pas `env_file` ni ne crée un service ajouté (bug déjà constaté sur `agent db add`). `RestartCommand` fait `up -d` puis `restart`, pour converger vers l'état déclaré. Le message des commandes mutantes peut alors dire `portabase restart NAME` sans mentir. + +## 8. Ce qui ne change pas + +- `dashboard.yml.j2` : aucune modification, `env_file` suffit. +- `EnvFile`, `Form`, `Field`, `Summary`, `render_dashboard` : réutilisés tels quels. +- Le mode `custom`/`external`/`internal` de la base : inchangé. + +## 9. Hors périmètre + +- SMTP (`SMTP_*`), `RETENTION_CRON`, `STALE_BACKUP_THRESHOLD_HOURS`, `BACKUP_FOLDER_NAME`, `TELEMETRY` du dashboard. Le registre les accepte en une ligne chacun le jour venu. +- Provider OAuth2 générique (endpoints libres) : le dashboard ne le documente pas. +- Un `agent set` : l'agent n'a que `TZ`, `POLLING`, `LOG_LEVEL` ; à ajouter si le besoin apparaît, avec le même registre. +- Tests automatisés : spec séparée. Vérification ici par `render_check` et les parcours non-interactifs. + +## 10. Fichiers + +| Fichier | Action | +|---|---| +| `commands/base.py` | `CommandGroup.groups` pour l'imbrication | +| `commands/agent.py` | `AgentCommands` (groupe) + `AgentCreateCommand` ; `db` devient `agent db` | +| `commands/db.py` | inchangé, enregistré sous `agent` ; alias racine déprécié | +| `commands/dashboard.py` | `DashboardCommands` : `create`, `show`, `set` | +| `commands/dashboard_auth.py` | `add`, `list`, `remove` | +| `commands/lifecycle.py` | `RestartCommand` → `up -d` puis `restart` | +| `services/dashboard_settings.py` | `Setting`, `SETTINGS`, `OAUTH_PROVIDERS`, `OIDC_FIELDS` | +| `services/project.py` | `DashboardProject` : `settings`, `providers`, `validate()`, `set()`, `add_provider()`, `remove_provider()` | +| `main.py` | enregistrement des groupes, hint « did you mean » | +| `README.md`, `.github/CONTRIBUTING.md` | nouvelle surface | + +## 11. Questions ouvertes + +- Le `providerId` OIDC : imposer le même que `` en minuscules, ou le laisser libre via `--id` ? Défaut retenu : identique, pas de flag. +- `dashboard set` accepte plusieurs paires en une commande ; faut-il aussi `dashboard unset KEY` pour revenir au défaut du dashboard (retirer la variable) ? Défaut retenu : oui, trivial avec `env.remove`. From 1b31db7cf3925d656a91d6be7bf13e532191084e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Fri, 11 Sep 2026 14:45:28 +0200 Subject: [PATCH 102/124] style: name the pre-write panel SUMMARY Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CbemoDqsmMdRq6PS5Vaup8 --- commands/agent.py | 2 +- commands/dashboard.py | 2 +- docs/superpowers/plans/2026-09-11-plan-4-render-commands.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/commands/agent.py b/commands/agent.py index 845a333..456558f 100644 --- a/commands/agent.py +++ b/commands/agent.py @@ -121,7 +121,7 @@ def run( ("Host Gateway", "Yes" if gateway else "No"), ("Files to Create", "docker-compose.yml, .env, databases.json"), ], - title="PROPOSED CONFIGURATION", + title="SUMMARY", ) if not yes: self.confirm_or_abort( diff --git a/commands/dashboard.py b/commands/dashboard.py index 0a58dcc..3d048b3 100644 --- a/commands/dashboard.py +++ b/commands/dashboard.py @@ -146,7 +146,7 @@ def run( ] rows.append(("Files to Create", "docker-compose.yml, .env")) - self.ui.summary(rows, title="PROPOSED CONFIGURATION") + self.ui.summary(rows, title="SUMMARY") if not yes: self.confirm_or_abort( "Apply this configuration and generate files?", default=True 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 index e93ca1c..1856538 100644 --- a/docs/superpowers/plans/2026-09-11-plan-4-render-commands.md +++ b/docs/superpowers/plans/2026-09-11-plan-4-render-commands.md @@ -1349,7 +1349,7 @@ class DashboardCommand(Command): 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") + self.ui.summary(rows, title="SUMMARY") if not yes: self.confirm_or_abort("Apply this configuration and generate files?", default=True) From c59cb08fac3ada61438c17f8aafd0f19b95b63fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Fri, 11 Sep 2026 14:47:25 +0200 Subject: [PATCH 103/124] =?UTF-8?q?feat!:=20one=20namespace=20per=20compon?= =?UTF-8?q?ent=20=E2=80=94=20agent=20create,=20agent=20db?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'portabase agent NAME' becomes 'portabase agent create NAME' and 'db' moves under 'agent'. A Typer group cannot carry both a positional argument and subcommands, and the dashboard is about to grow 'auth' and 'set', so both components get the same shape now, before the refactor ships. 'portabase db' survives one release as a deprecated alias that warns, and the old 'agent NAME' form answers with the new command instead of a bare 'No such command'. restart now runs 'up -d' before 'restart': compose restart neither creates a service added since the last start nor rereads env_file, so the advice printed after 'db add' was wrong. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CbemoDqsmMdRq6PS5Vaup8 --- commands/agent.py | 38 +++++++++++++++++++++++++++++++++----- commands/base.py | 32 ++++++++++++++++++++++++++++++++ commands/db.py | 2 +- commands/lifecycle.py | 14 ++++++++++++-- main.py | 31 ++++++++++++++++++------------- services/project.py | 2 +- ui/components/hints.py | 2 +- 7 files changed, 98 insertions(+), 23 deletions(-) diff --git a/commands/agent.py b/commands/agent.py index 456558f..377b4bd 100644 --- a/commands/agent.py +++ b/commands/agent.py @@ -5,8 +5,8 @@ import typer -from commands.base import Command -from commands.db import report_write +from commands.base import Command, CommandGroup +from commands.db import DbCommands, report_write from commands.flows.add_database import AddDatabaseFlow from core.errors import ValidationError from core.utils import validate_edge_key @@ -31,8 +31,8 @@ def _edge_key(value: str) -> str: return value -class AgentCommand(Command): - name, help, panel = "agent", "Create a new Portabase Agent instance.", "Creation" +class AgentCreateCommand(Command): + name, help, panel = "create", "Create a new Portabase Agent instance.", "Creation" no_args_is_help = True def __init__( @@ -134,7 +134,7 @@ def run( if self.ui.non_interactive: self.ui.hint( - f"Add databases with: portabase db add {name} " + f"Add databases with: portabase agent db add {name} " "--engine postgresql --mode new" ) else: @@ -164,3 +164,31 @@ def _write(self, project: AgentProject) -> None: project.save_state() report = result.write(project.path) report_write(self.ui, report) + + +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.db = DbCommands(ui, telemetry, engines, ports, templates, renderer, docker) + + @property + def commands(self) -> list[Command]: + return [self._create] + + @property + def groups(self) -> list[CommandGroup]: + return [self.db] diff --git a/commands/base.py b/commands/base.py index 8aabba8..c849de3 100644 --- a/commands/base.py +++ b/commands/base.py @@ -95,11 +95,43 @@ def __init__(self, ui: UI, telemetry: Telemetry) -> None: @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) + + +class DeprecatedAlias(CommandGroup): + def __init__( + self, ui: UI, telemetry: Telemetry, target: CommandGroup, *, name: str, use: str + ) -> None: + super().__init__(ui, telemetry) + self.name = name + self.help = f"Deprecated alias of '{use}'." + self.panel = target.panel + self._target = target + self._use = use + + @property + def commands(self) -> list[Command]: + return self._target.commands + + def build_typer(self) -> typer.Typer: + sub = super().build_typer() + ui, use, name = self.ui, self._use, self.name + + @sub.callback() + def _warn() -> None: + ui.warning(f"'portabase {name}' is deprecated, use 'portabase {use}'.") + + return sub diff --git a/commands/db.py b/commands/db.py index 4c5118b..867aaf1 100644 --- a/commands/db.py +++ b/commands/db.py @@ -258,7 +258,7 @@ def run(self, name: NameArg) -> None: class DbCommands(CommandGroup): - name, help, panel = "db", "Manage an agent's databases.", "Configuration" + name, help, panel = "db", "Manage an agent's databases.", "Components" def __init__( self, diff --git a/commands/lifecycle.py b/commands/lifecycle.py index dc2adab..d0583ff 100644 --- a/commands/lifecycle.py +++ b/commands/lifecycle.py @@ -45,8 +45,18 @@ class StopCommand(_ComposeCommand): class RestartCommand(_ComposeCommand): - name, help = "restart", "Restart a Portabase component." - verb, compose_args, done = "Restarting", ["restart"], "Restarted" + 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): diff --git a/main.py b/main.py index fe5da03..22ecffd 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,6 @@ import os import platform +import re import sys from dataclasses import dataclass from typing import Annotated @@ -7,11 +8,11 @@ import click import typer -from commands.agent import AgentCommand +from commands.agent import AgentCommands +from commands.base import DeprecatedAlias from commands.build import BuildCommand from commands.config import ConfigCommands from commands.dashboard import DashboardCommand -from commands.db import DbCommands from commands.decrypt import DecryptCommand from commands.lifecycle import ( LogsCommand, @@ -120,11 +121,10 @@ def root( ui.out(ctx.get_help() + "\n") raise typer.Exit() + agent = AgentCommands( + ui, telemetry, docker, templates, renderer, engine_registry, ports + ) 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), @@ -134,16 +134,23 @@ def root( DecryptCommand(ui, telemetry), UpdateCommand(ui, telemetry, checker, updater), ] + agent.register(app) + DashboardCommand(ui, telemetry, docker, templates, renderer, ports).register(app) for cmd in commands: cmd.register(app) - - DbCommands( - ui, telemetry, engine_registry, ports, templates, renderer, docker - ).register(app) + DeprecatedAlias(ui, telemetry, agent.db, name="db", use="agent db").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 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: @@ -188,9 +195,7 @@ def main() -> None: except click.exceptions.Exit as e: exit_code = e.exit_code except click.UsageError as e: - err = ValidationError( - e.format_message(), hint="Run 'portabase --help' for usage." - ) + err = ValidationError(e.format_message(), hint=_usage_hint(e)) ui.error(err) telemetry.error(err) exit_code = err.exit_code diff --git a/services/project.py b/services/project.py index 9436beb..0dd63dd 100644 --- a/services/project.py +++ b/services/project.py @@ -148,7 +148,7 @@ def find(self, id_or_name: str) -> DatabaseSpec: ] if not matches: raise ValidationError( - f"No database matching '{id_or_name}'.", hint="See: portabase db list" + f"No database matching '{id_or_name}'.", hint="See: portabase agent db list" ) if len(matches) > 1: raise ValidationError( diff --git a/ui/components/hints.py b/ui/components/hints.py index 2b6e834..0bb059b 100644 --- a/ui/components/hints.py +++ b/ui/components/hints.py @@ -7,7 +7,7 @@ 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 db list '.", + "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 '.", From 6627fc8051014a485b044b20acdaa0da8c7671fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Fri, 11 Sep 2026 14:49:02 +0200 Subject: [PATCH 104/124] feat(dashboard): settings registry, auth providers and lock-out checks A declarative registry maps 18 dashboard settings to their .env variable, type, default and prompt; flags, prompts, validation and display derive from it. OIDC and OAuth providers are stored by prefix in .env and read back by scanning it, the same way managed databases are. DashboardProject refuses three states that would leave an instance unreachable: skipping onboarding without an initial account, disabling password login with no provider, and a provider whose callback would point at localhost. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CbemoDqsmMdRq6PS5Vaup8 --- services/dashboard_settings.py | 260 +++++++++++++++++++++++++++++++++ services/project.py | 107 +++++++++++++- 2 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 services/dashboard_settings.py diff --git a/services/dashboard_settings.py b/services/dashboard_settings.py new file mode 100644 index 0000000..4065c81 --- /dev/null +++ b/services/dashboard_settings.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any, Literal + +from core.errors import ValidationError +from core.fields import Field + +Section = Literal["network", "api", "onboarding", "auth"] +SECTION_TITLES: dict[Section, str] = { + "network": "Network", + "api": "API & MCP", + "onboarding": "Onboarding", + "auth": "Authentication", +} + + +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("/") + + +@dataclass(frozen=True) +class Setting: + field: Field + env: str + section: Section + secret: bool = False + + @property + def name(self) -> str: + return self.field.name + + +SETTINGS: tuple[Setting, ...] = ( + 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", + ), +) + +BY_NAME: dict[str, Setting] = {s.name: s for s in SETTINGS} + +WIZARD_SECTIONS: tuple[tuple[Section, tuple[str, ...]], ...] = ( + ("api", ("api", "openapi", "mcp")), + ("onboarding", ("skip_onboarding", "admin_name", "admin_email", "admin_password")), + ("auth", ("password_auth", "signup", "passkey")), +) + + +def get(name: str) -> Setting: + try: + return BY_NAME[name] + except KeyError: + raise ValidationError( + f"Unknown setting '{name}'.", + hint="Known: " + ", ".join(BY_NAME), + ) from None + + +def to_env(setting: Setting, value: Any) -> str: + if setting.field.kind == "bool": + return "true" if value else "false" + return str(value) + + +def from_env(setting: Setting, raw: str | None) -> Any: + if raw is None: + return setting.field.default + if setting.field.kind == "bool": + return raw.strip().lower() in ("1", "true", "yes", "on") + return raw + + +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/project.py b/services/project.py index 0dd63dd..171f010 100644 --- a/services/project.py +++ b/services/project.py @@ -9,10 +9,12 @@ from core.specs import DatabaseSpec from engines.base import DbEngine from engines.sqlite import SqliteEngine +from services import dashboard_settings as ds from services.compose_facts import 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" @@ -148,7 +150,8 @@ def find(self, id_or_name: str) -> DatabaseSpec: ] if not matches: raise ValidationError( - f"No database matching '{id_or_name}'.", hint="See: portabase agent db list" + f"No database matching '{id_or_name}'.", + hint="See: portabase agent db list", ) if len(matches) > 1: raise ValidationError( @@ -194,5 +197,107 @@ def db_mode(self) -> Literal["external", "internal", "custom"]: def project_name(self) -> str: return self.env.get("PROJECT_NAME") or self.path.name + def setting(self, name: str) -> Any: + setting = ds.get(name) + return ds.from_env(setting, self.env.get(setting.env)) + + def settings(self) -> dict[str, Any]: + return {s.name: ds.from_env(s, self.env.get(s.env)) for s in ds.SETTINGS} + + def set(self, name: str, value: Any) -> None: + setting = ds.get(name) + self.env.set(setting.env, ds.to_env(setting, value)) + + def unset(self, name: str) -> None: + self.env.remove(ds.get(name).env) + + @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 = ds.OIDC_ENV if kind == "oidc" else ds.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(p.id == provider.id for p in self.providers): + raise ValidationError( + f"A provider named '{provider.id}' already exists.", + hint="Remove it first: portabase dashboard auth remove", + ) + prefix = ds.provider_prefix(provider.kind, provider.id) + env_map = ds.OIDC_ENV if provider.kind == "oidc" else ds.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((p for p in self.providers if p.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(ds.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] From 00d0e794225b3cc3b977e8a1737fe47b6ea25ac9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Fri, 11 Sep 2026 14:53:37 +0200 Subject: [PATCH 105/124] feat(dashboard): create/show/set/unset and auth add/list/remove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'dashboard' is now a group. create derives its settings flags from the registry — --api, --mcp, --skip-onboarding, --admin-password-stdin, --password-auth/--no-password-auth and the rest — so a new setting is one registry entry. In interactive mode the wizard offers the API, onboarding and authentication sections behind a single question, off by default. show prints settings by section with secrets masked, plus the providers and the callback URL each one needs registered. set validates keys and values through the same registry; unset returns to the dashboard default. auth add|list|remove manage OIDC and OAuth providers stored by prefix in .env. OAuth ids are limited to the names the dashboard recognizes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CbemoDqsmMdRq6PS5Vaup8 --- README.md | 15 +- commands/dashboard.py | 273 +++++++++++++++++++++++++++++++++++-- commands/dashboard_auth.py | 203 +++++++++++++++++++++++++++ main.py | 4 +- 4 files changed, 478 insertions(+), 17 deletions(-) create mode 100644 commands/dashboard_auth.py diff --git a/README.md b/README.md index d59ef84..94d171d 100644 --- a/README.md +++ b/README.md @@ -64,10 +64,23 @@ Distributed under the Apache License. See `LICENSE.txt` for more details. +## Commands + +``` +portabase agent create NAME create an agent folder +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` still works for one release as an alias of `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 db add`, `db remove` or +`.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. diff --git a/commands/dashboard.py b/commands/dashboard.py index 3d048b3..acd214b 100644 --- a/commands/dashboard.py +++ b/commands/dashboard.py @@ -1,23 +1,29 @@ from __future__ import annotations +import inspect import secrets import sys from pathlib import Path -from typing import Annotated +from typing import Annotated, Any from urllib.parse import quote import typer -from commands.base import Command +from commands.base import Command, CommandGroup +from commands.dashboard_auth import DashboardAuthCommands from commands.db import report_write +from core.errors import ValidationError from core.utils import generate_password, slugify_project_name +from services import dashboard_settings as ds 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 = { @@ -25,12 +31,69 @@ "internal": "Embedded Database (In-container)", "custom": "Custom/Existing Database", } +PathArg = Annotated[Path, typer.Argument(help="Dashboard folder")] -class DashboardCommand(Command): - name = "dashboard" - help = "Create a new Portabase Dashboard instance." - panel = "Creation" +def _flag(name: str) -> str: + return "--" + name.replace("_", "-") + + +def settings_parameters() -> list[inspect.Parameter]: + params: list[inspect.Parameter] = [] + for setting in ds.SETTINGS: + field = setting.field + flag = _flag(setting.name) + if field.kind == "bool": + ann: Any = Annotated[ + bool | None, typer.Option(f"{flag}/--no-{flag[2:]}", 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 read_secret_flags(values: dict[str, Any]) -> dict[str, Any]: + out = dict(values) + for setting in ds.SETTINGS: + if setting.secret and out.pop(f"{setting.name}_stdin", False): + out[setting.name] = sys.stdin.readline().rstrip("\n") + return out + + +def display(name: str, value: Any) -> str: + if ds.get(name).secret: + return "••••••••" + if isinstance(value, bool): + return "Yes" if value else "No" + return str(value) + + +class _DashboardCommand(Command): + panel = "Components" no_args_is_help = True def __init__( @@ -48,6 +111,41 @@ def __init__( 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) + + def apply_settings(self, project: DashboardProject, values: dict[str, Any]) -> None: + form = self.ui.form() + for name, raw in values.items(): + if raw is not None: + project.set(name, form.ask(ds.get(name).field, raw)) + + +class DashboardCreateCommand(_DashboardCommand): + name, help = "create", "Create a new Portabase Dashboard instance." + + def register(self, app: typer.Typer) -> None: + def entry(*args: Any, **kwargs: Any) -> None: + self.run(*args, **kwargs) + + static = [ + p + for p in inspect.signature(self.run, eval_str=True).parameters.values() + if p.kind is not inspect.Parameter.VAR_KEYWORD + ] + signature = inspect.Signature(static + settings_parameters()) + entry.__signature__ = signature # type: ignore[attr-defined] + entry.__annotations__ = { + name: param.annotation for name, param in signature.parameters.items() + } + app.command( + self.name, help=self.help, rich_help_panel=self.panel, no_args_is_help=True + )(self._traced(entry)) + def run( self, name: Annotated[str, typer.Argument(help="Dashboard name (creates a folder)")], @@ -84,6 +182,7 @@ def run( bool, typer.Option("--yes", "-y", help="Skip the configuration confirmation"), ] = False, + **settings: Any, ) -> None: self.ui.banner() self.require_docker(self.docker) @@ -116,7 +215,6 @@ def run( rows = [ ("Dashboard Name", name), ("Path", str(path)), - ("Access URL", env_vars["PROJECT_URL"]), ("Database Setup", MODE_LABELS[mode]), ] @@ -145,6 +243,28 @@ def run( ("Connection URL", env_vars["DATABASE_URL"]), ] + env = EnvFile(path / ".env") + env.merge(env_vars) + project = DashboardProject(path, env) + + provided = read_secret_flags(settings) + self.apply_settings(project, provided) + explicit = any(v is not None for v 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 += [ + (ds.get(k).field.prompt, display(k, v)) + for k, v in project.settings().items() + if k != "url" and project.env.get(ds.get(k).env) is not None + ] rows.append(("Files to Create", "docker-compose.yml, .env")) self.ui.summary(rows, title="SUMMARY") if not yes: @@ -152,13 +272,12 @@ def run( "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) + path.mkdir(parents=True, exist_ok=True) + self.write(project) self.ui.success(f"Dashboard '{name}' created in {path}") + self.ui.hint( + f"Add a login provider with: portabase dashboard auth add {name} oidc ..." + ) if start or ( not self.ui.non_interactive @@ -166,10 +285,24 @@ def run( ): with self.ui.status("Starting..."): self.docker.compose(path, ["up", "-d"]) - self.ui.success(f"Live at: {env_vars['PROJECT_URL']}") + self.ui.success(f"Live at: {project.setting('url')}") else: self.ui.info(f"Run: portabase start {name}") + def _wizard(self, form: Form, project: DashboardProject) -> None: + for section, names in ds.WIZARD_SECTIONS: + self.ui.section(ds.SECTION_TITLES[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 = ds.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 @@ -186,3 +319,115 @@ def _pg_env( "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)) + values = project.settings() + for section, title in ds.SECTION_TITLES.items(): + rows = [ + (s.field.prompt, display(s.name, values[s.name])) + for s in ds.SETTINGS + if s.section == section and values[s.name] not in (None, "") + ] + if rows: + self.ui.summary(rows, title=title.upper()) + providers = project.providers + if providers: + self.ui.table( + ["Kind", "Id", "Title", "Issuer / provider", "Callback"], + [ + [ + p.kind, + p.id, + p.values.get("title", ""), + p.values.get("issuer", p.id), + project.callback_url(p.id), + ] + for p in providers + ], + title="LOGIN PROVIDERS", + ) + else: + state = "enabled." if values["password_auth"] else "disabled!" + self.ui.hint(f"No login provider. Password login is {state}") + + +class DashboardSetCommand(_DashboardCommand): + name, help = "set", "Change dashboard settings: KEY VALUE [KEY VALUE ...]." + + def run( + self, + path: PathArg, + pairs: Annotated[ + list[str], + typer.Argument(help="KEY VALUE pairs; keys as in 'dashboard show'"), + ], + ) -> None: + if len(pairs) % 2: + raise ValidationError( + "Expected KEY VALUE pairs.", hint="Known keys: " + ", ".join(ds.BY_NAME) + ) + project_path = self.require_project_dir(path) + self.templates.resolve() + project = DashboardProject.load(project_path) + self.apply_settings(project, dict(zip(pairs[::2], pairs[1::2], strict=True))) + self.write(project) + for key in pairs[::2]: + self.ui.success(f"{key} = {display(key, project.setting(key))}") + self.ui.info(f"Apply with: portabase restart {project_path.name}") + + +class DashboardUnsetCommand(_DashboardCommand): + name, help = "unset", "Reset dashboard settings to their default: KEY [KEY ...]." + + def run( + self, + path: PathArg, + keys: Annotated[list[str], typer.Argument(help="Setting keys")], + ) -> None: + project_path = self.require_project_dir(path) + self.templates.resolve() + project = DashboardProject.load(project_path) + for key in keys: + project.unset(key) + self.write(project) + self.ui.success("Reset: " + ", ".join(keys)) + self.ui.info(f"Apply with: portabase restart {project_path.name}") + + +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]: + return [ + DashboardCreateCommand(*self._deps), + DashboardShowCommand(*self._deps), + DashboardSetCommand(*self._deps), + DashboardUnsetCommand(*self._deps), + ] + + @property + def groups(self) -> list[CommandGroup]: + return [self.auth] diff --git a/commands/dashboard_auth.py b/commands/dashboard_auth.py new file mode 100644 index 0000000..0c919d0 --- /dev/null +++ b/commands/dashboard_auth.py @@ -0,0 +1,203 @@ +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 dashboard_settings as ds +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, typer.Argument(help="oidc | oauth")], + provider_id: Annotated[ + str, + typer.Argument( + help="Provider id: any slug for oidc, a known name for oauth " + "(google, github, discord, apple, linkedin, x, reddit)" + ), + ], + 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: + if kind not in KINDS: + raise ValidationError(f"Unknown kind '{kind}'.", hint="Use oidc or oauth.") + pid = ds.validate_provider_id(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 = ds.OIDC_FIELDS if kind == "oidc" else ds.OAUTH_FIELDS + allowed = {f.name for f in fields} + stray = sorted( + k for k, v in values.items() if v is not None and k not in allowed + ) + if stray: + flags = ", ".join("--" + k for k in stray) + raise ValidationError(f"Not applicable to {kind}: {flags}.") + + project = self.load(path) + answers = self.ui.form().collect(list(fields), values) + provider = AuthProvider(kind=kind, id=pid, values=answers) + project.add_provider(provider) + self.write(project) + self.ui.success(f"Added {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"], + [ + [ + p.kind, + p.id, + p.values.get("title", ""), + p.values.get("issuer", p.id), + project.callback_url(p.id), + ] + for p 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, typer.Argument(help="Provider id")], + yes: Annotated[ + bool, typer.Option("--yes", "-y", help="Skip confirmation") + ] = False, + ) -> None: + project = self.load(path) + 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/main.py b/main.py index 22ecffd..67ba840 100644 --- a/main.py +++ b/main.py @@ -12,7 +12,7 @@ from commands.base import DeprecatedAlias from commands.build import BuildCommand from commands.config import ConfigCommands -from commands.dashboard import DashboardCommand +from commands.dashboard import DashboardCommands from commands.decrypt import DecryptCommand from commands.lifecycle import ( LogsCommand, @@ -135,7 +135,7 @@ def root( UpdateCommand(ui, telemetry, checker, updater), ] agent.register(app) - DashboardCommand(ui, telemetry, docker, templates, renderer, ports).register(app) + DashboardCommands(ui, telemetry, docker, templates, renderer, ports).register(app) for cmd in commands: cmd.register(app) DeprecatedAlias(ui, telemetry, agent.db, name="db", use="agent db").register(app) From 5e0188c16aa2447063c8eb494852483f6e9714a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Fri, 11 Sep 2026 15:02:20 +0200 Subject: [PATCH 106/124] feat(agent): show/set/unset, and every documented agent variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings registry now serves both components. The agent gains the five variables its documentation lists but the CLI never exposed — DATA_PATH, TMPDIR, RETRY_ATTEMPTS, RETRY_BACKOFF_MS, SSL_CERT_FILE — plus LOG_LEVEL as a choice; agent create derives its flags from the registry like dashboard create does. host_gateway is a compose fact rather than a variable, so the registry allows env-less settings that map to a project attribute. show, set and unset are one implementation shared by both components, parameterised by the project loader and renderer. The agent template renders whichever optional variables are set, so they reach the container without an env_file (which would also expose the managed database credentials). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CbemoDqsmMdRq6PS5Vaup8 --- README.md | 1 + commands/agent.py | 129 +++++++------ commands/dashboard.py | 175 ++++-------------- commands/dashboard_auth.py | 6 +- commands/settings.py | 199 ++++++++++++++++++++ services/auth_providers.py | 64 +++++++ services/dashboard_settings.py | 260 -------------------------- services/project.py | 57 ++++-- services/renderer.py | 3 + services/settings.py | 329 +++++++++++++++++++++++++++++++++ templates/agent.yml.j2 | 3 + 11 files changed, 750 insertions(+), 476 deletions(-) create mode 100644 commands/settings.py create mode 100644 services/auth_providers.py delete mode 100644 services/dashboard_settings.py create mode 100644 services/settings.py diff --git a/README.md b/README.md index 94d171d..7350344 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ Distributed under the Apache License. See `LICENSE.txt` for more details. ``` 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 diff --git a/commands/agent.py b/commands/agent.py index 377b4bd..397f65c 100644 --- a/commands/agent.py +++ b/commands/agent.py @@ -1,16 +1,24 @@ from __future__ import annotations from pathlib import Path -from typing import Annotated +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 core.errors import ValidationError -from core.utils import validate_edge_key +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 @@ -22,17 +30,8 @@ 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 AgentCreateCommand(Command): - name, help, panel = "create", "Create a new Portabase Agent instance.", "Creation" + name, help, panel = "create", "Create a new Portabase Agent instance.", "Components" no_args_is_help = True def __init__( @@ -52,21 +51,14 @@ def __init__( 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)")], - 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, @@ -77,6 +69,7 @@ def run( bool, typer.Option("--yes", "-y", help="Skip the configuration confirmation"), ] = False, + **settings: Any, ) -> None: self.ui.banner() self.require_docker(self.docker) @@ -88,47 +81,33 @@ def run( 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 = { + s.name: form.ask(s.field, provided.get(s.name)) for s in cfg.AGENT if s.core + } 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", + s.env: s.to_env(answers[s.name]) for s in cfg.AGENT if s.core and s.env } - gateway = form.confirm( - "Add extra_hosts mapping (localhost -> host-gateway)?", - value=host_gateway, - default=False, - name="host_gateway", - ) - - self.ui.summary( - [ - ("Agent Name", name), - ("Path", str(path)), - ("Edge Key", env_vars["EDGE_KEY"]), - ("Timezone", env_vars["TZ"]), - ("Polling", f"{env_vars['POLLING']}s"), - ("Host Gateway", "Yes" if gateway else "No"), - ("Files to Create", "docker-compose.yml, .env, databases.json"), - ], - title="SUMMARY", - ) + gateway = bool(answers["host_gateway"]) + + rows = [("Agent Name", name), ("Path", str(path))] + rows += [ + (s.field.prompt, display(s, answers[s.name])) for s in cfg.AGENT if s.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, + {k: v for k, v in provided.items() if not cfg.AGENT.get(k).core}, + ) self._write(project) self.ui.success(f"Agent '{name}' created in {path}") @@ -166,6 +145,27 @@ def _write(self, project: AgentProject) -> None: 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 = [ + [d.name, d.engine, self.engines.get(d.engine).describe(d)] + for d in project.databases + ] + self.ui.table(["Name", "Engine", "Where"], rows, title="DATABASES") + else: + self.ui.hint("No database yet: portabase agent db add") + + class AgentCommands(CommandGroup): name, help, panel = "agent", "Create and manage Portabase agents.", "Components" @@ -183,11 +183,24 @@ def __init__( 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]: - return [self._create] + 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]: diff --git a/commands/dashboard.py b/commands/dashboard.py index acd214b..1c198eb 100644 --- a/commands/dashboard.py +++ b/commands/dashboard.py @@ -1,6 +1,5 @@ from __future__ import annotations -import inspect import secrets import sys from pathlib import Path @@ -12,9 +11,17 @@ from commands.base import Command, CommandGroup from commands.dashboard_auth import DashboardAuthCommands from commands.db import report_write -from core.errors import ValidationError +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 dashboard_settings as ds +from services import settings as cfg from services.docker import DockerRunner from services.envfile import EnvFile from services.ports import PortAllocator @@ -34,64 +41,6 @@ PathArg = Annotated[Path, typer.Argument(help="Dashboard folder")] -def _flag(name: str) -> str: - return "--" + name.replace("_", "-") - - -def settings_parameters() -> list[inspect.Parameter]: - params: list[inspect.Parameter] = [] - for setting in ds.SETTINGS: - field = setting.field - flag = _flag(setting.name) - if field.kind == "bool": - ann: Any = Annotated[ - bool | None, typer.Option(f"{flag}/--no-{flag[2:]}", 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 read_secret_flags(values: dict[str, Any]) -> dict[str, Any]: - out = dict(values) - for setting in ds.SETTINGS: - if setting.secret and out.pop(f"{setting.name}_stdin", False): - out[setting.name] = sys.stdin.readline().rstrip("\n") - return out - - -def display(name: str, value: Any) -> str: - if ds.get(name).secret: - return "••••••••" - if isinstance(value, bool): - return "Yes" if value else "No" - return str(value) - - class _DashboardCommand(Command): panel = "Components" no_args_is_help = True @@ -118,33 +67,14 @@ def write(self, project: DashboardProject) -> None: report = result.write(project.path) report_write(self.ui, report) - def apply_settings(self, project: DashboardProject, values: dict[str, Any]) -> None: - form = self.ui.form() - for name, raw in values.items(): - if raw is not None: - project.set(name, form.ask(ds.get(name).field, raw)) - class DashboardCreateCommand(_DashboardCommand): name, help = "create", "Create a new Portabase Dashboard instance." def register(self, app: typer.Typer) -> None: - def entry(*args: Any, **kwargs: Any) -> None: - self.run(*args, **kwargs) - - static = [ - p - for p in inspect.signature(self.run, eval_str=True).parameters.values() - if p.kind is not inspect.Parameter.VAR_KEYWORD - ] - signature = inspect.Signature(static + settings_parameters()) - entry.__signature__ = signature # type: ignore[attr-defined] - entry.__annotations__ = { - name: param.annotation for name, param in signature.parameters.items() - } app.command( self.name, help=self.help, rich_help_panel=self.panel, no_args_is_help=True - )(self._traced(entry)) + )(self._traced(with_settings_flags(self.run, cfg.DASHBOARD))) def run( self, @@ -247,8 +177,8 @@ def run( env.merge(env_vars) project = DashboardProject(path, env) - provided = read_secret_flags(settings) - self.apply_settings(project, provided) + provided = read_secret_flags(cfg.DASHBOARD, settings) + apply_settings(self.ui, project, provided) explicit = any(v is not None for v in provided.values()) if ( not self.ui.non_interactive @@ -261,9 +191,9 @@ def run( rows.append(("Access URL", project.setting("url"))) rows += [ - (ds.get(k).field.prompt, display(k, v)) - for k, v in project.settings().items() - if k != "url" and project.env.get(ds.get(k).env) is not None + (s.field.prompt, display(s, project.setting(s.name))) + for s in cfg.DASHBOARD + if s.name != "url" and project.env.get(s.env or "") is not None ] rows.append(("Files to Create", "docker-compose.yml, .env")) self.ui.summary(rows, title="SUMMARY") @@ -290,15 +220,15 @@ def run( self.ui.info(f"Run: portabase start {name}") def _wizard(self, form: Form, project: DashboardProject) -> None: - for section, names in ds.WIZARD_SECTIONS: - self.ui.section(ds.SECTION_TITLES[section]) + 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 = ds.get(setting_name) + setting = cfg.DASHBOARD.get(setting_name) value = form.ask(setting.field) if value != setting.field.default or needs_account: project.set(setting_name, value) @@ -326,15 +256,7 @@ class DashboardShowCommand(_DashboardCommand): def run(self, path: PathArg) -> None: project = DashboardProject.load(self.require_project_dir(path)) - values = project.settings() - for section, title in ds.SECTION_TITLES.items(): - rows = [ - (s.field.prompt, display(s.name, values[s.name])) - for s in ds.SETTINGS - if s.section == section and values[s.name] not in (None, "") - ] - if rows: - self.ui.summary(rows, title=title.upper()) + show_settings(self.ui, project) providers = project.providers if providers: self.ui.table( @@ -352,53 +274,10 @@ def run(self, path: PathArg) -> None: title="LOGIN PROVIDERS", ) else: - state = "enabled." if values["password_auth"] else "disabled!" + state = "enabled." if project.setting("password_auth") else "disabled!" self.ui.hint(f"No login provider. Password login is {state}") -class DashboardSetCommand(_DashboardCommand): - name, help = "set", "Change dashboard settings: KEY VALUE [KEY VALUE ...]." - - def run( - self, - path: PathArg, - pairs: Annotated[ - list[str], - typer.Argument(help="KEY VALUE pairs; keys as in 'dashboard show'"), - ], - ) -> None: - if len(pairs) % 2: - raise ValidationError( - "Expected KEY VALUE pairs.", hint="Known keys: " + ", ".join(ds.BY_NAME) - ) - project_path = self.require_project_dir(path) - self.templates.resolve() - project = DashboardProject.load(project_path) - self.apply_settings(project, dict(zip(pairs[::2], pairs[1::2], strict=True))) - self.write(project) - for key in pairs[::2]: - self.ui.success(f"{key} = {display(key, project.setting(key))}") - self.ui.info(f"Apply with: portabase restart {project_path.name}") - - -class DashboardUnsetCommand(_DashboardCommand): - name, help = "unset", "Reset dashboard settings to their default: KEY [KEY ...]." - - def run( - self, - path: PathArg, - keys: Annotated[list[str], typer.Argument(help="Setting keys")], - ) -> None: - project_path = self.require_project_dir(path) - self.templates.resolve() - project = DashboardProject.load(project_path) - for key in keys: - project.unset(key) - self.write(project) - self.ui.success("Reset: " + ", ".join(keys)) - self.ui.info(f"Apply with: portabase restart {project_path.name}") - - class DashboardCommands(CommandGroup): name, help, panel = ( "dashboard", @@ -421,11 +300,19 @@ def __init__( @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), - DashboardSetCommand(*self._deps), - DashboardUnsetCommand(*self._deps), + SetCommand(*shared), + UnsetCommand(*shared), ] @property diff --git a/commands/dashboard_auth.py b/commands/dashboard_auth.py index 0c919d0..461e296 100644 --- a/commands/dashboard_auth.py +++ b/commands/dashboard_auth.py @@ -9,7 +9,7 @@ from commands.base import Command, CommandGroup from commands.db import report_write from core.errors import ValidationError -from services import dashboard_settings as ds +from services import auth_providers as ap from services.docker import DockerRunner from services.ports import PortAllocator from services.project import AuthProvider, DashboardProject, ProviderKind @@ -96,7 +96,7 @@ def run( ) -> None: if kind not in KINDS: raise ValidationError(f"Unknown kind '{kind}'.", hint="Use oidc or oauth.") - pid = ds.validate_provider_id(kind, provider_id) + pid = ap.validate_provider_id(kind, provider_id) if secret_stdin: secret = sys.stdin.readline().rstrip("\n") elif secret is not None: @@ -113,7 +113,7 @@ def run( "pkce": pkce, "host": host, } - fields = ds.OIDC_FIELDS if kind == "oidc" else ds.OAUTH_FIELDS + fields = ap.OIDC_FIELDS if kind == "oidc" else ap.OAUTH_FIELDS allowed = {f.name for f in fields} stray = sorted( k for k, v in values.items() if v is not None and k not in allowed diff --git a/commands/settings.py b/commands/settings.py new file mode 100644 index 0000000..6638570 --- /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 = [ + p + for p in inspect.signature(run, eval_str=True).parameters.values() + if p.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 = [ + (s.field.prompt, display(s, values[s.name])) + for s in project.registry.in_section(section) + if values[s.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/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/dashboard_settings.py b/services/dashboard_settings.py deleted file mode 100644 index 4065c81..0000000 --- a/services/dashboard_settings.py +++ /dev/null @@ -1,260 +0,0 @@ -from __future__ import annotations - -import re -from dataclasses import dataclass -from typing import Any, Literal - -from core.errors import ValidationError -from core.fields import Field - -Section = Literal["network", "api", "onboarding", "auth"] -SECTION_TITLES: dict[Section, str] = { - "network": "Network", - "api": "API & MCP", - "onboarding": "Onboarding", - "auth": "Authentication", -} - - -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("/") - - -@dataclass(frozen=True) -class Setting: - field: Field - env: str - section: Section - secret: bool = False - - @property - def name(self) -> str: - return self.field.name - - -SETTINGS: tuple[Setting, ...] = ( - 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", - ), -) - -BY_NAME: dict[str, Setting] = {s.name: s for s in SETTINGS} - -WIZARD_SECTIONS: tuple[tuple[Section, tuple[str, ...]], ...] = ( - ("api", ("api", "openapi", "mcp")), - ("onboarding", ("skip_onboarding", "admin_name", "admin_email", "admin_password")), - ("auth", ("password_auth", "signup", "passkey")), -) - - -def get(name: str) -> Setting: - try: - return BY_NAME[name] - except KeyError: - raise ValidationError( - f"Unknown setting '{name}'.", - hint="Known: " + ", ".join(BY_NAME), - ) from None - - -def to_env(setting: Setting, value: Any) -> str: - if setting.field.kind == "bool": - return "true" if value else "false" - return str(value) - - -def from_env(setting: Setting, raw: str | None) -> Any: - if raw is None: - return setting.field.default - if setting.field.kind == "bool": - return raw.strip().lower() in ("1", "true", "yes", "on") - return raw - - -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/project.py b/services/project.py index 171f010..e01fc3b 100644 --- a/services/project.py +++ b/services/project.py @@ -9,7 +9,8 @@ from core.specs import DatabaseSpec from engines.base import DbEngine from engines.sqlite import SqliteEngine -from services import dashboard_settings as ds +from services import auth_providers as ap +from services import settings as cfg from services.compose_facts import ComposeFacts from services.envfile import EnvFile @@ -142,6 +143,38 @@ def remove(self, spec: DatabaseSpec, engine: DbEngine) -> None: 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 {s.name: self.setting(s.name) for s 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.") + self.env.remove(setting.env or "") + + @property + def extra_env(self) -> list[str]: + return [ + s.env + for s in self.registry + if s.env and not s.core and self.env.get(s.env) is not None + ] + def find(self, id_or_name: str) -> DatabaseSpec: matches = [ d @@ -197,19 +230,21 @@ def db_mode(self) -> Literal["external", "internal", "custom"]: 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 = ds.get(name) - return ds.from_env(setting, self.env.get(setting.env)) + setting = self.registry.get(name) + return setting.from_env(self.env.get(setting.env or "")) def settings(self) -> dict[str, Any]: - return {s.name: ds.from_env(s, self.env.get(s.env)) for s in ds.SETTINGS} + return {s.name: self.setting(s.name) for s in self.registry} def set(self, name: str, value: Any) -> None: - setting = ds.get(name) - self.env.set(setting.env, ds.to_env(setting, value)) + setting = self.registry.get(name) + self.env.set(setting.env or "", setting.to_env(value)) def unset(self, name: str) -> None: - self.env.remove(ds.get(name).env) + self.env.remove(self.registry.get(name).env or "") @property def providers(self) -> list[AuthProvider]: @@ -223,7 +258,7 @@ def providers(self) -> list[AuthProvider]: if not key.startswith(prefix): continue rest = key[len(prefix) :] - env_map = ds.OIDC_ENV if kind == "oidc" else ds.OAUTH_ENV + 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] @@ -244,8 +279,8 @@ def add_provider(self, provider: AuthProvider) -> None: f"A provider named '{provider.id}' already exists.", hint="Remove it first: portabase dashboard auth remove", ) - prefix = ds.provider_prefix(provider.kind, provider.id) - env_map = ds.OIDC_ENV if provider.kind == "oidc" else ds.OAUTH_ENV + 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(): @@ -261,7 +296,7 @@ def remove_provider(self, provider_id: str) -> AuthProvider: f"No provider named '{provider_id}'.", hint="See: portabase dashboard auth list", ) - self.env.remove_prefix(ds.provider_prefix(match.kind, match.id)) + self.env.remove_prefix(ap.provider_prefix(match.kind, match.id)) return match def callback_url(self, provider_id: str) -> str: diff --git a/services/renderer.py b/services/renderer.py index 57d748b..969128b 100644 --- a/services/renderer.py +++ b/services/renderer.py @@ -123,6 +123,9 @@ def render_agent( "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 + ], } compose = self.header() + self._render("agent.yml.j2", ctx) databases = [ diff --git a/services/settings.py b/services/settings.py new file mode 100644 index 0000000..375af03 --- /dev/null +++ b/services/settings.py @@ -0,0 +1,329 @@ +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 = {s.name: s for s 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 [s for s in self._settings if s.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( + "ssl_cert_file", + "CA bundle path for outgoing TLS", + "text", + help="Replaces the default root store; include the Mozilla roots too.", + ), + "SSL_CERT_FILE", + "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/templates/agent.yml.j2 b/templates/agent.yml.j2 index d865063..be89b10 100644 --- a/templates/agent.yml.j2 +++ b/templates/agent.yml.j2 @@ -19,6 +19,9 @@ services: EDGE_KEY: "{{ edge_key_var }}" LOG_LEVEL: "{{ log_level_var }}" POLLING: "{{ polling_var }}" +{%- for name, value in extra_env %} + {{ name }}: "{{ value }}" +{%- endfor %} networks: - portabase {% for s in services %} From f35b34bfae8c058d5863a398c854c2e3c30d33e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Fri, 11 Sep 2026 15:04:20 +0200 Subject: [PATCH 107/124] feat(agent): ca_bundle mounts an internal CA and points SSL_CERT_FILE at it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent uses rustls, which ignores the system CA store, so the only way to trust an internal CA is SSL_CERT_FILE — and the file it names must exist inside the container. Setting the variable alone would have produced an agent that fails at startup. 'agent set NAME ca_bundle ./ca-bundle.crt' now bind-mounts the host file read-only at a fixed path and sets SSL_CERT_FILE to that path. The host path lives in .env as CA_BUNDLE for compose interpolation only; it is not passed to the container. set refuses a path that does not exist, and the help says why the bundle must include the Mozilla roots. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CbemoDqsmMdRq6PS5Vaup8 --- services/project.py | 18 +++++++++++++++++- services/renderer.py | 2 ++ services/settings.py | 17 ++++++++++++----- templates/agent.yml.j2 | 6 ++++++ 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/services/project.py b/services/project.py index e01fc3b..3ea5559 100644 --- a/services/project.py +++ b/services/project.py @@ -124,6 +124,12 @@ def sqlite_mounts(self) -> list[tuple[str, str]]: 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 d in self.managed: if d.host in seen: @@ -172,9 +178,18 @@ def extra_env(self) -> list[str]: return [ s.env for s in self.registry - if s.env and not s.core and self.env.get(s.env) is not None + if s.env + and s.container_env + and not s.core + and self.env.get(s.env) is not None ] + CA_BUNDLE_IN_CONTAINER = "/etc/ssl/certs/portabase-ca-bundle.crt" + + @property + def ca_bundle(self) -> str | None: + return self.env.get("CA_BUNDLE") + def find(self, id_or_name: str) -> DatabaseSpec: matches = [ d @@ -193,6 +208,7 @@ def find(self, id_or_name: str) -> DatabaseSpec: return matches[0] def save_state(self) -> None: + self.validate() self.env.save() diff --git a/services/renderer.py b/services/renderer.py index 969128b..0267b02 100644 --- a/services/renderer.py +++ b/services/renderer.py @@ -126,6 +126,8 @@ def render_agent( "extra_env": [ (name, _var(env, name, inline)) for name in project.extra_env ], + "ca_bundle": _var(env, "CA_BUNDLE", inline) if project.ca_bundle else None, + "ca_bundle_in_container": project.CA_BUNDLE_IN_CONTAINER, } compose = self.header() + self._render("agent.yml.j2", ctx) databases = [ diff --git a/services/settings.py b/services/settings.py index 375af03..a26acd8 100644 --- a/services/settings.py +++ b/services/settings.py @@ -56,6 +56,7 @@ class Setting: section: str secret: bool = False core: bool = False + container_env: bool = True @property def name(self) -> str: @@ -178,13 +179,19 @@ def in_section(self, section: str) -> list[Setting]: ), Setting( Field( - "ssl_cert_file", - "CA bundle path for outgoing TLS", - "text", - help="Replaces the default root store; include the Mozilla roots too.", + "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" + ), ), - "SSL_CERT_FILE", + "CA_BUNDLE", "network", + container_env=False, ), ), { diff --git a/templates/agent.yml.j2 b/templates/agent.yml.j2 index be89b10..5bdce60 100644 --- a/templates/agent.yml.j2 +++ b/templates/agent.yml.j2 @@ -10,6 +10,9 @@ services: {%- 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" @@ -22,6 +25,9 @@ services: {%- for name, value in extra_env %} {{ name }}: "{{ value }}" {%- endfor %} +{%- if ca_bundle %} + SSL_CERT_FILE: "{{ ca_bundle_in_container }}" +{%- endif %} networks: - portabase {% for s in services %} From 86089dc2bf6f90ef2fcb03585a84766451752570 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Fri, 11 Sep 2026 15:06:13 +0200 Subject: [PATCH 108/124] refactor(agent): keep only SSL_CERT_FILE in .env; the bundle path lives in the mount The host path of the CA bundle is not a container variable, so it no longer goes in .env. It is a compose fact like host_gateway: written as the bind-mount source and read back from the existing compose on load. SSL_CERT_FILE stays in .env with the in-container path, which is what the agent consumes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CbemoDqsmMdRq6PS5Vaup8 --- services/compose_facts.py | 16 ++++++++++++++++ services/project.py | 25 +++++++++++++++++-------- services/renderer.py | 11 ++++++++--- services/settings.py | 4 +--- templates/agent.yml.j2 | 2 +- 5 files changed, 43 insertions(+), 15 deletions(-) diff --git a/services/compose_facts.py b/services/compose_facts.py index f54bd7f..6b3568a 100644 --- a/services/compose_facts.py +++ b/services/compose_facts.py @@ -5,6 +5,7 @@ import yaml GENERATED_MARKER = "# Generated by Portabase CLI" +CA_BUNDLE_IN_CONTAINER = "/etc/ssl/certs/portabase-ca-bundle.crt" class ComposeFacts: @@ -41,3 +42,18 @@ def host_gateway(self) -> bool: if isinstance(extra, dict): return any("host-gateway" in str(v) for v 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/project.py b/services/project.py index 3ea5559..77b430f 100644 --- a/services/project.py +++ b/services/project.py @@ -11,7 +11,7 @@ from engines.sqlite import SqliteEngine from services import auth_providers as ap from services import settings as cfg -from services.compose_facts import ComposeFacts +from services.compose_facts import CA_BUNDLE_IN_CONTAINER, ComposeFacts from services.envfile import EnvFile ProjectKind = Literal["agent", "dashboard"] @@ -93,6 +93,7 @@ def load(cls, path: Path) -> AgentProject: databases = [spec_from_entry(e, env) for e in entries if isinstance(e, dict)] facts = ComposeFacts(path / COMPOSE_FILE) project = cls(path, env, databases, facts.host_gateway) + project._ca_bundle = facts.ca_bundle project.validate() return project @@ -171,24 +172,32 @@ def unset(self, name: str) -> None: setting = self.registry.get(name) if setting.core: raise ValidationError(f"'{name}' is required and cannot be unset.") - self.env.remove(setting.env or "") + if setting.env is None: + setattr(self, name, None) + else: + self.env.remove(setting.env) @property def extra_env(self) -> list[str]: return [ s.env for s in self.registry - if s.env - and s.container_env - and not s.core - and self.env.get(s.env) is not None + if s.env and not s.core and self.env.get(s.env) is not None ] - CA_BUNDLE_IN_CONTAINER = "/etc/ssl/certs/portabase-ca-bundle.crt" + _ca_bundle: str | None = None @property def ca_bundle(self) -> str | None: - return self.env.get("CA_BUNDLE") + 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 = [ diff --git a/services/renderer.py b/services/renderer.py index 0267b02..ac0043a 100644 --- a/services/renderer.py +++ b/services/renderer.py @@ -15,7 +15,11 @@ from core.errors import TemplateError from core.specs import DatabaseSpec from engines import EngineRegistry -from services.compose_facts import GENERATED_MARKER, ComposeFacts +from services.compose_facts import ( + CA_BUNDLE_IN_CONTAINER, + GENERATED_MARKER, + ComposeFacts, +) from services.envfile import EnvFile from services.project import ( COMPOSE_FILE, @@ -126,8 +130,9 @@ def render_agent( "extra_env": [ (name, _var(env, name, inline)) for name in project.extra_env ], - "ca_bundle": _var(env, "CA_BUNDLE", inline) if project.ca_bundle else None, - "ca_bundle_in_container": project.CA_BUNDLE_IN_CONTAINER, + "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 = [ diff --git a/services/settings.py b/services/settings.py index a26acd8..d7a3002 100644 --- a/services/settings.py +++ b/services/settings.py @@ -56,7 +56,6 @@ class Setting: section: str secret: bool = False core: bool = False - container_env: bool = True @property def name(self) -> str: @@ -189,9 +188,8 @@ def in_section(self, section: str) -> list[Setting]: "> ca-bundle.crt" ), ), - "CA_BUNDLE", + None, "network", - container_env=False, ), ), { diff --git a/templates/agent.yml.j2 b/templates/agent.yml.j2 index 5bdce60..4732866 100644 --- a/templates/agent.yml.j2 +++ b/templates/agent.yml.j2 @@ -26,7 +26,7 @@ services: {{ name }}: "{{ value }}" {%- endfor %} {%- if ca_bundle %} - SSL_CERT_FILE: "{{ ca_bundle_in_container }}" + SSL_CERT_FILE: "{{ ssl_cert_file_var }}" {%- endif %} networks: - portabase From 4658f7441cd031a9a1a55e35c60fa0d2a1779414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Fri, 11 Sep 2026 15:10:43 +0200 Subject: [PATCH 109/124] feat(dashboard): end create with a hint on adding OIDC/OAuth providers Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CbemoDqsmMdRq6PS5Vaup8 --- commands/dashboard.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/commands/dashboard.py b/commands/dashboard.py index 1c198eb..2bcf720 100644 --- a/commands/dashboard.py +++ b/commands/dashboard.py @@ -205,9 +205,6 @@ def run( path.mkdir(parents=True, exist_ok=True) self.write(project) self.ui.success(f"Dashboard '{name}' created in {path}") - self.ui.hint( - f"Add a login provider with: portabase dashboard auth add {name} oidc ..." - ) if start or ( not self.ui.non_interactive @@ -219,6 +216,16 @@ def run( 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]) From ea37e4e45d9548ecb44118924cadf82f3566f55d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Fri, 11 Sep 2026 15:21:16 +0200 Subject: [PATCH 110/124] feat(dashboard): prompt for provider kind and id in auth add/remove Rename commands/dashboard_auth.py to commands/auth.py. Both arguments of 'dashboard auth add' become optional and are asked interactively when omitted (kind as a choice, OAuth provider from the known list, OIDC id as free text). 'dashboard auth remove' without an id offers the configured providers as a selection list. Non-interactive mode keeps failing with 'Missing --kind' / 'Missing --id'. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CbemoDqsmMdRq6PS5Vaup8 --- commands/{dashboard_auth.py => auth.py} | 47 ++++++++++++++----- commands/dashboard.py | 2 +- .../2026-09-11-dashboard-settings-design.md | 2 +- 3 files changed, 37 insertions(+), 14 deletions(-) rename commands/{dashboard_auth.py => auth.py} (78%) diff --git a/commands/dashboard_auth.py b/commands/auth.py similarity index 78% rename from commands/dashboard_auth.py rename to commands/auth.py index 461e296..f26e9c4 100644 --- a/commands/dashboard_auth.py +++ b/commands/auth.py @@ -59,14 +59,16 @@ class AuthAddCommand(_AuthCommand): def run( self, path: PathArg, - kind: Annotated[str, typer.Argument(help="oidc | oauth")], + kind: Annotated[ + str | None, typer.Argument(help="oidc | oauth (asked if omitted)") + ] = None, provider_id: Annotated[ - str, + 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, @@ -94,9 +96,18 @@ def run( str | None, typer.Option("--host", help="OIDC host override") ] = None, ) -> None: - if kind not in KINDS: - raise ValidationError(f"Unknown kind '{kind}'.", hint="Use oidc or oauth.") - pid = ap.validate_provider_id(kind, provider_id) + 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: @@ -113,21 +124,21 @@ def run( "pkce": pkce, "host": host, } - fields = ap.OIDC_FIELDS if kind == "oidc" else ap.OAUTH_FIELDS + fields = ap.OIDC_FIELDS if provider_kind == "oidc" else ap.OAUTH_FIELDS allowed = {f.name for f in fields} stray = sorted( k for k, v in values.items() if v is not None and k not in allowed ) if stray: flags = ", ".join("--" + k for k in stray) - raise ValidationError(f"Not applicable to {kind}: {flags}.") + raise ValidationError(f"Not applicable to {provider_kind}: {flags}.") project = self.load(path) - answers = self.ui.form().collect(list(fields), values) - provider = AuthProvider(kind=kind, id=pid, values=answers) + 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 {kind} provider '{pid}'.") + self.ui.success(f"Added {provider_kind} provider '{pid}'.") self.ui.info( f"Callback URL to register at the provider: {project.callback_url(pid)}" ) @@ -164,12 +175,24 @@ class AuthRemoveCommand(_AuthCommand): def run( self, path: PathArg, - provider_id: Annotated[str, typer.Argument(help="Provider id")], + 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"{p.id} ({p.kind})" for p 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 diff --git a/commands/dashboard.py b/commands/dashboard.py index 2bcf720..a38c5e2 100644 --- a/commands/dashboard.py +++ b/commands/dashboard.py @@ -8,8 +8,8 @@ import typer +from commands.auth import DashboardAuthCommands from commands.base import Command, CommandGroup -from commands.dashboard_auth import DashboardAuthCommands from commands.db import report_write from commands.settings import ( SetCommand, diff --git a/docs/superpowers/specs/2026-09-11-dashboard-settings-design.md b/docs/superpowers/specs/2026-09-11-dashboard-settings-design.md index 559f793..97096eb 100644 --- a/docs/superpowers/specs/2026-09-11-dashboard-settings-design.md +++ b/docs/superpowers/specs/2026-09-11-dashboard-settings-design.md @@ -185,7 +185,7 @@ Puis le message : `Apply with: portabase start NAME`. | `commands/agent.py` | `AgentCommands` (groupe) + `AgentCreateCommand` ; `db` devient `agent db` | | `commands/db.py` | inchangé, enregistré sous `agent` ; alias racine déprécié | | `commands/dashboard.py` | `DashboardCommands` : `create`, `show`, `set` | -| `commands/dashboard_auth.py` | `add`, `list`, `remove` | +| `commands/auth.py` | `add`, `list`, `remove` | | `commands/lifecycle.py` | `RestartCommand` → `up -d` puis `restart` | | `services/dashboard_settings.py` | `Setting`, `SETTINGS`, `OAUTH_PROVIDERS`, `OIDC_FIELDS` | | `services/project.py` | `DashboardProject` : `settings`, `providers`, `validate()`, `set()`, `add_provider()`, `remove_provider()` | From 932b94257ddb4a43b906480ec92a1f64fa493080 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Fri, 11 Sep 2026 15:21:37 +0200 Subject: [PATCH 111/124] chore: drop a leftover comment in main.py Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CbemoDqsmMdRq6PS5Vaup8 --- main.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/main.py b/main.py index 67ba840..1ed4388 100644 --- a/main.py +++ b/main.py @@ -50,8 +50,6 @@ def from_env(cls, argv: list[str]) -> "Settings": no_color=bool(os.environ.get("NO_COLOR")) or "--no-color" in argv, ) if settings.no_color: - # Typer renders --help with its own Rich console, which only honors - # the NO_COLOR convention; --help is handled before any callback runs. os.environ["NO_COLOR"] = "1" return settings From 8d1ef4e0b307bcf414ce5f94b1070b91e43214b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Fri, 11 Sep 2026 15:39:47 +0200 Subject: [PATCH 112/124] feat(dashboard): prompt for provider kind and id in auth add/remove Rename commands/dashboard_auth.py to commands/auth.py. Both arguments of 'dashboard auth add' become optional and are asked interactively when omitted (kind as a choice, OAuth provider from the known list, OIDC id as free text). 'dashboard auth remove' without an id offers the configured providers as a selection list. Non-interactive mode keeps failing with 'Missing --kind' / 'Missing --id'. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CbemoDqsmMdRq6PS5Vaup8 --- commands/{dashboard_auth.py => auth.py} | 47 ++++++++++++++----- commands/dashboard.py | 2 +- .../2026-09-11-dashboard-settings-design.md | 2 +- main.py | 2 - 4 files changed, 37 insertions(+), 16 deletions(-) rename commands/{dashboard_auth.py => auth.py} (78%) diff --git a/commands/dashboard_auth.py b/commands/auth.py similarity index 78% rename from commands/dashboard_auth.py rename to commands/auth.py index 461e296..f26e9c4 100644 --- a/commands/dashboard_auth.py +++ b/commands/auth.py @@ -59,14 +59,16 @@ class AuthAddCommand(_AuthCommand): def run( self, path: PathArg, - kind: Annotated[str, typer.Argument(help="oidc | oauth")], + kind: Annotated[ + str | None, typer.Argument(help="oidc | oauth (asked if omitted)") + ] = None, provider_id: Annotated[ - str, + 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, @@ -94,9 +96,18 @@ def run( str | None, typer.Option("--host", help="OIDC host override") ] = None, ) -> None: - if kind not in KINDS: - raise ValidationError(f"Unknown kind '{kind}'.", hint="Use oidc or oauth.") - pid = ap.validate_provider_id(kind, provider_id) + 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: @@ -113,21 +124,21 @@ def run( "pkce": pkce, "host": host, } - fields = ap.OIDC_FIELDS if kind == "oidc" else ap.OAUTH_FIELDS + fields = ap.OIDC_FIELDS if provider_kind == "oidc" else ap.OAUTH_FIELDS allowed = {f.name for f in fields} stray = sorted( k for k, v in values.items() if v is not None and k not in allowed ) if stray: flags = ", ".join("--" + k for k in stray) - raise ValidationError(f"Not applicable to {kind}: {flags}.") + raise ValidationError(f"Not applicable to {provider_kind}: {flags}.") project = self.load(path) - answers = self.ui.form().collect(list(fields), values) - provider = AuthProvider(kind=kind, id=pid, values=answers) + 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 {kind} provider '{pid}'.") + self.ui.success(f"Added {provider_kind} provider '{pid}'.") self.ui.info( f"Callback URL to register at the provider: {project.callback_url(pid)}" ) @@ -164,12 +175,24 @@ class AuthRemoveCommand(_AuthCommand): def run( self, path: PathArg, - provider_id: Annotated[str, typer.Argument(help="Provider id")], + 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"{p.id} ({p.kind})" for p 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 diff --git a/commands/dashboard.py b/commands/dashboard.py index 2bcf720..a38c5e2 100644 --- a/commands/dashboard.py +++ b/commands/dashboard.py @@ -8,8 +8,8 @@ import typer +from commands.auth import DashboardAuthCommands from commands.base import Command, CommandGroup -from commands.dashboard_auth import DashboardAuthCommands from commands.db import report_write from commands.settings import ( SetCommand, diff --git a/docs/superpowers/specs/2026-09-11-dashboard-settings-design.md b/docs/superpowers/specs/2026-09-11-dashboard-settings-design.md index 559f793..97096eb 100644 --- a/docs/superpowers/specs/2026-09-11-dashboard-settings-design.md +++ b/docs/superpowers/specs/2026-09-11-dashboard-settings-design.md @@ -185,7 +185,7 @@ Puis le message : `Apply with: portabase start NAME`. | `commands/agent.py` | `AgentCommands` (groupe) + `AgentCreateCommand` ; `db` devient `agent db` | | `commands/db.py` | inchangé, enregistré sous `agent` ; alias racine déprécié | | `commands/dashboard.py` | `DashboardCommands` : `create`, `show`, `set` | -| `commands/dashboard_auth.py` | `add`, `list`, `remove` | +| `commands/auth.py` | `add`, `list`, `remove` | | `commands/lifecycle.py` | `RestartCommand` → `up -d` puis `restart` | | `services/dashboard_settings.py` | `Setting`, `SETTINGS`, `OAUTH_PROVIDERS`, `OIDC_FIELDS` | | `services/project.py` | `DashboardProject` : `settings`, `providers`, `validate()`, `set()`, `add_provider()`, `remove_provider()` | diff --git a/main.py b/main.py index 67ba840..1ed4388 100644 --- a/main.py +++ b/main.py @@ -50,8 +50,6 @@ def from_env(cls, argv: list[str]) -> "Settings": no_color=bool(os.environ.get("NO_COLOR")) or "--no-color" in argv, ) if settings.no_color: - # Typer renders --help with its own Rich console, which only honors - # the NO_COLOR convention; --help is handled before any callback runs. os.environ["NO_COLOR"] = "1" return settings From 77167e592cdf957ed2021e048f08a612061148e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 14 Sep 2026 14:42:17 +0200 Subject: [PATCH 113/124] add: new cli --- .github/CONTRIBUTING.md | 9 + .github/workflows/ci.yml | 19 +- README.md | 2 +- commands/agent.py | 26 +- commands/auth.py | 22 +- commands/base.py | 26 - commands/build.py | 2 +- commands/dashboard.py | 20 +- commands/db.py | 26 +- commands/decrypt.py | 14 +- commands/flows/add_database.py | 12 +- commands/lifecycle.py | 4 +- commands/settings.py | 12 +- commands/update.py | 6 +- core/config.py | 8 +- core/utils.py | 8 +- core/version.py | 30 +- .../plans/2026-09-11-plan-1-ci-hygiene.md | 954 ------- ...2026-09-11-plan-2-foundations-lifecycle.md | 2385 ----------------- .../2026-09-11-plan-3-templates-engines.md | 1944 -------------- .../2026-09-11-plan-4-render-commands.md | 1935 ------------- .../specs/2026-09-11-cli-refactor-design.md | 592 ---- .../2026-09-11-dashboard-settings-design.md | 198 -- engines/base.py | 26 +- engines/mongodb.py | 11 +- engines/mssql.py | 7 +- engines/redis.py | 6 +- engines/valkey.py | 6 +- main.py | 36 +- pyproject.toml | 7 +- scripts/render_check.py | 215 -- services/compose_facts.py | 4 +- services/docker.py | 24 +- services/envfile.py | 41 +- services/http.py | 58 +- services/ports.py | 6 +- services/project.py | 61 +- services/renderer.py | 25 +- services/settings.py | 4 +- services/templates.py | 14 +- services/updater.py | 21 +- templates/dashboard.yml.j2 | 14 +- templates/engines/firebird.yml.j2 | 10 +- templates/engines/mariadb.yml.j2 | 8 +- templates/engines/mongodb.yml.j2 | 6 +- templates/engines/mssql.yml.j2 | 4 +- templates/engines/mysql.yml.j2 | 8 +- templates/engines/postgresql-cluster.yml.j2 | 6 +- templates/engines/postgresql.yml.j2 | 6 +- templates/engines/redis.yml.j2 | 2 +- templates/engines/valkey.yml.j2 | 4 +- tests/__init__.py | 0 tests/conftest.py | 90 + tests/core/__init__.py | 0 tests/core/config.py | 38 + tests/core/crypto.py | 135 + tests/core/errors.py | 53 + tests/core/fields.py | 29 + tests/core/specs.py | 33 + tests/core/utils.py | 99 + tests/core/version.py | 99 + tests/engines/__init__.py | 0 tests/engines/base.py | 106 + tests/engines/docker_volume.py | 84 + tests/engines/firebird.py | 145 + tests/engines/mariadb.py | 131 + tests/engines/mongodb.py | 132 + tests/engines/mssql.py | 120 + tests/engines/mysql.py | 126 + tests/engines/postgresql.py | 139 + tests/engines/postgresql_cluster.py | 135 + tests/engines/redis.py | 143 + tests/engines/registry.py | 47 + tests/engines/sqlite.py | 108 + tests/engines/valkey.py | 141 + tests/services/__init__.py | 0 tests/services/auth_providers.py | 49 + tests/services/compose_facts.py | 76 + tests/services/docker.py | 23 + tests/services/envfile.py | 125 + tests/services/http.py | 147 + tests/services/ports.py | 41 + tests/services/project.py | 382 +++ tests/services/renderer.py | 220 ++ tests/services/settings.py | 144 + tests/services/telemetry.py | 29 + tests/services/templates.py | 46 + tests/services/updater.py | 271 ++ tests/structure.py | 89 + tests/support.py | 139 + ui/components/progress.py | 2 +- ui/components/prompt.py | 4 +- ui/components/table.py | 6 +- ui/form.py | 34 +- 94 files changed, 4303 insertions(+), 8551 deletions(-) delete mode 100644 docs/superpowers/plans/2026-09-11-plan-1-ci-hygiene.md delete mode 100644 docs/superpowers/plans/2026-09-11-plan-2-foundations-lifecycle.md delete mode 100644 docs/superpowers/plans/2026-09-11-plan-3-templates-engines.md delete mode 100644 docs/superpowers/plans/2026-09-11-plan-4-render-commands.md delete mode 100644 docs/superpowers/specs/2026-09-11-cli-refactor-design.md delete mode 100644 docs/superpowers/specs/2026-09-11-dashboard-settings-design.md delete mode 100644 scripts/render_check.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/core/__init__.py create mode 100644 tests/core/config.py create mode 100644 tests/core/crypto.py create mode 100644 tests/core/errors.py create mode 100644 tests/core/fields.py create mode 100644 tests/core/specs.py create mode 100644 tests/core/utils.py create mode 100644 tests/core/version.py create mode 100644 tests/engines/__init__.py create mode 100644 tests/engines/base.py create mode 100644 tests/engines/docker_volume.py create mode 100644 tests/engines/firebird.py create mode 100644 tests/engines/mariadb.py create mode 100644 tests/engines/mongodb.py create mode 100644 tests/engines/mssql.py create mode 100644 tests/engines/mysql.py create mode 100644 tests/engines/postgresql.py create mode 100644 tests/engines/postgresql_cluster.py create mode 100644 tests/engines/redis.py create mode 100644 tests/engines/registry.py create mode 100644 tests/engines/sqlite.py create mode 100644 tests/engines/valkey.py create mode 100644 tests/services/__init__.py create mode 100644 tests/services/auth_providers.py create mode 100644 tests/services/compose_facts.py create mode 100644 tests/services/docker.py create mode 100644 tests/services/envfile.py create mode 100644 tests/services/http.py create mode 100644 tests/services/ports.py create mode 100644 tests/services/project.py create mode 100644 tests/services/renderer.py create mode 100644 tests/services/settings.py create mode 100644 tests/services/telemetry.py create mode 100644 tests/services/templates.py create mode 100644 tests/services/updater.py create mode 100644 tests/structure.py create mode 100644 tests/support.py diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 76e16d0..735b9dc 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -168,6 +168,15 @@ uv run python main.py config channel stable # or: beta uv run python main.py update ``` +### Running the tests + +Unit tests live in `tests/`, mirroring `core/`, `services/` and `engines/`. They call +the functions directly: no Docker, no network, no built binary. + +```bash +uv run pytest +uv run ruff check . && uv run ruff format --check . && uv run mypy +``` --- ## Reporting Issues diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cae7298..9b0883d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,8 +28,8 @@ jobs: run: uv run ruff format --check . - name: types run: uv run mypy - - name: templates - run: uv run python scripts/render_check.py --templates templates + - name: tests + run: uv run pytest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: ./.github/actions/setup @@ -48,18 +48,3 @@ jobs: - name: Build id: build uses: ./.github/actions/build - - # No PORTABASE_TEMPLATES_DIR: the binary must run on its own bundled copy. - - name: End-to-end agent creation - env: - BINARY: ${{ github.workspace }}/${{ steps.build.outputs.path }} - EDGE_KEY: eyJzZXJ2ZXJVcmwiOiJodHRwOi8veCIsImFnZW50SWQiOiJhIiwibWFzdGVyS2V5QjY0IjoiayJ9 - run: | - set -euo pipefail - cd "$(mktemp -d)" - "$BINARY" --help > /dev/null - "$BINARY" --non-interactive agent smoke --key "$EDGE_KEY" - "$BINARY" --non-interactive db add smoke --engine postgresql --mode new - "$BINARY" --non-interactive db list smoke - "$BINARY" --non-interactive build smoke --diff - cd smoke && docker compose config --quiet diff --git a/README.md b/README.md index 7350344..252a001 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ portabase dashboard auth add|list|remove NAME portabase start|stop|restart|logs|uninstall|build PATH ``` -`portabase db` still works for one release as an alias of `portabase agent db`. +`portabase db` was removed: database commands only apply to an agent, use `portabase agent db`. ## Upgrading from 26.08 or earlier diff --git a/commands/agent.py b/commands/agent.py index 397f65c..176bb0d 100644 --- a/commands/agent.py +++ b/commands/agent.py @@ -84,16 +84,22 @@ def run( provided = read_secret_flags(cfg.AGENT, settings) form = self.ui.form() answers = { - s.name: form.ask(s.field, provided.get(s.name)) for s in cfg.AGENT if s.core + setting.name: form.ask(setting.field, provided.get(setting.name)) + for setting in cfg.AGENT + if setting.core } env_vars = { - s.env: s.to_env(answers[s.name]) for s in cfg.AGENT if s.core and s.env + 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 += [ - (s.field.prompt, display(s, answers[s.name])) for s in cfg.AGENT if s.core + (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") @@ -106,7 +112,11 @@ def run( apply_settings( self.ui, project, - {k: v for k, v in provided.items() if not cfg.AGENT.get(k).core}, + { + 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}") @@ -158,8 +168,12 @@ def run(self, path: Annotated[Path, typer.Argument(help="Agent folder")]) -> Non show_settings(self.ui, project) if project.databases: rows = [ - [d.name, d.engine, self.engines.get(d.engine).describe(d)] - for d in project.databases + [ + 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: diff --git a/commands/auth.py b/commands/auth.py index f26e9c4..cdab244 100644 --- a/commands/auth.py +++ b/commands/auth.py @@ -125,12 +125,14 @@ def run( "host": host, } fields = ap.OIDC_FIELDS if provider_kind == "oidc" else ap.OAUTH_FIELDS - allowed = {f.name for f in fields} + allowed = {field.name for field in fields} stray = sorted( - k for k, v in values.items() if v is not None and k not in allowed + key + for key, value in values.items() + if value is not None and key not in allowed ) if stray: - flags = ", ".join("--" + k for k in stray) + flags = ", ".join("--" + name for name in stray) raise ValidationError(f"Not applicable to {provider_kind}: {flags}.") project = self.load(path) @@ -157,13 +159,13 @@ def run(self, path: PathArg) -> None: ["Kind", "Id", "Title", "Issuer / provider", "Callback"], [ [ - p.kind, - p.id, - p.values.get("title", ""), - p.values.get("issuer", p.id), - project.callback_url(p.id), + provider.kind, + provider.id, + provider.values.get("title", ""), + provider.values.get("issuer", provider.id), + project.callback_url(provider.id), ] - for p in providers + for provider in providers ], title=f"Login providers for {project.path.name}", ) @@ -188,7 +190,7 @@ def run( if not providers: self.ui.warning("No login provider to remove.") return - choices = [f"{p.id} ({p.kind})" for p in providers] + choices = [f"{provider.id} ({provider.kind})" for provider in providers] picked = self.ui.form().choice( "Which provider to remove?", choices, name="id" ) diff --git a/commands/base.py b/commands/base.py index c849de3..a36ed75 100644 --- a/commands/base.py +++ b/commands/base.py @@ -109,29 +109,3 @@ def build_typer(self) -> typer.Typer: def register(self, app: typer.Typer) -> None: app.add_typer(self.build_typer(), name=self.name, rich_help_panel=self.panel) - - -class DeprecatedAlias(CommandGroup): - def __init__( - self, ui: UI, telemetry: Telemetry, target: CommandGroup, *, name: str, use: str - ) -> None: - super().__init__(ui, telemetry) - self.name = name - self.help = f"Deprecated alias of '{use}'." - self.panel = target.panel - self._target = target - self._use = use - - @property - def commands(self) -> list[Command]: - return self._target.commands - - def build_typer(self) -> typer.Typer: - sub = super().build_typer() - ui, use, name = self.ui, self._use, self.name - - @sub.callback() - def _warn() -> None: - ui.warning(f"'portabase {name}' is deprecated, use 'portabase {use}'.") - - return sub diff --git a/commands/build.py b/commands/build.py index 4b5465e..85316df 100644 --- a/commands/build.py +++ b/commands/build.py @@ -92,7 +92,7 @@ def run( report = result.write(target) report_write(self.ui, report) self.ui.success( - f"Rendered {', '.join(p.name for p in report.wrote)} in {target}" + 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/dashboard.py b/commands/dashboard.py index a38c5e2..78d16b7 100644 --- a/commands/dashboard.py +++ b/commands/dashboard.py @@ -179,7 +179,7 @@ def run( provided = read_secret_flags(cfg.DASHBOARD, settings) apply_settings(self.ui, project, provided) - explicit = any(v is not None for v in provided.values()) + explicit = any(value is not None for value in provided.values()) if ( not self.ui.non_interactive and not explicit @@ -191,9 +191,9 @@ def run( rows.append(("Access URL", project.setting("url"))) rows += [ - (s.field.prompt, display(s, project.setting(s.name))) - for s in cfg.DASHBOARD - if s.name != "url" and project.env.get(s.env or "") is not None + (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") @@ -270,13 +270,13 @@ def run(self, path: PathArg) -> None: ["Kind", "Id", "Title", "Issuer / provider", "Callback"], [ [ - p.kind, - p.id, - p.values.get("title", ""), - p.values.get("issuer", p.id), - project.callback_url(p.id), + provider.kind, + provider.id, + provider.values.get("title", ""), + provider.values.get("issuer", provider.id), + project.callback_url(provider.id), ] - for p in providers + for provider in providers ], title="LOGIN PROVIDERS", ) diff --git a/commands/db.py b/commands/db.py index 867aaf1..bbee8ab 100644 --- a/commands/db.py +++ b/commands/db.py @@ -183,7 +183,10 @@ def run( return if target is None: - choices = [f"{d.name} ({d.engine}) [{d.id[:8]}]" for d in project.databases] + 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" ) @@ -231,23 +234,26 @@ def run(self, name: NameArg) -> None: self.ui.warning("No databases configured.") return rows = [] - for d in project.databases: - engine = self.engines.get(d.engine) + for database in project.databases: + engine = self.engines.get(database.engine) opts = ", ".join( - f"{k}={v}" for k, v in engine.non_default_options(d).items() + f"{key}={value}" + for key, value in engine.non_default_options(database).items() ) user = ( - "N/A" if d.engine in ("sqlite", "docker-volume") else (d.username or "") + "N/A" + if database.engine in ("sqlite", "docker-volume") + else (database.username or "") ) rows.append( [ - d.name, - d.database or "", - d.engine, - engine.describe(d), + database.name, + database.database or "", + database.engine, + engine.describe(database), user, opts, - d.id[:8] + "...", + database.id[:8] + "...", ] ) self.ui.table( diff --git a/commands/decrypt.py b/commands/decrypt.py index 0b06a9b..f1c08f5 100644 --- a/commands/decrypt.py +++ b/commands/decrypt.py @@ -72,15 +72,19 @@ def _single( out = output_path.resolve() try: decrypt_enc_file(enc_path, out, master_key) - except OSError as e: - raise DecryptionError(f"I/O error on {enc_path.name}: {e}", cause=e) from e + 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( - p for p in in_dir.iterdir() if p.is_file() and p.suffix == ENC_SUFFIX + 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}.") @@ -101,8 +105,8 @@ def _folder( out = out_dir / default_output_for(enc_path) try: decrypt_enc_file(enc_path, out, master_key) - except (DecryptionError, OSError) as e: - failures.append((enc_path.name, str(e))) + 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 " diff --git a/commands/flows/add_database.py b/commands/flows/add_database.py index 878c4eb..3098795 100644 --- a/commands/flows/add_database.py +++ b/commands/flows/add_database.py @@ -96,7 +96,7 @@ def _collect_options( self, form: Form, engine: DbEngine, provided: dict[str, str] ) -> dict[str, Any]: option_fields = engine.option_fields() - known = {f.name for f in option_fields} + known = {field.name for field in option_fields} unknown = set(provided) - known if unknown: raise ValidationError( @@ -115,14 +115,16 @@ def _collect_options( def _reject_irrelevant( values: dict[str, Any], fields: list[Field], engine: DbEngine, mode: str ) -> None: - relevant = {f.name for f in fields} | FLOW_KEYS + relevant = {field.name for field in fields} | FLOW_KEYS extra = sorted( - k for k, v in values.items() if v is not None and k not in relevant + key + for key, value in values.items() + if value is not None and key not in relevant ) if not extra: return - flags = ", ".join("--" + k.replace("_", "-") for k in extra) - applicable = ", ".join("--" + f.name.replace("_", "-") for f in fields) + 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}" diff --git a/commands/lifecycle.py b/commands/lifecycle.py index d0583ff..b277dd9 100644 --- a/commands/lifecycle.py +++ b/commands/lifecycle.py @@ -109,6 +109,6 @@ def run( self.docker.compose(path, ["down", "-v"]) try: shutil.rmtree(path) - except OSError as e: - self.ui.warning(f"Could not remove directory: {e}") + 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 index 6638570..07ded24 100644 --- a/commands/settings.py +++ b/commands/settings.py @@ -80,9 +80,9 @@ def entry(*args: Any, **kwargs: Any) -> None: run(*args, **kwargs) static = [ - p - for p in inspect.signature(run, eval_str=True).parameters.values() - if p.kind is not inspect.Parameter.VAR_KEYWORD + 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] @@ -119,9 +119,9 @@ def show_settings(ui: UI, project: SettingsProject) -> None: values = project.settings() for section, title in project.registry.sections.items(): rows = [ - (s.field.prompt, display(s, values[s.name])) - for s in project.registry.in_section(section) - if values[s.name] not in (None, "") + (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()) diff --git a/commands/update.py b/commands/update.py index dd6cafb..04a7eba 100644 --- a/commands/update.py +++ b/commands/update.py @@ -56,10 +56,10 @@ def run(self) -> None: def _latest(self) -> Release: try: release = self.checker.fetch_latest() - except NetworkError as e: + except NetworkError as error: raise UpdateError( - "Could not fetch latest release data from GitHub.", cause=e - ) from e + "Could not fetch latest release data from GitHub.", cause=error + ) from error if release is None: raise UpdateError("No release found for this channel.") return release diff --git a/core/config.py b/core/config.py index 1e4c418..da07a34 100644 --- a/core/config.py +++ b/core/config.py @@ -17,8 +17,8 @@ def all(self) -> dict: if not self.path.exists(): return {} try: - with open(self.path, encoding="utf-8") as f: - data = json.load(f) + 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 {} @@ -31,8 +31,8 @@ def set(self, key: str, value) -> None: 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) + with open(tmp, "w", encoding="utf-8") as file: + json.dump(data, file, indent=2) os.replace(tmp, self.path) @property diff --git a/core/utils.py b/core/utils.py index c676f18..c7eaaad 100644 --- a/core/utils.py +++ b/core/utils.py @@ -31,6 +31,10 @@ def generate_password(length: int = 16) -> str: return "".join(password) +def escape_yaml_double_quoted(value: str) -> str: + return value.replace("\\", "\\\\").replace('"', '\\"') + + def slugify_project_name(value: str, fallback: str = "portabase") -> str: slug = re.sub(r"[^a-z0-9_-]+", "-", value.lower()) slug = slug.strip("-_") @@ -52,6 +56,8 @@ def validate_edge_key(key: str) -> bool: return False required_fields = ["serverUrl", "agentId", "masterKeyB64"] - return all(field in data for field in required_fields) + return isinstance(data, dict) and all( + field in data for field in required_fields + ) except TypeError: return False diff --git a/core/version.py b/core/version.py index 0ea8299..eea5cfa 100644 --- a/core/version.py +++ b/core/version.py @@ -7,7 +7,9 @@ from pathlib import Path UNKNOWN = "unknown" -_PRE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:[-.]?(rc|alpha|beta|a|b)(\d*))?$", re.I) +_PRE = re.compile( + r"^(\d+)\.(\d+)\.(\d+)(?:[-.]?(rc|alpha|beta|a|b)(\d*)(?:\.(\d+))?)?$", re.I +) @lru_cache(maxsize=1) @@ -15,23 +17,25 @@ 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 f: - return tomllib.load(f)["project"]["version"] + 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: - m = _PRE.match(version.strip().lstrip("v")) - return bool(m and m.group(4)) + 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]: - 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() +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 = int(m.group(5)) if m.group(5) else 0 - return (major, minor, patch, rank, num) + 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 deleted file mode 100644 index 9f0648a..0000000 --- a/docs/superpowers/plans/2026-09-11-plan-1-ci-hygiene.md +++ /dev/null @@ -1,954 +0,0 @@ -# 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 deleted file mode 100644 index 51f8cae..0000000 --- a/docs/superpowers/plans/2026-09-11-plan-2-foundations-lifecycle.md +++ /dev/null @@ -1,2385 +0,0 @@ -# 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`, `commands/decrypt.py`, `core/crypto.py`, `core/network.py`, `core/docker.py`, `templates/compose.py` (réécrits ou supprimés au Plan 4). `decrypt` (ajouté en 26.08.12) est enregistré via `LegacyCommand` comme `agent`/`dashboard`. `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 import decrypt as legacy_decrypt -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"), - LegacyCommand(ui, telemetry, legacy_decrypt.decrypt, name="decrypt", help="Decrypt Portabase .enc backup files (single file or folder).", panel="Configuration"), - 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"] -"commands/decrypt.py" = ["B904", "TID251"] -"core/crypto.py" = ["BLE001", "SIM105"] -"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` (decrypt, 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 deleted file mode 100644 index 3b5be5d..0000000 --- a/docs/superpowers/plans/2026-09-11-plan-3-templates-engines.md +++ /dev/null @@ -1,1944 +0,0 @@ -# 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 deleted file mode 100644 index 1856538..0000000 --- a/docs/superpowers/plans/2026-09-11-plan-4-render-commands.md +++ /dev/null @@ -1,1935 +0,0 @@ -# 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/decrypt.py` | réécrire | `DecryptCommand` | -| `core/crypto.py` | modifier | `DecryptionError(PortabaseError)` | -| `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="SUMMARY") - 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 8b : `commands/decrypt.py` en classe - -**Files:** -- Modify: `core/crypto.py` (`DecryptionError` hérite de `PortabaseError`) -- Modify: `commands/decrypt.py` (réécriture) - -**Interfaces:** -- Produces: `DecryptCommand(ui, telemetry)` ; `core.crypto.DecryptionError(PortabaseError)` avec `code = "E_CRYPTO"`, `exit_code = 8`. - -- [ ] **Step 1: `core/crypto.py`** - -Remplacer la définition de `DecryptionError` par : - -```python -from core.errors import PortabaseError - - -class DecryptionError(PortabaseError): - """Raised when a ``.enc`` file cannot be decrypted.""" - - code = "E_CRYPTO" - exit_code = 8 -``` - -Le reste du module (fonctions pures de déchiffrement) est inchangé. - -- [ ] **Step 2: `commands/decrypt.py`** - -```python -"""portabase decrypt INPUT [OUTPUT] — decrypt .enc backups (file or folder).""" - -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, help, panel = "decrypt", "Decrypt Portabase .enc backup files (single file or folder).", "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) # raises DecryptionError - 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 e: - raise DecryptionError(f"I/O error on {enc_path.name}: {e}", cause=e) from e - 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(p for p in in_dir.iterdir() if p.is_file() and p.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 e: - failures.append((enc_path.name, str(e))) - continue - succeeded = len(enc_files) - len(failures) - self.ui.info(f"Done: {succeeded} succeeded, {len(failures)} failed 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érence assumée : les succès individuels ne sont plus imprimés un par un sous le spinner (un `console.print` sous `status` est autorisé mais bruyant) ; le résumé final reste. `main.py` remplace le `LegacyCommand` decrypt par `DecryptCommand(ui, telemetry)` (Task 9). - -- [ ] **Step 3: Vérifier** - -Run: `uv run python main.py decrypt /tmp/nope; echo "exit=$?"` → `E_CONFIG`, exit 3. `uv run python main.py decrypt . ; echo "exit=$?"` sans `master_key.bin` → `E_CRYPTO`, exit 8. Avec un vrai `.enc` et sa clé si disponible : déchiffrement identique à 26.08.12. - -- [ ] **Step 4: Commit** - -```bash -git add core/crypto.py commands/decrypt.py -git commit -m "refactor(commands): rewrite decrypt as a Command class" -``` - ---- - -### 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 commands.decrypt import DecryptCommand -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`, `decrypt as legacy_decrypt`, `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), - DecryptCommand(ui, telemetry), - 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 deleted file mode 100644 index cb62728..0000000 --- a/docs/superpowers/specs/2026-09-11-cli-refactor-design.md +++ /dev/null @@ -1,592 +0,0 @@ -# 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. | -| `decrypt INPUT [OUTPUT]` | `--key` | déchiffre des sauvegardes `.enc` (ajouté en 26.08.12) ; `DecryptCommand`, `DecryptionError(PortabaseError)` code `E_CRYPTO` exit 8. `core/crypto.py` reste un module de fonctions pures. | - -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 | -| `DecryptionError` (`core/crypto.py`) | `E_CRYPTO` | 8 | - -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/docs/superpowers/specs/2026-09-11-dashboard-settings-design.md b/docs/superpowers/specs/2026-09-11-dashboard-settings-design.md deleted file mode 100644 index 97096eb..0000000 --- a/docs/superpowers/specs/2026-09-11-dashboard-settings-design.md +++ /dev/null @@ -1,198 +0,0 @@ -# Dashboard settings and authentication — Design - -Date : 2026-09-11 -Dépend de : `2026-09-11-cli-refactor-design.md` (état déclaratif, `EnvFile`, `Field`, `Form`, `CommandGroup`). - -## 1. Objectif - -Configurer depuis le CLI ce que le dashboard lit dans son environnement : API et MCP, onboarding, authentification par mot de passe, providers OIDC et OAuth2. À la création et après coup, en interactif et par flags. - -Au passage, une rupture de surface décidée pour être cohérente : un namespace par composant. - -## 2. Surface CLI - -### 2.1 Avant / après - -| Avant | Après | -|---|---| -| `agent NAME` | `agent create NAME` | -| `db add\|remove\|list NAME` | `agent db add\|remove\|list NAME` | -| `dashboard NAME` | `dashboard create NAME` | -| — | `dashboard show NAME` | -| — | `dashboard set NAME KEY VALUE [KEY VALUE…]` | -| — | `dashboard auth add\|remove\|list NAME` | -| `start\|stop\|restart\|logs\|uninstall\|build PATH` | inchangés | -| `config`, `update`, `decrypt` | inchangés | - -Un groupe Typer ne peut pas porter à la fois un argument positionnel et des sous-commandes (vérifié : `dashboard auth` créerait un dashboard nommé `auth`). D'où `create`. - -### 2.2 Compatibilité - -- `db` reste à la racine **une version**, alias de `agent db`, avec un avertissement à chaque appel : `'portabase db' is deprecated, use 'portabase agent db'`. Retiré à la version suivante. -- `portabase agent my-agent` et `portabase dashboard my-dash` produisent une `UsageError` « No such command ». Le catcher de `main.py` la reconnaît (token précédent = `agent` ou `dashboard`) et ajoute le hint `Did you mean: portabase agent create my-agent?`. -- README, `CONTRIBUTING.md`, doc portabase.io et script d'installation à mettre à jour dans la même release. - -### 2.3 Implémentation - -`CommandGroup` gagne `groups: list[CommandGroup]` pour imbriquer (`agent` contient `db`). `AgentCommand` devient `AgentCreateCommand` dans un `AgentCommands(CommandGroup)` ; idem `DashboardCreateCommand` dans `DashboardCommands`. - -## 3. Registre de settings - -Le cœur : une liste déclarative, un endroit à toucher pour ajouter un réglage. - -```python -@dataclass(frozen=True) -class Setting: - field: Field # name, prompt, kind, default, choices, help, validator - env: str # variable écrite dans .env - section: str # "network" | "api" | "onboarding" | "auth" - secret: bool = False # jamais en flag visible sans avertissement, masqué à l'affichage -``` - -`services/dashboard_settings.py` : - -| Section | `field.name` | `env` | kind | défaut dashboard | -|---|---|---|---|---| -| network | `url` | `PROJECT_URL` | text | `http://localhost:` | -| network | `behind_proxy` | `TUSD_BEHIND_PROXY` | bool | false | -| network | `trusted_domains` | `TRUSTED_DOMAINS` | text | — | -| api | `api` | `API_ENABLED` | bool | false | -| api | `openapi` | `OPENAPI_ENABLED` | bool | false | -| api | `mcp` | `MCP_ENABLED` | bool | false | -| onboarding | `skip_onboarding` | `SKIP_ONBOARDING` | bool | false | -| onboarding | `admin_name` | `AUTH_DEFAULT_USER_NAME` | text | — | -| onboarding | `admin_email` | `AUTH_DEFAULT_USER` | text | — | -| onboarding | `admin_password` | `AUTH_DEFAULT_PASSWORD` | secret | — | -| auth | `password_auth` | `AUTH_EMAIL_PASSWORD_ENABLED` | bool | true | -| auth | `signup` | `AUTH_SIGNUP_ENABLED` | bool | — | -| auth | `passkey` | `AUTH_PASSKEY_ENABLED` | bool | — | -| auth | `account_linking` | `AUTH_ALLOW_LINKING` | bool | — | -| auth | `account_unlinking` | `AUTH_ALLOW_UNLINKING` | bool | — | -| auth | `sync_oidc_roles` | `AUTH_SYNC_OIDC_ROLES_ON_LOGIN` | bool | — | -| auth | `role_map` | `AUTH_ROLE_MAP` | text | — | -| auth | `allowed_group` | `ALLOWED_GROUP` | text | — | - -Ce que le registre produit, sans code par réglage : - -- les flags de `dashboard create` : `--api/--no-api` pour un bool, `--url` pour un texte, `--admin-password-stdin` pour un secret ; -- les prompts interactifs, groupés par section ; -- la validation de `dashboard set` : `KEY` doit être un `field.name` du registre, `VALUE` est coercé par `Form` (bool `true/false/yes/no/1/0`, choix, validateur) ; -- l'affichage de `dashboard show`, section par section, secrets masqués. - -Écriture dans `.env` : booléens en `true`/`false`. À la création, seuls les réglages fournis ou différents du défaut sont écrits (le `.env` reste lisible). `set` écrit toujours la valeur demandée. - -`admin_password` porte le validateur documenté : 8 caractères, majuscule, minuscule, chiffre, spécial. - -## 4. Providers d'authentification - -Répétables, donc en sous-commande, comme `agent db`. - -### 4.1 Modèle - -```python -@dataclass(frozen=True) -class AuthProvider: - kind: Literal["oidc", "oauth"] - id: str # providerId : "keycloak", "github" - values: dict[str, str] # champs → valeurs, sans le préfixe -``` - -Stockage dans `.env` par préfixe, ce qui est exactement le mécanisme des bases managées : - -| kind | préfixe | champs | -|---|---|---| -| oidc | `AUTH_OIDC__` | `ID`, `TITLE`, `DESC`, `ICON`, `ISSUER_URL`, `CLIENT`, `SECRET`, `SCOPES`, `PKCE`, `HOST` | -| oauth | `AUTH_SOCIAL__` | `CLIENT`, `SECRET`, `TITLE` | - -`` est l'identifiant en majuscules avec `-` → `_` ; `AUTH_OIDC__ID` reçoit l'identifiant tel que saisi (c'est le `providerId` du callback). Les providers OAuth sont limités aux noms connus du dashboard : `google`, `github`, `discord`, `apple`, `linkedin`, `x`, `reddit`. - -Lecture : `DashboardProject.providers` scanne les clés du `.env` par préfixe et reconstruit la liste. Aucun autre état. - -### 4.2 Commandes - -``` -dashboard auth add NAME oidc ID --issuer URL --client CLIENT (--secret S | --secret-stdin) - [--title T] [--scopes "openid profile email"] [--pkce] [--host H] -dashboard auth add NAME oauth PROVIDER --client CLIENT (--secret S | --secret-stdin) [--title T] -dashboard auth list NAME -dashboard auth remove NAME ID [--yes] -``` - -- `add` sur un `ID` existant → `ValidationError`, hint « remove it first ». -- `remove` fait `env.remove_prefix(...)` puis re-rend. -- `list` affiche kind, id, titre, issuer/provider, et le callback à déclarer chez le fournisseur : `/api/auth/sso/callback/`. -- `--secret` visible accepté avec avertissement, `--secret-stdin` recommandé — même règle que `agent db add`. - -En interactif, `add` sans flags pose les champs du kind via `Form`. - -## 5. Validations croisées - -Dans `DashboardProject.validate()`, appelé avant toute écriture. Ce sont des refus, pas des avertissements : chacune laisse une instance inaccessible. - -| Condition | Erreur | -|---|---| -| `skip_onboarding` sans `admin_email` **et** `admin_password` | « Skipping onboarding needs an initial account: set admin_email and admin_password. » | -| `password_auth = false` et aucun provider | « Disabling password login with no OIDC or OAuth provider would lock everyone out. » | -| au moins un provider et `url` sur `localhost` | « Providers need a public URL for their callback; set url (currently http://localhost:8887). » | -| `auth remove` du dernier provider alors que `password_auth = false` | même refus que la ligne 2 | - -`admin_password` faible → refus par le validateur du champ. - -## 6. Interactif — `dashboard create` - -Le flux actuel reste : port, mode base, timezone, résumé, confirmation. Entre le résumé et la confirmation, une question : - -``` -Configure API, MCP and authentication now? [y/N] -``` - -Non par défaut. Si oui, trois sections courtes, chacune précédée de `ui.section(...)` : - -1. **API** — `api`, `openapi`, `mcp` -2. **Onboarding** — `skip_onboarding` ; si oui, `admin_name`, `admin_email`, `admin_password` (masqué) -3. **Authentication** — `password_auth`, `signup`, `passkey` - -Les providers ne sont pas dans le wizard : hint `Add a login provider with: portabase dashboard auth add NAME oidc …`, comme `agent create` renvoie vers `db add`. - -Le résumé inclut les réglages non-défaut. `--yes` saute la confirmation, `--non-interactive` prend les défauts et ne pose pas la question des sections. - -## 7. Application des changements - -Toute commande mutante (`create`, `set`, `auth add`, `auth remove`) termine par `project.save_state()` puis `renderer.render_dashboard(project).write(path)`. Le template a `env_file: .env`, donc le compose ne change pas — mais `write` est appelé quand même pour garder un seul chemin. - -Puis le message : `Apply with: portabase start NAME`. - -**`restart` est corrigé dans cette spec** : `docker compose restart` ne relit pas `env_file` ni ne crée un service ajouté (bug déjà constaté sur `agent db add`). `RestartCommand` fait `up -d` puis `restart`, pour converger vers l'état déclaré. Le message des commandes mutantes peut alors dire `portabase restart NAME` sans mentir. - -## 8. Ce qui ne change pas - -- `dashboard.yml.j2` : aucune modification, `env_file` suffit. -- `EnvFile`, `Form`, `Field`, `Summary`, `render_dashboard` : réutilisés tels quels. -- Le mode `custom`/`external`/`internal` de la base : inchangé. - -## 9. Hors périmètre - -- SMTP (`SMTP_*`), `RETENTION_CRON`, `STALE_BACKUP_THRESHOLD_HOURS`, `BACKUP_FOLDER_NAME`, `TELEMETRY` du dashboard. Le registre les accepte en une ligne chacun le jour venu. -- Provider OAuth2 générique (endpoints libres) : le dashboard ne le documente pas. -- Un `agent set` : l'agent n'a que `TZ`, `POLLING`, `LOG_LEVEL` ; à ajouter si le besoin apparaît, avec le même registre. -- Tests automatisés : spec séparée. Vérification ici par `render_check` et les parcours non-interactifs. - -## 10. Fichiers - -| Fichier | Action | -|---|---| -| `commands/base.py` | `CommandGroup.groups` pour l'imbrication | -| `commands/agent.py` | `AgentCommands` (groupe) + `AgentCreateCommand` ; `db` devient `agent db` | -| `commands/db.py` | inchangé, enregistré sous `agent` ; alias racine déprécié | -| `commands/dashboard.py` | `DashboardCommands` : `create`, `show`, `set` | -| `commands/auth.py` | `add`, `list`, `remove` | -| `commands/lifecycle.py` | `RestartCommand` → `up -d` puis `restart` | -| `services/dashboard_settings.py` | `Setting`, `SETTINGS`, `OAUTH_PROVIDERS`, `OIDC_FIELDS` | -| `services/project.py` | `DashboardProject` : `settings`, `providers`, `validate()`, `set()`, `add_provider()`, `remove_provider()` | -| `main.py` | enregistrement des groupes, hint « did you mean » | -| `README.md`, `.github/CONTRIBUTING.md` | nouvelle surface | - -## 11. Questions ouvertes - -- Le `providerId` OIDC : imposer le même que `` en minuscules, ou le laisser libre via `--id` ? Défaut retenu : identique, pas de flag. -- `dashboard set` accepte plusieurs paires en une commande ; faut-il aussi `dashboard unset KEY` pour revenir au défaut du dashboard (retirer la variable) ? Défaut retenu : oui, trivial avec `env.remove`. diff --git a/engines/base.py b/engines/base.py index 5107c10..444a8d7 100644 --- a/engines/base.py +++ b/engines/base.py @@ -7,7 +7,7 @@ from core.fields import Field from core.specs import DatabaseSpec -from core.utils import generate_password +from core.utils import escape_yaml_double_quoted, generate_password from services.ports import PortAllocator STANDARD_EXISTING_FIELDS = ( @@ -53,9 +53,9 @@ def __init_subclass__(cls, **kwargs: Any) -> None: 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 + if field.name == "port" + else field + for field in STANDARD_EXISTING_FIELDS ] def fields_new(self) -> list[Field]: @@ -83,12 +83,12 @@ def from_existing(self, answers: dict[str, Any]) -> DatabaseSpec: ) def env_vars(self, spec: DatabaseSpec) -> dict[str, str]: - p = spec.env_prefix + prefix = 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 "", + 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( @@ -127,9 +127,11 @@ def describe(self, spec: DatabaseSpec) -> str: return f"{spec.host}:{spec.port}" def non_default_options(self, spec: DatabaseSpec) -> dict[str, Any]: - defaults = {f.name: f.default for f in self.option_fields()} + defaults = {field.name: field.default for field in self.option_fields()} return { - k: v for k, v in spec.options.items() if k in defaults and v != defaults[k] + key: value + for key, value in spec.options.items() + if key in defaults and value != defaults[key] } @staticmethod @@ -144,7 +146,7 @@ def service_name(slug: str, auth: bool = False) -> str: @staticmethod def var(spec: DatabaseSpec, suffix: str, value: Any, inline: bool) -> str: if inline: - return str(value if value is not None else "") + return escape_yaml_double_quoted(str(value if value is not None else "")) return f"${{{spec.env_prefix}_{suffix}}}" diff --git a/engines/mongodb.py b/engines/mongodb.py index fca6a4b..09232ba 100644 --- a/engines/mongodb.py +++ b/engines/mongodb.py @@ -32,9 +32,12 @@ def generate( ) 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 ""} + prefix = spec.env_prefix + out = { + f"{prefix}_PORT": str(spec.host_port), + f"{prefix}_DB": spec.database or "", + } if spec.auth: - out[f"{p}_USER"] = spec.username or "" - out[f"{p}_PASS"] = spec.password or "" + out[f"{prefix}_USER"] = spec.username or "" + out[f"{prefix}_PASS"] = spec.password or "" return out diff --git a/engines/mssql.py b/engines/mssql.py index 2d110bc..ae56b62 100644 --- a/engines/mssql.py +++ b/engines/mssql.py @@ -29,5 +29,8 @@ def generate( ) 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 ""} + prefix = spec.env_prefix + return { + f"{prefix}_PORT": str(spec.host_port), + f"{prefix}_PASS": spec.password or "", + } diff --git a/engines/redis.py b/engines/redis.py index 0a872a8..45f1eec 100644 --- a/engines/redis.py +++ b/engines/redis.py @@ -41,10 +41,10 @@ def generate( ) def env_vars(self, spec: DatabaseSpec) -> dict[str, str]: - p = spec.env_prefix - out = {f"{p}_PORT": str(spec.host_port)} + prefix = spec.env_prefix + out = {f"{prefix}_PORT": str(spec.host_port)} if spec.auth: - out[f"{p}_PASS"] = spec.password or "" + out[f"{prefix}_PASS"] = spec.password or "" return out def agent_database(self, spec: DatabaseSpec) -> str: diff --git a/engines/valkey.py b/engines/valkey.py index c9a95c6..376c81f 100644 --- a/engines/valkey.py +++ b/engines/valkey.py @@ -41,10 +41,10 @@ def generate( ) def env_vars(self, spec: DatabaseSpec) -> dict[str, str]: - p = spec.env_prefix - out = {f"{p}_PORT": str(spec.host_port)} + prefix = spec.env_prefix + out = {f"{prefix}_PORT": str(spec.host_port)} if spec.auth: - out[f"{p}_PASS"] = spec.password or "" + out[f"{prefix}_PASS"] = spec.password or "" return out def agent_database(self, spec: DatabaseSpec) -> str: diff --git a/main.py b/main.py index 1ed4388..22ce500 100644 --- a/main.py +++ b/main.py @@ -9,7 +9,6 @@ import typer from commands.agent import AgentCommands -from commands.base import DeprecatedAlias from commands.build import BuildCommand from commands.config import ConfigCommands from commands.dashboard import DashboardCommands @@ -136,7 +135,6 @@ def root( DashboardCommands(ui, telemetry, docker, templates, renderer, ports).register(app) for cmd in commands: cmd.register(app) - DeprecatedAlias(ui, telemetry, agent.db, name="db", use="agent db").register(app) ConfigCommands(ui, telemetry, config).register(app) return app, checker @@ -144,6 +142,8 @@ def root( 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." @@ -172,7 +172,9 @@ def main() -> None: ui = UI(non_interactive=settings.non_interactive, no_color=settings.no_color) telemetry = NoopTelemetry() app, checker = build_app(ui, telemetry, config, settings) - invoked = next((a for a in sys.argv[1:] if not a.startswith("-")), None) + invoked = next( + (argument for argument in sys.argv[1:] if not argument.startswith("-")), None + ) exit_code = 0 try: @@ -180,20 +182,20 @@ def main() -> None: result = app(standalone_mode=False) if isinstance(result, int): exit_code = result - except UserAbort as e: - ui.warning(e.message) + except UserAbort as error: + ui.warning(error.message) telemetry.event("abort") - exit_code = e.exit_code - except PortabaseError as e: - ui.error(e) - telemetry.error(e) - exit_code = e.exit_code + 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 e: - exit_code = e.exit_code - except click.UsageError as e: - err = ValidationError(e.format_message(), hint=_usage_hint(e)) + 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 @@ -201,10 +203,10 @@ def main() -> None: ui.print("") ui.warning("Canceled.") 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) + 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(e, unexpected=True) + telemetry.error(error, unexpected=True) exit_code = 1 finally: telemetry.flush() diff --git a/pyproject.toml b/pyproject.toml index 5ae4b14..3c41f35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,11 +60,14 @@ known-first-party = ["commands", "core", "engines", "services", "ui"] [tool.pytest.ini_options] testpaths = ["tests"] -addopts = "-q" +python_files = ["*.py"] +python_functions = ["[!_]*"] +pythonpath = ["."] +addopts = "-q --import-mode=importlib" [tool.mypy] python_version = "3.12" -files = ["commands", "core", "engines", "services", "ui", "scripts", "main.py"] +files = ["commands", "core", "engines", "services", "ui", "tests", "main.py"] ignore_missing_imports = true warn_unused_ignores = true warn_redundant_casts = true diff --git a/scripts/render_check.py b/scripts/render_check.py deleted file mode 100644 index 68c7952..0000000 --- a/scripts/render_check.py +++ /dev/null @@ -1,215 +0,0 @@ -#!/usr/bin/env python3 - -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.specs import DatabaseSpec # noqa: E402 -from engines import registry # noqa: E402 -from engines.base import DbEngine # noqa: E402 -from services.envfile import EnvFile # noqa: E402 -from services.ports import FixedPortAllocator # noqa: E402 -from services.project import AgentProject, DashboardProject # noqa: E402 -from services.renderer import ComposeRenderer # noqa: E402 -from services.templates import TemplateRepository # noqa: E402 - -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", -} - - -class Failure(Exception): - pass - - -def env_text(project: AgentProject | DashboardProject) -> str: - return "".join(f'{k}="{v}"\n' for k, v in project.env.as_dict().items()) - - -def validate(label: str, compose: str, env: 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, 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_project( - tmp: Path, - pairs: list[tuple[DatabaseSpec, DbEngine]], - *, - host_gateway: bool = False, - sqlite: bool = False, - docker_volume: bool = False, -) -> AgentProject: - env = EnvFile(tmp / ".env") - env.merge(AGENT_ENV) - project = AgentProject(tmp, env, [], host_gateway) - for spec, engine in pairs: - project.add(spec, engine) - if sqlite: - sq = registry.get("sqlite") - project.add( - sq.generate(auth=False, ports=FixedPortAllocator(), answers={"name": "x"}), - sq, - ) - if docker_volume: - dv = registry.get("docker-volume") - project.add(dv.from_existing({"volume": "v"}), dv) - return project - - -def agent_cases(renderer: ComposeRenderer) -> list[tuple[str, str, str]]: - ports = FixedPortAllocator() - tmp = Path(tempfile.mkdtemp()) - cases: list[tuple[str, str, str]] = [] - - 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, docker_volume=True) - cases.append( - ("agent/toggles", renderer.render_agent(toggles).compose, env_text(toggles)) - ) - - everything: list[tuple[DatabaseSpec, DbEngine]] = [] - 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)]) - variant = ("/auth" if auth else "/noauth") if engine.auth_variants else "" - cases.append( - ( - f"agent/{engine.key}{variant}", - renderer.render_agent(one).compose, - env_text(one), - ) - ) - everything.append((spec, engine)) - - combined = agent_project(tmp, everything, host_gateway=True, sqlite=True) - cases.append( - ("agent/all", renderer.render_agent(combined).compose, env_text(combined)) - ) - return cases - - -def dashboard_cases(renderer: ComposeRenderer) -> list[tuple[str, str, str]]: - tmp = Path(tempfile.mkdtemp()) - variants = { - "external": {**DASHBOARD_BASE, **DASHBOARD_PG, "POSTGRES_HOST": "db"}, - "internal": DASHBOARD_BASE, - "custom": {**DASHBOARD_BASE, **DASHBOARD_PG, "POSTGRES_HOST": "remote"}, - } - cases = [] - for mode, values in variants.items(): - env = EnvFile(tmp / f".env.{mode}") - env.merge(values) - project = DashboardProject(tmp, env) - if project.db_mode != mode: - raise Failure(f"db_mode mismatch: {project.db_mode} != {mode}") - cases.append( - ( - f"dashboard/{mode}", - renderer.render_dashboard(project).compose, - env_text(project), - ) - ) - return cases - - -def engines_check(repo: TemplateRepository) -> None: - shipped = set(repo.names()) - used = set() - for engine in registry: - if engine.template is None: - continue - if engine.template not in shipped: - raise Failure( - f"{engine.key}: template {engine.template} not found in {repo.root}" - ) - used.add(engine.template) - orphans = { - name for name in shipped if name.startswith("engines/") and name not in used - } - if orphans: - raise Failure( - f"template(s) not used by any engine: {', '.join(sorted(orphans))}" - ) - print(f"ok engines-check ({len(used)} templates, {len(registry.keys())} engines)") - - -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(Path(args.templates)) - renderer = ComposeRenderer(repo, registry, "render-check") - try: - engines_check(repo) - for label, compose, env in agent_cases(renderer) + dashboard_cases(renderer): - validate(label, compose, env, use_compose) - except Failure as e: - print(f"FAIL {e}", file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/services/compose_facts.py b/services/compose_facts.py index 6b3568a..b3cae80 100644 --- a/services/compose_facts.py +++ b/services/compose_facts.py @@ -38,9 +38,9 @@ def _service(self, name: str) -> dict: 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) + return any("host-gateway" in str(entry) for entry in extra) if isinstance(extra, dict): - return any("host-gateway" in str(v) for v in extra.values()) + return any("host-gateway" in str(value) for value in extra.values()) return False @property diff --git a/services/docker.py b/services/docker.py index 8dd3491..8f60b3d 100644 --- a/services/docker.py +++ b/services/docker.py @@ -53,8 +53,10 @@ def start_daemon(self, *, wait_seconds: int = 20) -> bool: 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 + 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(): @@ -77,10 +79,10 @@ def ensure_network(self, name: str) -> None: stdout=subprocess.DEVNULL, check=True, ) - except subprocess.CalledProcessError as e: + except subprocess.CalledProcessError as error: raise DockerError( - f"Could not create Docker network '{name}'.", cause=e - ) from e + f"Could not create Docker network '{name}'.", cause=error + ) from error def remove_volume(self, name: str) -> bool: proc = subprocess.run( @@ -116,11 +118,11 @@ def compose( capture_output=capture, text=capture, ) - except subprocess.CalledProcessError as e: + except subprocess.CalledProcessError as error: raise DockerError( - f"docker compose {' '.join(args)} failed (exit {e.returncode}).", + f"docker compose {' '.join(args)} failed (exit {error.returncode}).", hint=f"Run it manually in {cwd} to see the full output.", - cause=e, - ) from e - except OSError as e: - raise DockerError(f"Could not run docker: {e}", cause=e) from e + 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 index 2cc9f3e..ceb4dfb 100644 --- a/services/envfile.py +++ b/services/envfile.py @@ -34,10 +34,10 @@ def load(cls, path: Path) -> EnvFile: 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 + 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 @@ -45,37 +45,40 @@ 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: + index = self._index.get(key) + if index is None: return default - m = _LINE.match(self._lines[i]) - return _unquote(m.group(2)) if m else default + match = _LINE.match(self._lines[index]) + return _unquote(match.group(2)) if match else default def as_dict(self) -> dict[str, str]: - return {k: self.get(k) or "" for k in self._index} + 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))}" - i = self._index.get(key) - if i is None: + index = self._index.get(key) + if index is None: self._lines.append(line) self._index[key] = len(self._lines) - 1 else: - self._lines[i] = line + self._lines[index] = line def merge(self, mapping: Mapping[str, str]) -> None: - for k, v in mapping.items(): - self.set(k, v) + for key, value in mapping.items(): + self.set(key, value) def remove(self, key: str) -> None: - i = self._index.pop(key, None) - if i is None: + index = self._index.pop(key, None) + if index is None: return - del self._lines[i] - self._index = {k: (n - 1 if n > i else n) for k, n in self._index.items()} + 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 [k for k in self._index if k.startswith(prefix + "_")]: + for key in [name for name in self._index if name.startswith(prefix + "_")]: self.remove(key) def save(self) -> None: diff --git a/services/http.py b/services/http.py index 03661e4..14d083b 100644 --- a/services/http.py +++ b/services/http.py @@ -21,27 +21,35 @@ def __init__( 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 + 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: - 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 + 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 e: - raise NetworkError(f"GET {url} failed: {e}", hint=_HINT, cause=e) from e + except requests.RequestException as error: + raise NetworkError( + f"GET {url} failed: {error}", hint=_HINT, cause=error + ) from error def download( self, @@ -53,27 +61,29 @@ def download( ) -> 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): + 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 - f.write(chunk) + file.write(chunk) written += len(chunk) if on_progress: on_progress(len(chunk)) - except requests.RequestException as e: + except requests.RequestException as error: dest.unlink(missing_ok=True) raise NetworkError( - f"Download of {url} failed: {e}", hint=_HINT, cause=e - ) from e + f"Download of {url} failed: {error}", hint=_HINT, cause=error + ) from error 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") + 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 index 7bff3fa..f95ee29 100644 --- a/services/ports.py +++ b/services/ports.py @@ -9,9 +9,9 @@ def __init__(self) -> None: 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] + 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 diff --git a/services/project.py b/services/project.py index 77b430f..c59989d 100644 --- a/services/project.py +++ b/services/project.py @@ -87,10 +87,12 @@ def load(cls, path: Path) -> AgentProject: 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 + 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(e, env) for e in entries if isinstance(e, dict)] + 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 @@ -108,20 +110,20 @@ def create( @property def managed(self) -> list[DatabaseSpec]: - return [d for d in self.databases if d.managed] + return [database for database in self.databases if database.managed] @property def needs_docker_socket(self) -> bool: - return any(d.engine == "docker-volume" for d in self.databases) + 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 d in self.databases: - if d.engine == "sqlite": - m = SqliteEngine.mount_for(d) - if m and m not in mounts: - mounts.append(m) + 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: @@ -132,12 +134,12 @@ def validate(self) -> None: hint=f"Path is resolved from {self.path}; use an absolute path otherwise.", ) seen: set[str] = set() - for d in self.managed: - if d.host in seen: + for database in self.managed: + if database.host in seen: raise ConfigError( - f"Two managed databases share the service name '{d.host}'." + f"Two managed databases share the service name '{database.host}'." ) - seen.add(d.host or "") + seen.add(database.host or "") def add(self, spec: DatabaseSpec, engine: DbEngine) -> None: if spec.managed: @@ -146,7 +148,9 @@ def add(self, spec: DatabaseSpec, engine: DbEngine) -> None: self.validate() def remove(self, spec: DatabaseSpec, engine: DbEngine) -> None: - self.databases = [d for d in self.databases if d.id != spec.id] + 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) @@ -159,7 +163,7 @@ def setting(self, name: str) -> Any: return setting.from_env(self.env.get(setting.env)) def settings(self) -> dict[str, Any]: - return {s.name: self.setting(s.name) for s in self.registry} + 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) @@ -180,9 +184,11 @@ def unset(self, name: str) -> None: @property def extra_env(self) -> list[str]: return [ - s.env - for s in self.registry - if s.env and not s.core and self.env.get(s.env) is not None + 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 @@ -201,9 +207,11 @@ def ca_bundle(self, host_path: str | None) -> None: 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 + 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( @@ -262,7 +270,7 @@ def setting(self, name: str) -> Any: return setting.from_env(self.env.get(setting.env or "")) def settings(self) -> dict[str, Any]: - return {s.name: self.setting(s.name) for s in self.registry} + 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) @@ -299,7 +307,7 @@ def providers(self) -> list[AuthProvider]: return providers def add_provider(self, provider: AuthProvider) -> None: - if any(p.id == provider.id for p in self.providers): + 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", @@ -315,7 +323,10 @@ def add_provider(self, provider: AuthProvider) -> None: self.env.set(f"{prefix}_{env_map[field_name]}", raw) def remove_provider(self, provider_id: str) -> AuthProvider: - match = next((p for p in self.providers if p.id == provider_id), None) + 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}'.", diff --git a/services/renderer.py b/services/renderer.py index ac0043a..8c69ae3 100644 --- a/services/renderer.py +++ b/services/renderer.py @@ -14,6 +14,7 @@ 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, @@ -46,10 +47,10 @@ class RenderResult: def validate(self) -> None: try: doc = yaml.safe_load(self.compose) - except yaml.YAMLError as e: + except yaml.YAMLError as error: raise TemplateError( - "Rendered compose is not valid YAML; templates are broken.", cause=e - ) from e + "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." @@ -100,7 +101,9 @@ def _atomic_write(path: Path, content: str) -> None: def _var(env: EnvFile, key: str, inline: bool) -> str: - return (env.get(key) or "") if inline else f"${{{key}}}" + if inline: + return escape_yaml_double_quoted(env.get(key) or "") + return f"${{{key}}}" class ComposeRenderer: @@ -121,7 +124,10 @@ def render_agent( ctx = { "host_gateway": project.host_gateway, "docker_socket": project.needs_docker_socket, - "mounts": [{"host": h, "container": c} for h, c in project.sqlite_mounts], + "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), @@ -136,7 +142,8 @@ def render_agent( } compose = self.header() + self._render("agent.yml.j2", ctx) databases = [ - self.engines.get(d.engine).agent_entry(d) for d in project.databases + self.engines.get(database.engine).agent_entry(database) + for database in project.databases ] return RenderResult(compose=compose, databases=databases) @@ -173,5 +180,7 @@ def _render(self, name: str, ctx: dict[str, Any]) -> str: 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 + 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 index d7a3002..fba5f65 100644 --- a/services/settings.py +++ b/services/settings.py @@ -79,7 +79,7 @@ def from_env(self, raw: str | None) -> Any: class Registry: def __init__(self, settings: tuple[Setting, ...], sections: dict[str, str]) -> None: self._settings = settings - self._by_name = {s.name: s for s in settings} + self._by_name = {setting.name: setting for setting in settings} self.sections = sections def __iter__(self) -> Iterator[Setting]: @@ -97,7 +97,7 @@ def get(self, name: str) -> Setting: ) from None def in_section(self, section: str) -> list[Setting]: - return [s for s in self._settings if s.section == section] + return [setting for setting in self._settings if setting.section == section] AGENT = Registry( diff --git a/services/templates.py b/services/templates.py index dbd5b6c..8607980 100644 --- a/services/templates.py +++ b/services/templates.py @@ -39,7 +39,7 @@ def resolve(self) -> Path: def names(self) -> list[str]: return sorted( - p.relative_to(self.root).as_posix() for p in self.root.rglob("*.j2") + path.relative_to(self.root).as_posix() for path in self.root.rglob("*.j2") ) def get(self, name: str) -> jinja2.Template: @@ -53,11 +53,11 @@ def get(self, name: str) -> jinja2.Template: ) try: return self._env.get_template(name) - except jinja2.TemplateNotFound as e: + except jinja2.TemplateNotFound as error: raise TemplateError( - f"Template '{name}' is missing from {root}.", cause=e - ) from e - except jinja2.TemplateError as e: + f"Template '{name}' is missing from {root}.", cause=error + ) from error + except jinja2.TemplateError as error: raise TemplateError( - f"Template '{name}' failed to load: {e}", cause=e - ) from e + f"Template '{name}' failed to load: {error}", cause=error + ) from error diff --git a/services/updater.py b/services/updater.py index 708ec6b..c18bc4e 100644 --- a/services/updater.py +++ b/services/updater.py @@ -34,7 +34,8 @@ 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", []) + asset["name"]: asset["browser_download_url"] + for asset in data.get("assets", []) }, prerelease=bool(data.get("prerelease", False)), ) @@ -98,8 +99,8 @@ def available(self, *, force: bool = False) -> str | None: def _read_cache(self) -> Release | None: try: - with open(self.cache_file, encoding="utf-8") as f: - data = json.load(f) + 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: @@ -115,7 +116,7 @@ def _read_cache(self) -> Release | 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: + with open(self.cache_file, "w", encoding="utf-8") as file: json.dump( { "checked_at": time.time(), @@ -124,7 +125,7 @@ def _write_cache(self, release: Release) -> None: "assets": release.assets, "prerelease": release.prerelease, }, - f, + file, ) except OSError: pass @@ -190,8 +191,8 @@ def _verify(self, release: Release, name: str, path: Path) -> None: 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""): + 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( @@ -218,5 +219,7 @@ def install(self, tmp: Path, target: Path) -> None: 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 + except (OSError, subprocess.CalledProcessError) as error: + raise UpdateError( + f"Could not install to {target}: {error}", cause=error + ) from error diff --git a/templates/dashboard.yml.j2 b/templates/dashboard.yml.j2 index aa20be5..75efd94 100644 --- a/templates/dashboard.yml.j2 +++ b/templates/dashboard.yml.j2 @@ -9,10 +9,10 @@ services: ports: - "{{ host_port_var }}:80" environment: - - TZ={{ tz_var }} - - LOG_LEVEL={{ log_level_var }} - - PROJECT_SECRET={{ project_secret_var }} - - PROJECT_URL={{ project_url_var }} + - "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" %} @@ -36,9 +36,9 @@ services: volumes: - postgres-data:/var/lib/postgresql/data environment: - - POSTGRES_DB={{ postgres_db_var }} - - POSTGRES_USER={{ postgres_user_var }} - - POSTGRES_PASSWORD={{ postgres_password_var }} + - "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 diff --git a/templates/engines/firebird.yml.j2 b/templates/engines/firebird.yml.j2 index f11f488..cfaac88 100644 --- a/templates/engines/firebird.yml.j2 +++ b/templates/engines/firebird.yml.j2 @@ -8,11 +8,11 @@ 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 + - "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 diff --git a/templates/engines/mariadb.yml.j2 b/templates/engines/mariadb.yml.j2 index 31c6c22..de59a3c 100644 --- a/templates/engines/mariadb.yml.j2 +++ b/templates/engines/mariadb.yml.j2 @@ -6,10 +6,10 @@ ports: - "{{ port_var }}:3306" environment: - - MYSQL_DATABASE={{ db_var }} - - MYSQL_USER={{ user_var }} - - MYSQL_PASSWORD={{ password_var }} - - MYSQL_RANDOM_ROOT_PASSWORD=yes + - "MYSQL_DATABASE={{ db_var }}" + - "MYSQL_USER={{ user_var }}" + - "MYSQL_PASSWORD={{ password_var }}" + - "MYSQL_RANDOM_ROOT_PASSWORD=yes" volumes: - {{ volume }}:/var/lib/mysql healthcheck: diff --git a/templates/engines/mongodb.yml.j2 b/templates/engines/mongodb.yml.j2 index c1e0e6e..0cd5023 100644 --- a/templates/engines/mongodb.yml.j2 +++ b/templates/engines/mongodb.yml.j2 @@ -7,10 +7,10 @@ - "{{ port_var }}:27017" environment: {%- if auth %} - - MONGO_INITDB_ROOT_USERNAME={{ user_var }} - - MONGO_INITDB_ROOT_PASSWORD={{ password_var }} + - "MONGO_INITDB_ROOT_USERNAME={{ user_var }}" + - "MONGO_INITDB_ROOT_PASSWORD={{ password_var }}" {%- endif %} - - MONGO_INITDB_DATABASE={{ db_var }} + - "MONGO_INITDB_DATABASE={{ db_var }}" {%- if auth %} command: mongod --auth {%- endif %} diff --git a/templates/engines/mssql.yml.j2 b/templates/engines/mssql.yml.j2 index 2855ea7..535623e 100644 --- a/templates/engines/mssql.yml.j2 +++ b/templates/engines/mssql.yml.j2 @@ -6,8 +6,8 @@ ports: - "{{ port_var }}:1433" environment: - - ACCEPT_EULA=Y - - MSSQL_SA_PASSWORD={{ password_var }} + - "ACCEPT_EULA=Y" + - "MSSQL_SA_PASSWORD={{ password_var }}" volumes: - {{ volume }}:/var/opt/mssql healthcheck: diff --git a/templates/engines/mysql.yml.j2 b/templates/engines/mysql.yml.j2 index 31c6c22..de59a3c 100644 --- a/templates/engines/mysql.yml.j2 +++ b/templates/engines/mysql.yml.j2 @@ -6,10 +6,10 @@ ports: - "{{ port_var }}:3306" environment: - - MYSQL_DATABASE={{ db_var }} - - MYSQL_USER={{ user_var }} - - MYSQL_PASSWORD={{ password_var }} - - MYSQL_RANDOM_ROOT_PASSWORD=yes + - "MYSQL_DATABASE={{ db_var }}" + - "MYSQL_USER={{ user_var }}" + - "MYSQL_PASSWORD={{ password_var }}" + - "MYSQL_RANDOM_ROOT_PASSWORD=yes" volumes: - {{ volume }}:/var/lib/mysql healthcheck: diff --git a/templates/engines/postgresql-cluster.yml.j2 b/templates/engines/postgresql-cluster.yml.j2 index 6dcf870..7006e9b 100644 --- a/templates/engines/postgresql-cluster.yml.j2 +++ b/templates/engines/postgresql-cluster.yml.j2 @@ -8,9 +8,9 @@ volumes: - {{ volume }}:/var/lib/postgresql/data environment: - - POSTGRES_DB={{ db_var }} - - POSTGRES_USER={{ user_var }} - - POSTGRES_PASSWORD={{ password_var }} + - "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 diff --git a/templates/engines/postgresql.yml.j2 b/templates/engines/postgresql.yml.j2 index 6dcf870..7006e9b 100644 --- a/templates/engines/postgresql.yml.j2 +++ b/templates/engines/postgresql.yml.j2 @@ -8,9 +8,9 @@ volumes: - {{ volume }}:/var/lib/postgresql/data environment: - - POSTGRES_DB={{ db_var }} - - POSTGRES_USER={{ user_var }} - - POSTGRES_PASSWORD={{ password_var }} + - "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 diff --git a/templates/engines/redis.yml.j2 b/templates/engines/redis.yml.j2 index 52a18ec..d7e55c8 100644 --- a/templates/engines/redis.yml.j2 +++ b/templates/engines/redis.yml.j2 @@ -7,7 +7,7 @@ - {{ volume }}:/data {%- if auth %} environment: - - REDIS_PASSWORD={{ password_var }} + - "REDIS_PASSWORD={{ password_var }}" command: ["redis-server", "--requirepass", "{{ password_var }}", "--appendonly", "yes"] {%- else %} command: ["redis-server", "--appendonly", "yes"] diff --git a/templates/engines/valkey.yml.j2 b/templates/engines/valkey.yml.j2 index 885d123..3867e35 100644 --- a/templates/engines/valkey.yml.j2 +++ b/templates/engines/valkey.yml.j2 @@ -2,10 +2,10 @@ image: valkey/valkey:latest restart: unless-stopped {%- if auth %} - command: --requirepass "{{ password_var }}" + command: ["valkey-server", "--requirepass", "{{ password_var }}"] {%- else %} environment: - - ALLOW_EMPTY_PASSWORD=yes + - "ALLOW_EMPTY_PASSWORD=yes" {%- endif %} ports: - "{{ port_var }}:6379" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f533a18 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest +import yaml + +from engines import registry +from services.ports import FixedPortAllocator +from services.project import AgentProject, DashboardProject +from services.renderer import ComposeRenderer +from services.templates import TemplateRepository +from tests.support import AGENT_ENV, DASHBOARD_MODES, ROOT, Rendered + +collect_ignore = ["conftest.py", "support.py"] + + +def pytest_pycollect_makeitem(collector, name, obj): + # Tests have no test_ prefix (python_functions = "[!_]*"): never collect a + # callable that a test module only imports (functions, lru_cache wrappers, + # pytest.mark decorators kept in constants). + if callable(obj) and getattr(obj, "__module__", None) != collector.module.__name__: + return [] + return None + + +@pytest.fixture +def ports() -> FixedPortAllocator: + return FixedPortAllocator() + + +@pytest.fixture +def templates() -> TemplateRepository: + return TemplateRepository(ROOT / "templates") + + +@pytest.fixture +def renderer(templates: TemplateRepository) -> ComposeRenderer: + return ComposeRenderer(templates, registry, "test") + + +@pytest.fixture +def agent(tmp_path: Path) -> AgentProject: + return AgentProject.create(tmp_path / "agent", dict(AGENT_ENV), host_gateway=False) + + +@pytest.fixture +def dashboard_for(tmp_path: Path) -> Callable[..., DashboardProject]: + def build(mode: str = "internal", **env: str) -> DashboardProject: + folder = tmp_path / f"dashboard-{len(list(tmp_path.iterdir()))}" + return DashboardProject.create(folder, {**DASHBOARD_MODES[mode], **env}) + + return build + + +@pytest.fixture +def dashboard(dashboard_for: Callable[..., DashboardProject]) -> DashboardProject: + return dashboard_for(PROJECT_URL="https://d.example") + + +@pytest.fixture +def render_engine( + tmp_path: Path, renderer: ComposeRenderer, ports: FixedPortAllocator +) -> Callable[..., Rendered]: + """Generate one database of an engine, add it to a fresh agent, render it.""" + + def render( + key: str, + *, + auth: bool = True, + inline: bool = False, + answers: dict[str, Any] | None = None, + ) -> Rendered: + engine = registry.get(key) + spec = engine.generate(auth=auth, ports=ports, answers=answers or {}) + folder = tmp_path / f"{key}-{len(list(tmp_path.iterdir()))}" + project = AgentProject.create(folder, dict(AGENT_ENV), host_gateway=False) + project.add(spec, engine) + result = renderer.render_agent(project, inline=inline) + result.validate() + return Rendered( + spec, + yaml.safe_load(result.compose), + project.env.as_dict(), + result.databases or [], + ) + + return render diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/config.py b/tests/core/config.py new file mode 100644 index 0000000..002d660 --- /dev/null +++ b/tests/core/config.py @@ -0,0 +1,38 @@ +import json + +import pytest + +from core.config import GlobalConfig + + +@pytest.fixture +def config(tmp_path): + return GlobalConfig(tmp_path / "home" / ".portabase" / "config.json") + + +def missing_file_reads_as_empty(config): + assert config.all() == {} + assert config.get("update_channel", "stable") == "stable" + assert config.update_channel is None + + +@pytest.mark.parametrize("content", ["{not json", "[1, 2]", ""]) +def unreadable_file_reads_as_empty(config, content): + config.path.parent.mkdir(parents=True) + config.path.write_text(content, encoding="utf-8") + assert config.all() == {} + + +def set_creates_the_file_and_keeps_other_keys(config): + config.set("update_channel", "beta") + config.set("other", 1) + assert json.loads(config.path.read_text(encoding="utf-8")) == { + "update_channel": "beta", + "other": 1, + } + assert config.update_channel == "beta" + assert not config.path.with_suffix(".json.tmp").exists() + + +def cache_dir_is_next_to_the_file(config): + assert config.cache_dir == config.path.parent / "cache" diff --git a/tests/core/crypto.py b/tests/core/crypto.py new file mode 100644 index 0000000..5e8f8e4 --- /dev/null +++ b/tests/core/crypto.py @@ -0,0 +1,135 @@ +import base64 +import json +import os +import struct + +import pytest + +from core.crypto import ( + DecryptionError, + decrypt_enc_file, + default_output_for, + load_master_key, +) +from tests.support import encrypt + +KEY = bytes(range(32)) +HEADER = {"cipher": "AES-256-GCM", "base_nonce": list(range(8)), "chunk_size": 4} + + +def _header(**overrides) -> bytes: + return json.dumps({**HEADER, **overrides}).encode() + b"\n" + + +@pytest.fixture +def paths(tmp_path): + return tmp_path / "backup.sql.enc", tmp_path / "out" / "backup.sql" + + +def load_master_key_raw(tmp_path): + path = tmp_path / "master_key.bin" + path.write_bytes(KEY) + assert load_master_key(path) == KEY + + +def load_master_key_base64(tmp_path): + path = tmp_path / "master_key.bin" + path.write_bytes(base64.b64encode(KEY) + b"\n") + assert load_master_key(path) == KEY + + +def load_master_key_defaults_to_cwd(tmp_path, monkeypatch): + (tmp_path / "master_key.bin").write_bytes(KEY) + monkeypatch.chdir(tmp_path) + assert load_master_key(None) == KEY + + +@pytest.mark.parametrize( + "content", [b"short", b"not base64 !!", base64.b64encode(b"x" * 16)] +) +def load_master_key_rejects_wrong_length(tmp_path, content): + path = tmp_path / "master_key.bin" + path.write_bytes(content) + with pytest.raises(DecryptionError, match="32-byte"): + load_master_key(path) + + +def load_master_key_missing(tmp_path): + with pytest.raises(DecryptionError, match="not found"): + load_master_key(tmp_path / "nope.bin") + + +def load_master_key_directory(tmp_path): + with pytest.raises(DecryptionError, match="not a file"): + load_master_key(tmp_path) + + +@pytest.mark.parametrize("plain", [b"", b"abc", b"exactly8", os.urandom(1000)]) +def decrypt_round_trip(paths, plain): + enc, out = paths + enc.write_bytes(encrypt(plain, KEY, chunk_size=4)) + decrypt_enc_file(enc, out, KEY) + assert out.read_bytes() == plain + assert not out.with_name(out.name + ".part").exists() + + +def decrypt_wrong_key_keeps_previous_output(paths): + enc, out = paths + enc.write_bytes(encrypt(b"secret data", KEY)) + out.parent.mkdir() + out.write_bytes(b"previous") + with pytest.raises(DecryptionError, match="Authentication failed on chunk 0"): + decrypt_enc_file(enc, out, bytes(32)) + assert out.read_bytes() == b"previous" + assert not out.with_name(out.name + ".part").exists() + + +def decrypt_detects_reordered_chunks(paths): + enc, out = paths + head, body = encrypt(b"aaaabbbb", KEY, chunk_size=4).split(b"\n", 1) + size = 4 + 4 + 16 + enc.write_bytes(head + b"\n" + body[size : 2 * size] + body[:size]) + with pytest.raises(DecryptionError, match="Authentication failed"): + decrypt_enc_file(enc, out, KEY) + assert not out.exists() + + +@pytest.mark.parametrize( + ("content", "message"), + [ + (b"", "missing header"), + (b"nope\n", "Invalid or missing JSON header"), + (_header(cipher="AES-128-CBC"), "Unsupported cipher"), + (_header(base_nonce=[1, 2]), "Invalid base_nonce length"), + (_header() + b"\x00\x00", "Truncated chunk length prefix"), + (_header() + struct.pack(">I", 4) + b"1234", "smaller than the 16-byte tag"), + (_header() + struct.pack(">I", 1000), "exceeds the maximum 20 bytes"), + (_header() + struct.pack(">I", 20) + b"short", "Truncated chunk 0"), + ], +) +def decrypt_rejects_corrupt_files(paths, content, message): + enc, out = paths + enc.write_bytes(content) + with pytest.raises(DecryptionError, match=message): + decrypt_enc_file(enc, out, KEY) + assert not out.exists() + assert not out.with_name(out.name + ".part").exists() + + +def decrypt_truncated_last_chunk(paths): + enc, out = paths + enc.write_bytes(encrypt(b"abcdefgh", KEY)[:-1]) + with pytest.raises(DecryptionError, match="Truncated chunk 1"): + decrypt_enc_file(enc, out, KEY) + + +@pytest.mark.parametrize( + ("name", "expected"), + [("dump.sql.enc", "dump.sql"), ("dump.enc", "dump"), ("dump.sql", "dump.sql.dec")], +) +def default_output_name(tmp_path, name, expected): + assert default_output_for(tmp_path / name) == expected + + +def decryption_error_exit_code(): + assert (DecryptionError.code, DecryptionError.exit_code) == ("E_CRYPTO", 8) diff --git a/tests/core/errors.py b/tests/core/errors.py new file mode 100644 index 0000000..3aee3eb --- /dev/null +++ b/tests/core/errors.py @@ -0,0 +1,53 @@ +import pytest + +from core import errors +from core.crypto import DecryptionError + +CODES = [ + (errors.PortabaseError, "E_GENERIC", 1), + (errors.ValidationError, "E_VALIDATION", 2), + (errors.ConfigError, "E_CONFIG", 3), + (errors.DockerError, "E_DOCKER", 4), + (errors.TemplateError, "E_TEMPLATE", 5), + (errors.NetworkError, "E_NETWORK", 6), + (errors.UpdateError, "E_UPDATE", 7), + (DecryptionError, "E_CRYPTO", 8), + (errors.UserAbort, "E_ABORT", 130), +] + + +@pytest.mark.parametrize( + ("cls", "code", "exit_code"), CODES, ids=[case[0].__name__ for case in CODES] +) +def codes_and_exit_codes(cls, code, exit_code): + assert issubclass(cls, errors.PortabaseError) + assert (cls.code, cls.exit_code) == (code, exit_code) + + +def exit_codes_are_unique(): + exit_codes = [exit_code for _, _, exit_code in CODES] + assert len(exit_codes) == len(set(exit_codes)) + + +def message_hint_and_cause(): + cause = ValueError("boom") + error = errors.ValidationError("Bad input.", hint="Try again.", cause=cause) + assert str(error) == "Bad input." + assert (error.message, error.hint, error.cause) == ( + "Bad input.", + "Try again.", + cause, + ) + assert error.__cause__ is cause + + +def no_hint_and_no_cause_by_default(): + error = errors.ConfigError("Broken.") + assert error.hint is None + assert error.cause is None + assert error.__cause__ is None + + +def user_abort_default_message(): + assert str(errors.UserAbort()) == "Canceled." + assert errors.UserAbort(hint="Run it again.").hint == "Run it again." diff --git a/tests/core/fields.py b/tests/core/fields.py new file mode 100644 index 0000000..2f53258 --- /dev/null +++ b/tests/core/fields.py @@ -0,0 +1,29 @@ +import dataclasses + +import pytest + +from core.fields import Field + + +def defaults(): + field = Field("name", "Prompt") + assert (field.kind, field.default, field.choices, field.help, field.validator) == ( + "text", + None, + (), + None, + None, + ) + + +@pytest.mark.parametrize( + ("name", "flag"), + [("key", "--key"), ("retry_attempts", "--retry-attempts"), ("a_b_c", "--a-b-c")], +) +def flag_uses_dashes(name, flag): + assert Field(name, "Prompt").flag == flag + + +def is_frozen(): + with pytest.raises(dataclasses.FrozenInstanceError): + Field("a", "A").name = "b" diff --git a/tests/core/specs.py b/tests/core/specs.py new file mode 100644 index 0000000..858e9aa --- /dev/null +++ b/tests/core/specs.py @@ -0,0 +1,33 @@ +import pytest + +from core.specs import DatabaseSpec + + +def _spec(**kwargs): + return DatabaseSpec(id="id-1", engine="postgresql", name="db", **kwargs) + + +def env_prefix_uppercases_and_replaces_dashes(): + assert _spec(host="db-pg-ab12").env_prefix == "DB_PG_AB12" + + +def env_prefix_requires_host(): + with pytest.raises(ValueError, match="host"): + _ = _spec().env_prefix + + +@pytest.mark.parametrize( + ("password", "expected"), [("s3cret", True), ("", False), (None, False)] +) +def auth_follows_password(password, expected): + assert _spec(password=password).auth is expected + + +def with_options_returns_a_copy(): + original = _spec(options={"a": 1}) + options = {"b": 2} + updated = original.with_options(options) + options["c"] = 3 + assert updated.options == {"b": 2} + assert original.options == {"a": 1} + assert updated.id == original.id diff --git a/tests/core/utils.py b/tests/core/utils.py new file mode 100644 index 0000000..224b271 --- /dev/null +++ b/tests/core/utils.py @@ -0,0 +1,99 @@ +import base64 +import json +import string + +import pytest + +from core.utils import ( + escape_yaml_double_quoted, + generate_password, + slugify_project_name, + validate_edge_key, +) +from tests.support import EDGE_KEY_PAYLOAD + +SYMBOLS = "!@#%^&*()-_=+[]{}|;:,.<>?" + + +@pytest.mark.parametrize( + ("length", "expected"), [(16, 16), (8, 8), (40, 40), (7, 8), (0, 8)] +) +def generate_password_length(length, expected): + assert len(generate_password(length)) == expected + + +def generate_password_has_every_character_class(): + for _ in range(100): + password = generate_password(8) + assert any(char in string.ascii_lowercase for char in password) + assert any(char in string.ascii_uppercase for char in password) + assert any(char in string.digits for char in password) + assert any(char in SYMBOLS for char in password) + + +def generate_password_is_random(): + assert len({generate_password() for _ in range(20)}) == 20 + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("my-agent", "my-agent"), + ("My Agent", "my-agent"), + ("--Prod_DB--", "prod_db"), + ("_-x", "x"), + ("été 2026", "t-2026"), + ("a b", "a-b"), + ("!!!", "portabase"), + ("", "portabase"), + ], +) +def slugify_project_name_cases(value, expected): + assert slugify_project_name(value) == expected + + +def slugify_project_name_fallback(): + assert slugify_project_name("***", fallback="agent") == "agent" + + +def validate_edge_key_accepts_base64_json(): + key = base64.b64encode(json.dumps(EDGE_KEY_PAYLOAD).encode()).decode() + assert validate_edge_key(key) + + +def validate_edge_key_accepts_raw_json(): + assert validate_edge_key(json.dumps(EDGE_KEY_PAYLOAD)) + + +@pytest.mark.parametrize("missing", ["serverUrl", "agentId", "masterKeyB64"]) +def validate_edge_key_rejects_missing_field(missing): + payload = {key: value for key, value in EDGE_KEY_PAYLOAD.items() if key != missing} + key = base64.b64encode(json.dumps(payload).encode()).decode() + assert not validate_edge_key(key) + + +@pytest.mark.parametrize( + "key", + ["", "not a key", "1234", base64.b64encode(b"hello").decode(), "{broken json"], +) +def validate_edge_key_rejects_garbage(key): + assert not validate_edge_key(key) + + +@pytest.mark.parametrize( + "data", + [list(EDGE_KEY_PAYLOAD), "serverUrl agentId masterKeyB64", None, 42], + ids=["list", "string", "null", "number"], +) +def validate_edge_key_requires_an_object(data): + raw = json.dumps(data) + assert not validate_edge_key(raw) + assert not validate_edge_key(base64.b64encode(raw.encode()).decode()) + + +@pytest.mark.parametrize( + ("value", "expected"), + [("plain", "plain"), ('a"b', 'a\\"b'), ("a\\b", "a\\\\b"), ('\\"', '\\\\\\"')], +) +def escape_yaml_double_quoted_cases(value, expected): + assert escape_yaml_double_quoted(value) == expected diff --git a/tests/core/version.py b/tests/core/version.py new file mode 100644 index 0000000..4e4d019 --- /dev/null +++ b/tests/core/version.py @@ -0,0 +1,99 @@ +import re +import tomllib + +import pytest + +from core.version import current_version, is_prerelease, parse_version +from tests.support import ROOT + +ZERO = (0, 0, 0, 0, 0, 0) + + +def current_version_reads_pyproject(): + with open(ROOT / "pyproject.toml", "rb") as file: + expected = tomllib.load(file)["project"]["version"] + assert current_version() == expected + + +@pytest.mark.parametrize( + ("version", "expected"), + [ + ("26.08.12", (26, 8, 12, 3, 0, 0)), + ("v1.2.3", (1, 2, 3, 3, 0, 0)), + (" 1.2.3 ", (1, 2, 3, 3, 0, 0)), + ("1.2.3rc1", (1, 2, 3, 2, 1, 0)), + ("1.2.3-rc2", (1, 2, 3, 2, 2, 0)), + ("1.2.3.beta4", (1, 2, 3, 1, 4, 0)), + ("1.2.3b", (1, 2, 3, 1, 0, 0)), + ("1.2.3-alpha", (1, 2, 3, 0, 0, 0)), + ("1.2.3a7", (1, 2, 3, 0, 7, 0)), + ("1.2.3RC1", (1, 2, 3, 2, 1, 0)), + ("1.2.3-beta.2", (1, 2, 3, 1, 2, 0)), + ("26.09.0rc1.2", (26, 9, 0, 2, 1, 2)), + ("unknown", ZERO), + ("1.2", ZERO), + ("1.2.3.4", ZERO), + ], +) +def parse_version_cases(version, expected): + assert parse_version(version) == expected + + +@pytest.mark.parametrize( + ("older", "newer"), + [ + ("1.0.0-alpha1", "1.0.0-beta1"), + ("1.0.0-beta9", "1.0.0rc1"), + ("1.0.0-beta.1", "1.0.0-beta.2"), + ("1.0.0-beta.3", "1.0.0rc1"), + ("1.0.0rc1", "1.0.0rc1.1"), + ("1.0.0rc1.9", "1.0.0rc2"), + ("1.0.0rc9", "1.0.0"), + ("1.0.9", "1.0.10"), + ("26.08.12", "26.09.0"), + ], +) +def parse_version_ordering(older, newer): + assert parse_version(older) < parse_version(newer) + + +@pytest.mark.parametrize( + ("version", "expected"), + [ + ("1.0.0", False), + ("v1.0.0", False), + ("1.0.0rc1", True), + ("1.0.0-beta", True), + ("1.0.0-beta.1", True), + ("26.09.0rc1.2", True), + ("garbage", False), + ], +) +def is_prerelease_cases(version, expected): + assert is_prerelease(version) is expected + + +def _bump_patterns(): + workflow = (ROOT / ".github" / "workflows" / "bump.yml").read_text(encoding="utf-8") + patterns = re.findall(r"=~ (\^\S+\$) \]\]", workflow) + assert len(patterns) == 2, "bump.yml version checks changed; update this test" + return patterns + + +@pytest.mark.parametrize( + "version", + [ + "26.09.0", + "26.09.0rc1", + "26.09.0rc1.2", + "26.09.0-beta.1", + "26.09.0.alpha3", + "26.09.0b", + "26.09", + "26.09.0-dev1", + ], +) +def bump_versions_are_understood(version): + if any(re.fullmatch(pattern, version) for pattern in _bump_patterns()): + assert parse_version(version) != ZERO + assert is_prerelease(version) is not re.fullmatch(r"\d+\.\d+\.\d+", version) diff --git a/tests/engines/__init__.py b/tests/engines/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/engines/base.py b/tests/engines/base.py new file mode 100644 index 0000000..83a38bb --- /dev/null +++ b/tests/engines/base.py @@ -0,0 +1,106 @@ +import json +import re + +import pytest + +from core.specs import DatabaseSpec +from engines import registry +from engines.base import DbEngine, StandardSqlEngine +from services.envfile import EnvFile +from services.project import spec_from_entry + +MANAGED = [engine for engine in registry if engine.template is not None] +VARIANTS = [ + pytest.param(engine, auth, id=f"{engine.key}-{'auth' if auth else 'noauth'}") + for engine in MANAGED + for auth in ((True, False) if engine.auth_variants else (True,)) +] + + +@pytest.mark.parametrize(("engine", "auth"), VARIANTS) +def generate_is_unique(engine, auth, ports): + first = engine.generate(auth=auth, ports=ports, answers={}) + second = engine.generate(auth=auth, ports=ports, answers={}) + assert first.id != second.id + assert first.host != second.host + assert first.host_port != second.host_port + + +@pytest.mark.parametrize(("engine", "auth"), VARIANTS) +def env_vars_are_prefixed_strings(engine, auth, ports): + spec = engine.generate(auth=auth, ports=ports, answers={}) + env = engine.env_vars(spec) + assert env[f"{spec.env_prefix}_PORT"] == str(spec.host_port) + assert all(key.startswith(spec.env_prefix + "_") for key in env) + assert all(isinstance(value, str) for value in env.values()) + + +@pytest.mark.parametrize(("engine", "auth"), VARIANTS) +def template_ctx_points_at_env_vars(engine, auth, ports): + spec = engine.generate(auth=auth, ports=ports, answers={}) + ctx = engine.template_ctx(spec) + assert (ctx["name"], ctx["volume"], ctx["auth"]) == ( + spec.host, + f"{spec.host}-data", + auth, + ) + for key in ("port_var", "db_var", "user_var", "password_var"): + assert re.fullmatch(rf"\$\{{{spec.env_prefix}_[A-Z_]+\}}", ctx[key]) + + +@pytest.mark.parametrize(("engine", "auth"), VARIANTS) +def agent_entry_survives_databases_json(engine, auth, ports, tmp_path): + spec = engine.generate(auth=auth, ports=ports, answers={}) + entry = json.loads(json.dumps(engine.agent_entry(spec))) + env = EnvFile(tmp_path / ".env") + env.merge(engine.env_vars(spec)) + loaded = spec_from_entry(entry, env) + assert loaded.managed + assert (loaded.id, loaded.engine, loaded.host, loaded.port, loaded.host_port) == ( + spec.id, + spec.engine, + spec.host, + spec.port, + spec.host_port, + ) + assert (loaded.password, loaded.root_password) == ( + spec.password, + spec.root_password, + ) + + +def service_name_format(): + assert re.fullmatch(r"db-pg-[0-9a-f]{4}", DbEngine.service_name("pg")) + assert re.fullmatch( + r"db-pg-auth-[0-9a-f]{4}", DbEngine.service_name("pg", auth=True) + ) + + +def var_references_or_escapes(): + spec = DatabaseSpec(id="1", engine="postgresql", name="n", host="db-pg-ab12") + assert DbEngine.var(spec, "PASS", "x", inline=False) == "${DB_PG_AB12_PASS}" + assert DbEngine.var(spec, "PASS", 'a"b\\c', inline=True) == 'a\\"b\\\\c' + assert DbEngine.var(spec, "PASS", None, inline=True) == "" + assert DbEngine.var(spec, "PORT", 5432, inline=True) == "5432" + + +def engine_without_required_attributes_is_refused(): + with pytest.raises(TypeError, match="missing required engine attribute"): + + class Broken(StandardSqlEngine): + key, display = "broken", "Broken" + + +def abstract_engine_may_be_incomplete(): + class Base(DbEngine): + abstract = True + + assert Base.abstract + + +def template_must_live_under_engines(): + with pytest.raises(TypeError, match="must be a path under 'engines/'"): + + class Misplaced(StandardSqlEngine): + key, display, default_port = "misplaced", "Misplaced", 1 + template, slug, db_prefix = "misplaced.yml.j2", "m", "m" diff --git a/tests/engines/docker_volume.py b/tests/engines/docker_volume.py new file mode 100644 index 0000000..f2123eb --- /dev/null +++ b/tests/engines/docker_volume.py @@ -0,0 +1,84 @@ +from core.specs import DatabaseSpec +from engines import registry +from engines.docker_volume import DockerVolumeEngine +from tests.support import agent_service, field_specs + +VOLUME = registry.get("docker-volume") +SOCKET = "/var/run/docker.sock:/var/run/docker.sock" + + +def attributes(): + assert type(VOLUME) is DockerVolumeEngine + assert (VOLUME.key, VOLUME.display, VOLUME.default_port) == ( + "docker-volume", + "Docker Volume", + None, + ) + assert VOLUME.template is None + assert (VOLUME.auth_variants, VOLUME.has_modes) == (False, False) + assert "/var/run/docker.sock" in (VOLUME.warning or "") + + +def generate(ports): + answers = {"volume": " data ", "container": "app", "label": "Files"} + spec = VOLUME.generate(auth=False, ports=ports, answers=answers) + assert spec == DatabaseSpec( + id=spec.id, + engine="docker-volume", + name="Files", + volume="data", + container="app", + ) + assert VOLUME.describe(spec) == "volume: data" + + +def env_vars(): + assert VOLUME.env_vars(VOLUME.from_existing({"volume": "v"})) == {} + + +def agent_entry(): + bare = VOLUME.from_existing({"volume": "v"}) + with_container = VOLUME.from_existing({"volume": "v", "container": "app"}) + assert VOLUME.agent_entry(bare) == { + "name": "Docker Volume", + "type": "docker-volume", + "volume_name": "v", + "generated_id": bare.id, + } + assert VOLUME.agent_entry(with_container) == { + "name": "Docker Volume", + "type": "docker-volume", + "volume_name": "v", + "container_name": "app", + "generated_id": with_container.id, + } + + +def fields(): + assert field_specs(VOLUME.fields_existing()) == [ + ("volume", "text", None), + ("container", "text", ""), + ] + assert VOLUME.fields_new() == VOLUME.fields_existing() + assert VOLUME.option_fields() == [] + + +def from_existing(): + spec = VOLUME.from_existing({"volume": " data ", "container": " "}) + assert spec == DatabaseSpec( + id=spec.id, engine="docker-volume", name="Docker Volume", volume="data" + ) + + +def compose_service(render_engine): + rendered = render_engine( + "docker-volume", answers={"volume": "v", "container": "app"} + ) + assert rendered.doc["services"] == {"agent": agent_service(SOCKET)} + assert "volumes" not in rendered.doc + assert rendered.databases == [VOLUME.agent_entry(rendered.spec)] + + +def compose_service_inline(render_engine): + rendered = render_engine("docker-volume", inline=True, answers={"volume": "v"}) + assert rendered.doc["services"] == {"agent": agent_service(SOCKET, inline=True)} diff --git a/tests/engines/firebird.py b/tests/engines/firebird.py new file mode 100644 index 0000000..6431bee --- /dev/null +++ b/tests/engines/firebird.py @@ -0,0 +1,145 @@ +import re + +from core.specs import DatabaseSpec +from engines import registry +from engines.firebird import FirebirdEngine +from tests.support import EXISTING_ANSWERS, field_specs + +FIREBIRD = registry.get("firebird") +HEALTH = {"interval": "10s", "timeout": "5s", "retries": 5} + + +def attributes(): + assert type(FIREBIRD) is FirebirdEngine + assert (FIREBIRD.key, FIREBIRD.display, FIREBIRD.default_port) == ( + "firebird", + "Firebird", + 3050, + ) + assert FIREBIRD.template == "engines/firebird.yml.j2" + assert (FIREBIRD.auth_variants, FIREBIRD.has_modes, FIREBIRD.warning) == ( + False, + True, + None, + ) + + +def generate(ports): + spec = FIREBIRD.generate(auth=True, ports=ports, answers={}) + assert re.fullmatch(r"db-firebird-[0-9a-f]{4}", spec.host or "") + assert len(spec.password or "") == 16 + assert len(spec.root_password or "") == 16 + assert spec.password != spec.root_password + assert spec == DatabaseSpec( + id=spec.id, + engine="firebird", + name="mirror.fdb", + managed=True, + host=spec.host, + port=3050, + host_port=40000, + database="/var/lib/firebird/data/mirror.fdb", + username="alice", + password=spec.password, + root_password=spec.root_password, + ) + assert FIREBIRD.describe(spec) == f"{spec.host}:3050" + + +def env_vars(ports): + spec = FIREBIRD.generate(auth=True, ports=ports, answers={}) + prefix = spec.env_prefix + assert FIREBIRD.env_vars(spec) == { + f"{prefix}_PORT": "40000", + f"{prefix}_DB": "mirror.fdb", + f"{prefix}_USER": "alice", + f"{prefix}_PASS": spec.password, + f"{prefix}_ROOT_PASS": spec.root_password, + } + + +def agent_entry(ports): + spec = FIREBIRD.generate(auth=True, ports=ports, answers={}) + assert FIREBIRD.agent_entry(spec) == { + "name": "mirror.fdb", + "database": "/var/lib/firebird/data/mirror.fdb", + "type": "firebird", + "username": "alice", + "password": spec.password, + "port": 3050, + "host": spec.host, + "generated_id": spec.id, + } + + +def fields(): + assert field_specs(FIREBIRD.fields_existing()) == [ + ("host", "text", "localhost"), + ("port", "int", 3050), + ("database", "text", None), + ("username", "text", None), + ("password", "secret", None), + ] + assert FIREBIRD.fields_new() == [] + assert FIREBIRD.option_fields() == [] + + +def from_existing(): + spec = FIREBIRD.from_existing({**EXISTING_ANSWERS, "label": "Prod"}) + assert spec == DatabaseSpec( + id=spec.id, + engine="firebird", + name="Prod", + host="db.example", + port=1234, + database="app", + username="u", + password="p", + ) + assert FIREBIRD.from_existing(EXISTING_ANSWERS).name == "External DB" + + +def compose_service(render_engine): + rendered = render_engine("firebird") + assert rendered.service == { + "image": "firebirdsql/firebird", + "restart": "unless-stopped", + "networks": ["portabase"], + "ports": [f"{rendered.var('PORT')}:3050"], + "volumes": [f"{rendered.spec.host}-data:/var/lib/firebird/data"], + "environment": [ + f"FIREBIRD_DATABASE={rendered.var('DB')}", + f"FIREBIRD_USER={rendered.var('USER')}", + f"FIREBIRD_PASSWORD={rendered.var('PASS')}", + f"FIREBIRD_ROOT_PASSWORD={rendered.var('ROOT_PASS')}", + "FIREBIRD_DATABASE_DEFAULT_CHARSET=UTF8", + ], + "healthcheck": {"test": ["CMD-SHELL", "nc -z localhost 3050"], **HEALTH}, + } + + +def compose_service_inline(render_engine): + rendered = render_engine("firebird", inline=True) + assert rendered.service["ports"] == ["40000:3050"] + assert rendered.service["environment"] == [ + "FIREBIRD_DATABASE=mirror.fdb", + "FIREBIRD_USER=alice", + f"FIREBIRD_PASSWORD={rendered.spec.password}", + f"FIREBIRD_ROOT_PASSWORD={rendered.spec.root_password}", + "FIREBIRD_DATABASE_DEFAULT_CHARSET=UTF8", + ] + + +def template_ctx_uses_the_file_name(ports): + spec = FIREBIRD.generate(auth=True, ports=ports, answers={}) + prefix = spec.env_prefix + ctx = FIREBIRD.template_ctx(spec) + inline = FIREBIRD.template_ctx(spec, inline=True) + assert (ctx["db_var"], ctx["root_password_var"]) == ( + f"${{{prefix}_DB}}", + f"${{{prefix}_ROOT_PASS}}", + ) + assert (inline["db_var"], inline["root_password_var"]) == ( + "mirror.fdb", + spec.root_password, + ) diff --git a/tests/engines/mariadb.py b/tests/engines/mariadb.py new file mode 100644 index 0000000..a3e53a2 --- /dev/null +++ b/tests/engines/mariadb.py @@ -0,0 +1,131 @@ +import re + +from core.specs import DatabaseSpec +from engines import registry +from engines.mariadb import MariaDbEngine +from tests.support import EXISTING_ANSWERS, field_specs + +MARIADB = registry.get("mariadb") +HEALTH = {"interval": "10s", "timeout": "5s", "retries": 5} + + +def attributes(): + assert type(MARIADB) is MariaDbEngine + assert (MARIADB.key, MARIADB.display, MARIADB.default_port) == ( + "mariadb", + "MariaDB", + 3306, + ) + assert MARIADB.template == "engines/mariadb.yml.j2" + assert (MARIADB.auth_variants, MARIADB.has_modes, MARIADB.warning) == ( + False, + True, + None, + ) + + +def generate(ports): + spec = MARIADB.generate(auth=True, ports=ports, answers={}) + assert re.fullmatch(r"db-mariadb-[0-9a-f]{4}", spec.host or "") + assert re.fullmatch(r"mysql_[0-9a-f]{8}", spec.database or "") + assert len(spec.password or "") == 16 + assert spec == DatabaseSpec( + id=spec.id, + engine="mariadb", + name=spec.database or "", + managed=True, + host=spec.host, + port=3306, + host_port=40000, + database=spec.database, + username="admin", + password=spec.password, + ) + assert MARIADB.describe(spec) == f"{spec.host}:3306" + + +def env_vars(ports): + spec = MARIADB.generate(auth=True, ports=ports, answers={}) + prefix = spec.env_prefix + assert MARIADB.env_vars(spec) == { + f"{prefix}_PORT": "40000", + f"{prefix}_DB": spec.database, + f"{prefix}_USER": "admin", + f"{prefix}_PASS": spec.password, + } + + +def agent_entry(ports): + spec = MARIADB.generate(auth=True, ports=ports, answers={}) + assert MARIADB.agent_entry(spec) == { + "name": spec.name, + "database": spec.database, + "type": "mariadb", + "username": "admin", + "password": spec.password, + "port": 3306, + "host": spec.host, + "generated_id": spec.id, + } + + +def fields(): + assert field_specs(MARIADB.fields_existing()) == [ + ("host", "text", "localhost"), + ("port", "int", 3306), + ("database", "text", None), + ("username", "text", None), + ("password", "secret", None), + ] + assert MARIADB.fields_new() == [] + assert MARIADB.option_fields() == [] + + +def from_existing(): + spec = MARIADB.from_existing({**EXISTING_ANSWERS, "label": "Prod"}) + assert spec == DatabaseSpec( + id=spec.id, + engine="mariadb", + name="Prod", + host="db.example", + port=1234, + database="app", + username="u", + password="p", + ) + assert MARIADB.from_existing(EXISTING_ANSWERS).name == "External DB" + + +def compose_service(render_engine): + rendered = render_engine("mariadb") + assert rendered.service == { + "image": "mariadb:latest", + "restart": "unless-stopped", + "networks": ["portabase"], + "ports": [f"{rendered.var('PORT')}:3306"], + "environment": [ + f"MYSQL_DATABASE={rendered.var('DB')}", + f"MYSQL_USER={rendered.var('USER')}", + f"MYSQL_PASSWORD={rendered.var('PASS')}", + "MYSQL_RANDOM_ROOT_PASSWORD=yes", + ], + "volumes": [f"{rendered.spec.host}-data:/var/lib/mysql"], + "healthcheck": { + "test": [ + "CMD-SHELL", + f"mariadb-admin ping -h localhost -u {rendered.var('USER')} -p{rendered.var('PASS')}", + ], + **HEALTH, + }, + } + + +def compose_service_inline(render_engine): + rendered = render_engine("mariadb", inline=True) + assert rendered.service["ports"] == ["40000:3306"] + assert rendered.service["environment"] == [ + f"MYSQL_DATABASE={rendered.spec.database}", + "MYSQL_USER=admin", + f"MYSQL_PASSWORD={rendered.spec.password}", + "MYSQL_RANDOM_ROOT_PASSWORD=yes", + ] diff --git a/tests/engines/mongodb.py b/tests/engines/mongodb.py new file mode 100644 index 0000000..dce63a4 --- /dev/null +++ b/tests/engines/mongodb.py @@ -0,0 +1,132 @@ +import re + +import pytest + +from core.specs import DatabaseSpec +from engines import registry +from engines.mongodb import MongoEngine +from tests.support import EXISTING_ANSWERS, field_specs + +MONGO = registry.get("mongodb") +HEALTH = {"interval": "10s", "timeout": "5s", "retries": 5} +AUTH = pytest.mark.parametrize("auth", [True, False], ids=["auth", "noauth"]) + + +def attributes(): + assert type(MONGO) is MongoEngine + assert (MONGO.key, MONGO.display, MONGO.default_port) == ( + "mongodb", + "MongoDB", + 27017, + ) + assert MONGO.template == "engines/mongodb.yml.j2" + assert (MONGO.auth_variants, MONGO.has_modes, MONGO.warning) == (True, True, None) + + +@AUTH +def generate(ports, auth): + spec = MONGO.generate(auth=auth, ports=ports, answers={}) + suffix = "auth-" if auth else "" + assert re.fullmatch(rf"db-mongo-{suffix}[0-9a-f]{{4}}", spec.host or "") + assert re.fullmatch(r"mongo_[0-9a-f]{8}", spec.database or "") + assert len(spec.password or "") == (16 if auth else 0) + assert spec == DatabaseSpec( + id=spec.id, + engine="mongodb", + name=spec.database or "", + managed=True, + host=spec.host, + port=27017, + host_port=40000, + database=spec.database, + username="admin" if auth else "", + password=spec.password, + ) + assert MONGO.describe(spec) == f"{spec.host}:27017" + + +@AUTH +def env_vars(ports, auth): + spec = MONGO.generate(auth=auth, ports=ports, answers={}) + prefix = spec.env_prefix + expected = {f"{prefix}_PORT": "40000", f"{prefix}_DB": spec.database} + if auth: + expected |= {f"{prefix}_USER": "admin", f"{prefix}_PASS": spec.password} + assert MONGO.env_vars(spec) == expected + + +@AUTH +def agent_entry(ports, auth): + spec = MONGO.generate(auth=auth, ports=ports, answers={}) + assert MONGO.agent_entry(spec) == { + "name": spec.name, + "database": spec.database, + "type": "mongodb", + "username": "admin" if auth else "", + "password": spec.password or "", + "port": 27017, + "host": spec.host, + "generated_id": spec.id, + } + + +def fields(): + assert field_specs(MONGO.fields_existing()) == [ + ("host", "text", "localhost"), + ("port", "int", 27017), + ("database", "text", None), + ("username", "text", None), + ("password", "secret", None), + ] + assert MONGO.fields_new() == [] + assert MONGO.option_fields() == [] + + +def from_existing(): + spec = MONGO.from_existing({**EXISTING_ANSWERS, "label": "Prod"}) + assert spec == DatabaseSpec( + id=spec.id, + engine="mongodb", + name="Prod", + host="db.example", + port=1234, + database="app", + username="u", + password="p", + ) + assert MONGO.from_existing(EXISTING_ANSWERS).name == "External DB" + + +@AUTH +def compose_service(render_engine, auth): + rendered = render_engine("mongodb", auth=auth) + expected = { + "image": "mongo:latest", + "restart": "unless-stopped", + "networks": ["portabase"], + "ports": [f"{rendered.var('PORT')}:27017"], + "environment": [f"MONGO_INITDB_DATABASE={rendered.var('DB')}"], + "volumes": [f"{rendered.spec.host}-data:/data/db"], + "healthcheck": { + "test": ["CMD-SHELL", "mongosh --eval 'db.runCommand({ping:1})' --quiet"], + **HEALTH, + }, + } + if auth: + expected["environment"] = [ + f"MONGO_INITDB_ROOT_USERNAME={rendered.var('USER')}", + f"MONGO_INITDB_ROOT_PASSWORD={rendered.var('PASS')}", + f"MONGO_INITDB_DATABASE={rendered.var('DB')}", + ] + expected["command"] = "mongod --auth" + assert rendered.service == expected + + +def compose_service_inline(render_engine): + rendered = render_engine("mongodb", auth=True, inline=True) + assert rendered.service["ports"] == ["40000:27017"] + assert rendered.service["environment"] == [ + "MONGO_INITDB_ROOT_USERNAME=admin", + f"MONGO_INITDB_ROOT_PASSWORD={rendered.spec.password}", + f"MONGO_INITDB_DATABASE={rendered.spec.database}", + ] diff --git a/tests/engines/mssql.py b/tests/engines/mssql.py new file mode 100644 index 0000000..f749e82 --- /dev/null +++ b/tests/engines/mssql.py @@ -0,0 +1,120 @@ +import re + +from core.specs import DatabaseSpec +from engines import registry +from engines.mssql import MssqlEngine +from tests.support import EXISTING_ANSWERS, field_specs + +MSSQL = registry.get("mssql") + + +def attributes(): + assert type(MSSQL) is MssqlEngine + assert (MSSQL.key, MSSQL.display, MSSQL.default_port) == ( + "mssql", + "Microsoft SQL Server", + 1433, + ) + assert MSSQL.template == "engines/mssql.yml.j2" + assert (MSSQL.auth_variants, MSSQL.has_modes, MSSQL.warning) == (False, True, None) + + +def generate(ports): + spec = MSSQL.generate(auth=True, ports=ports, answers={}) + assert re.fullmatch(r"db-mssql-[0-9a-f]{4}", spec.host or "") + assert len(spec.password or "") == 16 + assert spec == DatabaseSpec( + id=spec.id, + engine="mssql", + name="MSSQL", + managed=True, + host=spec.host, + port=1433, + host_port=40000, + database="master", + username="sa", + password=spec.password, + ) + assert MSSQL.describe(spec) == f"{spec.host}:1433" + + +def env_vars(ports): + spec = MSSQL.generate(auth=True, ports=ports, answers={}) + prefix = spec.env_prefix + assert MSSQL.env_vars(spec) == { + f"{prefix}_PORT": "40000", + f"{prefix}_PASS": spec.password, + } + + +def agent_entry(ports): + spec = MSSQL.generate(auth=True, ports=ports, answers={}) + assert MSSQL.agent_entry(spec) == { + "name": "MSSQL", + "database": "master", + "type": "mssql", + "username": "sa", + "password": spec.password, + "port": 1433, + "host": spec.host, + "generated_id": spec.id, + } + + +def fields(): + assert field_specs(MSSQL.fields_existing()) == [ + ("host", "text", "localhost"), + ("port", "int", 1433), + ("database", "text", None), + ("username", "text", None), + ("password", "secret", None), + ] + assert MSSQL.fields_new() == [] + assert MSSQL.option_fields() == [] + + +def from_existing(): + spec = MSSQL.from_existing({**EXISTING_ANSWERS, "label": "Prod"}) + assert spec == DatabaseSpec( + id=spec.id, + engine="mssql", + name="Prod", + host="db.example", + port=1234, + database="app", + username="u", + password="p", + ) + assert MSSQL.from_existing(EXISTING_ANSWERS).name == "External DB" + + +def compose_service(render_engine): + rendered = render_engine("mssql") + assert rendered.service == { + "image": "mcr.microsoft.com/azure-sql-edge:latest", + "restart": "unless-stopped", + "networks": ["portabase"], + "ports": [f"{rendered.var('PORT')}:1433"], + "environment": ["ACCEPT_EULA=Y", f"MSSQL_SA_PASSWORD={rendered.var('PASS')}"], + "volumes": [f"{rendered.spec.host}-data:/var/opt/mssql"], + "healthcheck": { + "test": ["CMD-SHELL", "cat /proc/net/tcp6 | grep -q '059901' || exit 1"], + "interval": "10s", + "timeout": "5s", + "retries": 20, + }, + } + + +def compose_service_inline(render_engine): + rendered = render_engine("mssql", inline=True) + assert rendered.service["ports"] == ["40000:1433"] + assert rendered.service["environment"] == [ + "ACCEPT_EULA=Y", + f"MSSQL_SA_PASSWORD={rendered.spec.password}", + ] + + +def options_are_ignored(ports): + spec = MSSQL.generate(auth=True, ports=ports, answers={"options": {"x": 1}}) + assert spec.options == {} diff --git a/tests/engines/mysql.py b/tests/engines/mysql.py new file mode 100644 index 0000000..ace0c9c --- /dev/null +++ b/tests/engines/mysql.py @@ -0,0 +1,126 @@ +import re + +from core.specs import DatabaseSpec +from engines import registry +from engines.mariadb import MariaDbEngine +from engines.mysql import MySqlEngine +from tests.support import EXISTING_ANSWERS, field_specs + +MYSQL = registry.get("mysql") +HEALTH = {"interval": "10s", "timeout": "5s", "retries": 5} + + +def attributes(): + assert type(MYSQL) is MySqlEngine + assert isinstance(MYSQL, MariaDbEngine) + assert (MYSQL.key, MYSQL.display, MYSQL.default_port) == ("mysql", "MySQL", 3306) + assert MYSQL.template == "engines/mysql.yml.j2" + assert (MYSQL.auth_variants, MYSQL.has_modes, MYSQL.warning) == (False, True, None) + + +def generate(ports): + spec = MYSQL.generate(auth=True, ports=ports, answers={}) + assert re.fullmatch(r"db-mariadb-[0-9a-f]{4}", spec.host or "") + assert re.fullmatch(r"mysql_[0-9a-f]{8}", spec.database or "") + assert len(spec.password or "") == 16 + assert spec == DatabaseSpec( + id=spec.id, + engine="mysql", + name=spec.database or "", + managed=True, + host=spec.host, + port=3306, + host_port=40000, + database=spec.database, + username="admin", + password=spec.password, + ) + assert MYSQL.describe(spec) == f"{spec.host}:3306" + + +def env_vars(ports): + spec = MYSQL.generate(auth=True, ports=ports, answers={}) + prefix = spec.env_prefix + assert MYSQL.env_vars(spec) == { + f"{prefix}_PORT": "40000", + f"{prefix}_DB": spec.database, + f"{prefix}_USER": "admin", + f"{prefix}_PASS": spec.password, + } + + +def agent_entry(ports): + spec = MYSQL.generate(auth=True, ports=ports, answers={}) + assert MYSQL.agent_entry(spec) == { + "name": spec.name, + "database": spec.database, + "type": "mysql", + "username": "admin", + "password": spec.password, + "port": 3306, + "host": spec.host, + "generated_id": spec.id, + } + + +def fields(): + assert field_specs(MYSQL.fields_existing()) == [ + ("host", "text", "localhost"), + ("port", "int", 3306), + ("database", "text", None), + ("username", "text", None), + ("password", "secret", None), + ] + assert MYSQL.fields_new() == [] + assert MYSQL.option_fields() == [] + + +def from_existing(): + spec = MYSQL.from_existing({**EXISTING_ANSWERS, "label": "Prod"}) + assert spec == DatabaseSpec( + id=spec.id, + engine="mysql", + name="Prod", + host="db.example", + port=1234, + database="app", + username="u", + password="p", + ) + assert MYSQL.from_existing(EXISTING_ANSWERS).name == "External DB" + + +def compose_service(render_engine): + rendered = render_engine("mysql") + assert rendered.service == { + # MySQL databases have always run on the MariaDB image (wire compatible). + "image": "mariadb:latest", + "restart": "unless-stopped", + "networks": ["portabase"], + "ports": [f"{rendered.var('PORT')}:3306"], + "environment": [ + f"MYSQL_DATABASE={rendered.var('DB')}", + f"MYSQL_USER={rendered.var('USER')}", + f"MYSQL_PASSWORD={rendered.var('PASS')}", + "MYSQL_RANDOM_ROOT_PASSWORD=yes", + ], + "volumes": [f"{rendered.spec.host}-data:/var/lib/mysql"], + "healthcheck": { + "test": [ + "CMD-SHELL", + f"mariadb-admin ping -h localhost -u {rendered.var('USER')} -p{rendered.var('PASS')}", + ], + **HEALTH, + }, + } + + +def compose_service_inline(render_engine): + rendered = render_engine("mysql", inline=True) + assert rendered.service["ports"] == ["40000:3306"] + assert rendered.service["environment"] == [ + f"MYSQL_DATABASE={rendered.spec.database}", + "MYSQL_USER=admin", + f"MYSQL_PASSWORD={rendered.spec.password}", + "MYSQL_RANDOM_ROOT_PASSWORD=yes", + ] diff --git a/tests/engines/postgresql.py b/tests/engines/postgresql.py new file mode 100644 index 0000000..40900b0 --- /dev/null +++ b/tests/engines/postgresql.py @@ -0,0 +1,139 @@ +import re + +from core.specs import DatabaseSpec +from engines import registry +from engines.postgresql import PostgresEngine +from tests.support import EXISTING_ANSWERS, field_specs + +PG = registry.get("postgresql") +HEALTH = {"interval": "10s", "timeout": "5s", "retries": 5} + + +def attributes(): + assert type(PG) is PostgresEngine + assert (PG.key, PG.display, PG.default_port) == ("postgresql", "PostgreSQL", 5432) + assert PG.template == "engines/postgresql.yml.j2" + assert (PG.auth_variants, PG.has_modes, PG.warning) == (False, True, None) + + +def generate(ports): + spec = PG.generate(auth=True, ports=ports, answers={}) + assert re.fullmatch(r"db-pg-[0-9a-f]{4}", spec.host or "") + assert re.fullmatch(r"pg_[0-9a-f]{8}", spec.database or "") + assert len(spec.password or "") == 16 + assert spec == DatabaseSpec( + id=spec.id, + engine="postgresql", + name=spec.database or "", + managed=True, + host=spec.host, + port=5432, + host_port=40000, + database=spec.database, + username="admin", + password=spec.password, + ) + assert PG.describe(spec) == f"{spec.host}:5432" + + +def env_vars(ports): + spec = PG.generate(auth=True, ports=ports, answers={}) + prefix = spec.env_prefix + assert PG.env_vars(spec) == { + f"{prefix}_PORT": "40000", + f"{prefix}_DB": spec.database, + f"{prefix}_USER": "admin", + f"{prefix}_PASS": spec.password, + } + + +def agent_entry(ports): + spec = PG.generate(auth=True, ports=ports, answers={}) + assert PG.agent_entry(spec) == { + "name": spec.name, + "database": spec.database, + "type": "postgresql", + "username": "admin", + "password": spec.password, + "port": 5432, + "host": spec.host, + "generated_id": spec.id, + } + + +def fields(): + assert field_specs(PG.fields_existing()) == [ + ("host", "text", "localhost"), + ("port", "int", 5432), + ("database", "text", None), + ("username", "text", None), + ("password", "secret", None), + ] + assert PG.fields_new() == [] + assert field_specs(PG.option_fields()) == [ + ("keep_ownership", "bool", False), + ("clean_mode", "choice", "clean"), + ] + assert PG.option_fields()[1].choices == ( + "clean", + "none", + "drop_schemas", + "drop_database", + ) + + +def from_existing(): + spec = PG.from_existing({**EXISTING_ANSWERS, "label": "Prod"}) + assert spec == DatabaseSpec( + id=spec.id, + engine="postgresql", + name="Prod", + host="db.example", + port=1234, + database="app", + username="u", + password="p", + ) + assert PG.from_existing(EXISTING_ANSWERS).name == "External DB" + + +def compose_service(render_engine): + rendered = render_engine("postgresql") + assert rendered.service == { + "image": "postgres:17-alpine", + "restart": "unless-stopped", + "networks": ["portabase"], + "ports": [f"{rendered.var('PORT')}:5432"], + "volumes": [f"{rendered.spec.host}-data:/var/lib/postgresql/data"], + "environment": [ + f"POSTGRES_DB={rendered.var('DB')}", + f"POSTGRES_USER={rendered.var('USER')}", + f"POSTGRES_PASSWORD={rendered.var('PASS')}", + ], + "healthcheck": { + "test": [ + "CMD-SHELL", + f"pg_isready -U {rendered.var('USER')} -d {rendered.var('DB')}", + ], + **HEALTH, + }, + } + + +def compose_service_inline(render_engine): + rendered = render_engine("postgresql", inline=True) + assert rendered.service["ports"] == ["40000:5432"] + assert rendered.service["environment"] == [ + f"POSTGRES_DB={rendered.spec.database}", + "POSTGRES_USER=admin", + f"POSTGRES_PASSWORD={rendered.spec.password}", + ] + + +def only_non_default_options_reach_the_agent(ports): + options = {"keep_ownership": True, "clean_mode": "clean", "unknown": 1} + spec = PG.generate(auth=True, ports=ports, answers={"options": options}) + assert spec.options == options + assert PG.non_default_options(spec) == {"keep_ownership": True} + assert PG.agent_entry(spec)["options"] == {"keep_ownership": True} + assert "options" not in PG.agent_entry(spec.with_options({"clean_mode": "clean"})) diff --git a/tests/engines/postgresql_cluster.py b/tests/engines/postgresql_cluster.py new file mode 100644 index 0000000..930616f --- /dev/null +++ b/tests/engines/postgresql_cluster.py @@ -0,0 +1,135 @@ +import re + +from core.specs import DatabaseSpec +from engines import registry +from engines.postgresql import PostgresClusterEngine +from tests.support import EXISTING_ANSWERS, field_specs + +CLUSTER = registry.get("postgresql-cluster") +HEALTH = {"interval": "10s", "timeout": "5s", "retries": 5} + + +def attributes(): + assert type(CLUSTER) is PostgresClusterEngine + assert (CLUSTER.key, CLUSTER.display, CLUSTER.default_port) == ( + "postgresql-cluster", + "PostgreSQL Cluster", + 5432, + ) + assert CLUSTER.template == "engines/postgresql-cluster.yml.j2" + assert (CLUSTER.auth_variants, CLUSTER.has_modes) == (False, True) + assert "superuser" in (CLUSTER.warning or "") + assert "pg_dumpall" in (CLUSTER.warning or "") + + +def generate(ports): + spec = CLUSTER.generate(auth=True, ports=ports, answers={}) + assert re.fullmatch(r"db-pg-[0-9a-f]{4}", spec.host or "") + assert re.fullmatch(r"pg_[0-9a-f]{8}", spec.database or "") + assert len(spec.password or "") == 16 + assert spec == DatabaseSpec( + id=spec.id, + engine="postgresql-cluster", + name=spec.database or "", + managed=True, + host=spec.host, + port=5432, + host_port=40000, + database=spec.database, + username="admin", + password=spec.password, + ) + assert CLUSTER.describe(spec) == f"{spec.host}:5432" + + +def env_vars(ports): + spec = CLUSTER.generate(auth=True, ports=ports, answers={}) + prefix = spec.env_prefix + assert CLUSTER.env_vars(spec) == { + f"{prefix}_PORT": "40000", + f"{prefix}_DB": spec.database, + f"{prefix}_USER": "admin", + f"{prefix}_PASS": spec.password, + } + + +def agent_entry(ports): + spec = CLUSTER.generate(auth=True, ports=ports, answers={}) + assert CLUSTER.agent_entry(spec) == { + "name": spec.name, + "database": spec.database, + "type": "postgresql-cluster", + "username": "admin", + "password": spec.password, + "port": 5432, + "host": spec.host, + "generated_id": spec.id, + } + + +def fields(): + assert field_specs(CLUSTER.fields_existing()) == [ + ("host", "text", "localhost"), + ("port", "int", 5432), + ("database", "text", None), + ("username", "text", None), + ("password", "secret", None), + ] + assert CLUSTER.fields_new() == [] + assert CLUSTER.option_fields() == [] + + +def from_existing(): + spec = CLUSTER.from_existing({**EXISTING_ANSWERS, "label": "Prod"}) + assert spec == DatabaseSpec( + id=spec.id, + engine="postgresql-cluster", + name="Prod", + host="db.example", + port=1234, + database="app", + username="u", + password="p", + ) + assert CLUSTER.from_existing(EXISTING_ANSWERS).name == "External DB" + + +def compose_service(render_engine): + rendered = render_engine("postgresql-cluster") + assert rendered.service == { + "image": "postgres:17-alpine", + "restart": "unless-stopped", + "networks": ["portabase"], + "ports": [f"{rendered.var('PORT')}:5432"], + "volumes": [f"{rendered.spec.host}-data:/var/lib/postgresql/data"], + "environment": [ + f"POSTGRES_DB={rendered.var('DB')}", + f"POSTGRES_USER={rendered.var('USER')}", + f"POSTGRES_PASSWORD={rendered.var('PASS')}", + ], + "healthcheck": { + "test": [ + "CMD-SHELL", + f"pg_isready -U {rendered.var('USER')} -d {rendered.var('DB')}", + ], + **HEALTH, + }, + } + + +def compose_service_inline(render_engine): + rendered = render_engine("postgresql-cluster", inline=True) + assert rendered.service["ports"] == ["40000:5432"] + assert rendered.service["environment"] == [ + f"POSTGRES_DB={rendered.spec.database}", + "POSTGRES_USER=admin", + f"POSTGRES_PASSWORD={rendered.spec.password}", + ] + + +def options_are_dropped(ports): + spec = CLUSTER.generate( + auth=True, ports=ports, answers={"options": {"keep_ownership": True}} + ) + assert CLUSTER.non_default_options(spec) == {} + assert "options" not in CLUSTER.agent_entry(spec) diff --git a/tests/engines/redis.py b/tests/engines/redis.py new file mode 100644 index 0000000..a8a053a --- /dev/null +++ b/tests/engines/redis.py @@ -0,0 +1,143 @@ +import dataclasses +import re + +import pytest + +from core.specs import DatabaseSpec +from engines import registry +from engines.redis import RedisEngine +from tests.support import EXISTING_ANSWERS, field_specs + +REDIS = registry.get("redis") +HEALTH = {"interval": "10s", "timeout": "5s", "retries": 5} +AUTH = pytest.mark.parametrize("auth", [True, False], ids=["auth", "noauth"]) + + +def attributes(): + assert type(REDIS) is RedisEngine + assert (REDIS.key, REDIS.display, REDIS.default_port) == ("redis", "Redis", 6379) + assert REDIS.template == "engines/redis.yml.j2" + assert (REDIS.auth_variants, REDIS.has_modes, REDIS.warning) == (True, True, None) + + +@AUTH +def generate(ports, auth): + spec = REDIS.generate(auth=auth, ports=ports, answers={}) + suffix = "auth-" if auth else "" + assert re.fullmatch(rf"db-redis-{suffix}[0-9a-f]{{4}}", spec.host or "") + assert re.fullmatch(r"redis_[0-9a-f]{8}", spec.name) + assert len(spec.password or "") == (16 if auth else 0) + assert spec == DatabaseSpec( + id=spec.id, + engine="redis", + name=spec.name, + managed=True, + host=spec.host, + port=6379, + host_port=40000, + database="0", + username="", + password=spec.password, + ) + assert REDIS.describe(spec) == f"{spec.host}:6379" + + +@AUTH +def env_vars(ports, auth): + spec = REDIS.generate(auth=auth, ports=ports, answers={}) + prefix = spec.env_prefix + expected = {f"{prefix}_PORT": "40000"} + if auth: + expected[f"{prefix}_PASS"] = spec.password or "" + assert REDIS.env_vars(spec) == expected + + +@AUTH +def agent_entry(ports, auth): + spec = REDIS.generate(auth=auth, ports=ports, answers={}) + assert REDIS.agent_entry(spec) == { + "name": spec.name, + "database": "0", + "type": "redis", + "username": "", + "password": spec.password or "", + "port": 6379, + "host": spec.host, + "generated_id": spec.id, + } + + +def fields(): + assert field_specs(REDIS.fields_existing()) == [ + ("host", "text", "localhost"), + ("port", "int", 6379), + ("database", "text", "0"), + ("username", "text", ""), + ("password", "text", ""), + ] + assert REDIS.fields_new() == [] + assert REDIS.option_fields() == [] + + +def from_existing(): + spec = REDIS.from_existing({**EXISTING_ANSWERS, "label": "Prod"}) + assert spec == DatabaseSpec( + id=spec.id, + engine="redis", + name="Prod", + host="db.example", + port=1234, + database="app", + username="u", + password="p", + ) + assert REDIS.from_existing(EXISTING_ANSWERS).name == "External DB" + + +@AUTH +def compose_service(render_engine, auth): + rendered = render_engine("redis", auth=auth) + password = rendered.var("PASS") + expected = { + "image": "redis:latest", + "restart": "unless-stopped", + "ports": [f"{rendered.var('PORT')}:6379"], + "volumes": [f"{rendered.spec.host}-data:/data"], + "command": ["redis-server", "--appendonly", "yes"], + "networks": ["portabase", "default"], + "healthcheck": {"test": ["CMD-SHELL", "redis-cli ping | grep PONG"], **HEALTH}, + } + if auth: + expected["environment"] = [f"REDIS_PASSWORD={password}"] + expected["command"] = [ + "redis-server", + "--requirepass", + password, + "--appendonly", + "yes", + ] + expected["healthcheck"] = { + "test": ["CMD-SHELL", f"redis-cli -a {password} ping | grep PONG"], + **HEALTH, + } + assert rendered.service == expected + + +def compose_service_inline(render_engine): + rendered = render_engine("redis", auth=True, inline=True) + password = rendered.spec.password + assert rendered.service["ports"] == ["40000:6379"] + assert rendered.service["environment"] == [f"REDIS_PASSWORD={password}"] + assert rendered.service["command"] == [ + "redis-server", + "--requirepass", + password, + "--appendonly", + "yes", + ] + + +def agent_database_defaults_to_index_zero(ports): + spec = REDIS.generate(auth=False, ports=ports, answers={}) + assert REDIS.agent_database(dataclasses.replace(spec, database=None)) == "0" + assert REDIS.agent_database(dataclasses.replace(spec, database="3")) == "3" diff --git a/tests/engines/registry.py b/tests/engines/registry.py new file mode 100644 index 0000000..0a27e17 --- /dev/null +++ b/tests/engines/registry.py @@ -0,0 +1,47 @@ +import pytest + +from core.errors import ValidationError +from engines import ALL, EngineRegistry, registry +from engines.postgresql import PostgresEngine + +EXPECTED = [ + "postgresql", + "postgresql-cluster", + "mysql", + "mariadb", + "sqlite", + "firebird", + "mongodb", + "redis", + "valkey", + "mssql", + "docker-volume", +] + + +def keys_in_order(): + assert registry.keys() == EXPECTED + assert registry.choices() == EXPECTED + assert [engine.key for engine in registry] == EXPECTED + assert "redis" in registry + assert "nope" not in registry + + +def get_engine(): + assert isinstance(registry.get("postgresql"), PostgresEngine) + with pytest.raises(ValidationError, match="Unknown engine 'nope'") as exc: + registry.get("nope") + assert "postgresql" in (exc.value.hint or "") + + +def duplicate_keys_are_refused(): + with pytest.raises(ValueError, match="Duplicate engine key: postgresql"): + EngineRegistry([*ALL, PostgresEngine()]) + + +def templates_match_engines(templates): + shipped = {name for name in templates.names() if name.startswith("engines/")} + used = {engine.template for engine in registry if engine.template is not None} + assert used == shipped + for name in used: + templates.get(name) diff --git a/tests/engines/sqlite.py b/tests/engines/sqlite.py new file mode 100644 index 0000000..4abc8aa --- /dev/null +++ b/tests/engines/sqlite.py @@ -0,0 +1,108 @@ +import pytest + +from core.specs import DatabaseSpec +from engines import registry +from engines.sqlite import SqliteEngine +from tests.support import agent_service, field_specs + +SQLITE = registry.get("sqlite") + + +def attributes(): + assert type(SQLITE) is SqliteEngine + assert (SQLITE.key, SQLITE.display, SQLITE.default_port) == ( + "sqlite", + "SQLite", + None, + ) + assert SQLITE.template is None + assert (SQLITE.auth_variants, SQLITE.has_modes, SQLITE.warning) == ( + False, + True, + None, + ) + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + (None, "local.sqlite"), + ("", "local.sqlite"), + ("app", "app.sqlite"), + ("app.sqlite", "app.sqlite"), + ], +) +def generate(ports, name, expected): + spec = SQLITE.generate(auth=False, ports=ports, answers={"name": name}) + assert spec == DatabaseSpec( + id=spec.id, + engine="sqlite", + name=expected, + path=expected, + database=f"/config/{expected}", + ) + assert SQLITE.describe(spec) == "Local File" + + +def env_vars(ports): + spec = SQLITE.generate(auth=False, ports=ports, answers={"name": "app"}) + assert SQLITE.env_vars(spec) == {} + + +def agent_entry(ports): + spec = SQLITE.generate(auth=False, ports=ports, answers={"name": "app"}) + assert SQLITE.agent_entry(spec) == { + "name": "app.sqlite", + "database": "/config/app.sqlite", + "type": "sqlite", + "generated_id": spec.id, + } + + +def fields(): + assert field_specs(SQLITE.fields_existing()) == [("path", "text", None)] + assert field_specs(SQLITE.fields_new()) == [("name", "text", "local")] + assert SQLITE.option_fields() == [] + + +@pytest.mark.parametrize( + ("path", "database"), + [("data/app.db", "/config/data/app.db"), ("/srv/app.db", "/srv/app.db")], + ids=["relative", "absolute"], +) +def from_existing(path, database): + spec = SQLITE.from_existing({"path": path, "label": "Prod"}) + assert spec == DatabaseSpec( + id=spec.id, engine="sqlite", name="Prod", path=path, database=database + ) + assert SQLITE.from_existing({"path": path}).name == "External DB" + + +def compose_service(render_engine): + rendered = render_engine("sqlite", answers={"name": "app"}) + assert rendered.doc["services"] == { + "agent": agent_service("./app.sqlite:/config/app.sqlite") + } + assert "volumes" not in rendered.doc + assert rendered.databases == [SQLITE.agent_entry(rendered.spec)] + + +def compose_service_inline(render_engine): + rendered = render_engine("sqlite", inline=True, answers={"name": "app"}) + assert rendered.doc["services"] == { + "agent": agent_service("./app.sqlite:/config/app.sqlite", inline=True) + } + + +@pytest.mark.parametrize( + ("database", "mount"), + [ + ("/config/app.sqlite", ("./app.sqlite", "/config/app.sqlite")), + ("/config/data/app.db", ("./data/app.db", "/config/data/app.db")), + ("/srv/app.db", None), + (None, None), + ], +) +def mount_for(database, mount): + spec = DatabaseSpec(id="1", engine="sqlite", name="n", database=database) + assert SqliteEngine.mount_for(spec) == mount diff --git a/tests/engines/valkey.py b/tests/engines/valkey.py new file mode 100644 index 0000000..cbdec5c --- /dev/null +++ b/tests/engines/valkey.py @@ -0,0 +1,141 @@ +import dataclasses +import re + +import pytest + +from core.specs import DatabaseSpec +from engines import registry +from engines.valkey import ValkeyEngine +from tests.support import EXISTING_ANSWERS, field_specs + +VALKEY = registry.get("valkey") +HEALTH = {"interval": "10s", "timeout": "5s", "retries": 5} +AUTH = pytest.mark.parametrize("auth", [True, False], ids=["auth", "noauth"]) + + +def attributes(): + assert type(VALKEY) is ValkeyEngine + assert (VALKEY.key, VALKEY.display, VALKEY.default_port) == ( + "valkey", + "Valkey", + 6379, + ) + assert VALKEY.template == "engines/valkey.yml.j2" + assert (VALKEY.auth_variants, VALKEY.has_modes, VALKEY.warning) == ( + True, + True, + None, + ) + + +@AUTH +def generate(ports, auth): + spec = VALKEY.generate(auth=auth, ports=ports, answers={}) + suffix = "auth-" if auth else "" + assert re.fullmatch(rf"db-valkey-{suffix}[0-9a-f]{{4}}", spec.host or "") + assert re.fullmatch(r"valkey_[0-9a-f]{8}", spec.name) + assert len(spec.password or "") == (16 if auth else 0) + assert spec == DatabaseSpec( + id=spec.id, + engine="valkey", + name=spec.name, + managed=True, + host=spec.host, + port=6379, + host_port=40000, + database="0", + username="", + password=spec.password, + ) + assert VALKEY.describe(spec) == f"{spec.host}:6379" + + +@AUTH +def env_vars(ports, auth): + spec = VALKEY.generate(auth=auth, ports=ports, answers={}) + prefix = spec.env_prefix + expected = {f"{prefix}_PORT": "40000"} + if auth: + expected[f"{prefix}_PASS"] = spec.password or "" + assert VALKEY.env_vars(spec) == expected + + +@AUTH +def agent_entry(ports, auth): + spec = VALKEY.generate(auth=auth, ports=ports, answers={}) + assert VALKEY.agent_entry(spec) == { + "name": spec.name, + "database": "0", + "type": "valkey", + "username": "", + "password": spec.password or "", + "port": 6379, + "host": spec.host, + "generated_id": spec.id, + } + + +def fields(): + assert field_specs(VALKEY.fields_existing()) == [ + ("host", "text", "localhost"), + ("port", "int", 6379), + ("database", "text", "0"), + ("username", "text", ""), + ("password", "text", ""), + ] + assert VALKEY.fields_new() == [] + assert VALKEY.option_fields() == [] + + +def from_existing(): + spec = VALKEY.from_existing({**EXISTING_ANSWERS, "label": "Prod"}) + assert spec == DatabaseSpec( + id=spec.id, + engine="valkey", + name="Prod", + host="db.example", + port=1234, + database="app", + username="u", + password="p", + ) + assert VALKEY.from_existing(EXISTING_ANSWERS).name == "External DB" + + +@AUTH +def compose_service(render_engine, auth): + rendered = render_engine("valkey", auth=auth) + password = rendered.var("PASS") + expected = { + "image": "valkey/valkey:latest", + "restart": "unless-stopped", + "environment": ["ALLOW_EMPTY_PASSWORD=yes"], + "ports": [f"{rendered.var('PORT')}:6379"], + "volumes": [f"{rendered.spec.host}-data:/data"], + "networks": ["portabase", "default"], + "healthcheck": {"test": ["CMD-SHELL", "valkey-cli ping | grep PONG"], **HEALTH}, + } + if auth: + del expected["environment"] + expected["command"] = ["valkey-server", "--requirepass", password] + expected["healthcheck"] = { + "test": ["CMD-SHELL", f"valkey-cli -a {password} ping | grep PONG"], + **HEALTH, + } + assert rendered.service == expected + + +def compose_service_inline(render_engine): + rendered = render_engine("valkey", auth=True, inline=True) + assert rendered.service["ports"] == ["40000:6379"] + assert rendered.service["command"] == [ + "valkey-server", + "--requirepass", + rendered.spec.password, + ] + + +def agent_database_defaults_to_index_zero(ports): + spec = VALKEY.generate(auth=False, ports=ports, answers={}) + assert VALKEY.agent_database(dataclasses.replace(spec, database=None)) == "0" + assert VALKEY.agent_database(dataclasses.replace(spec, database="3")) == "3" diff --git a/tests/services/__init__.py b/tests/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/services/auth_providers.py b/tests/services/auth_providers.py new file mode 100644 index 0000000..8e0d8ed --- /dev/null +++ b/tests/services/auth_providers.py @@ -0,0 +1,49 @@ +import pytest + +from core.errors import ValidationError +from services import auth_providers as ap + + +@pytest.mark.parametrize( + ("kind", "provider_id", "expected"), + [ + ("oidc", "keycloak", "AUTH_OIDC_KEYCLOAK"), + ("oidc", "my-kc", "AUTH_OIDC_MY_KC"), + ("oauth", "github", "AUTH_SOCIAL_GITHUB"), + ], +) +def provider_prefix_cases(kind, provider_id, expected): + assert ap.provider_prefix(kind, provider_id) == expected + + +@pytest.mark.parametrize( + ("kind", "provider_id", "expected"), + [ + ("oidc", " KeyCloak ", "keycloak"), + ("oidc", "my-kc-2", "my-kc-2"), + ("oauth", "GitHub", "github"), + ], +) +def validate_provider_id_accepts(kind, provider_id, expected): + assert ap.validate_provider_id(kind, provider_id) == expected + + +@pytest.mark.parametrize("provider_id", ["", "-kc", "k_c", "k c", "kc!"]) +def validate_provider_id_rejects_bad_ids(provider_id): + with pytest.raises(ValidationError, match="Invalid provider id"): + ap.validate_provider_id("oidc", provider_id) + + +def validate_provider_id_rejects_unknown_oauth(): + with pytest.raises(ValidationError, match="Unknown OAuth provider 'gitlab'") as exc: + ap.validate_provider_id("oauth", "gitlab") + assert "github" in (exc.value.hint or "") + + +@pytest.mark.parametrize( + ("fields", "env"), + [(ap.OIDC_FIELDS, ap.OIDC_ENV), (ap.OAUTH_FIELDS, ap.OAUTH_ENV)], + ids=["oidc", "oauth"], +) +def every_field_has_an_env_suffix(fields, env): + assert [field.name for field in fields] == list(env) diff --git a/tests/services/compose_facts.py b/tests/services/compose_facts.py new file mode 100644 index 0000000..c1d23a7 --- /dev/null +++ b/tests/services/compose_facts.py @@ -0,0 +1,76 @@ +import pytest + +from services.compose_facts import ( + CA_BUNDLE_IN_CONTAINER, + GENERATED_MARKER, + ComposeFacts, +) + + +def _facts(tmp_path, text): + path = tmp_path / "docker-compose.yml" + path.write_text(text, encoding="utf-8") + return ComposeFacts(path) + + +def missing_file(tmp_path): + facts = ComposeFacts(tmp_path / "docker-compose.yml") + assert not facts.exists + assert not facts.is_generated + assert not facts.host_gateway + assert facts.ca_bundle is None + + +def generated_marker(tmp_path): + header = f"{GENERATED_MARKER} 1.0. Do not edit.\nservices: {{}}\n" + assert _facts(tmp_path, header).is_generated + assert not _facts(tmp_path, "services: {}\n").is_generated + + +@pytest.mark.parametrize( + "text", + [ + "services:\n agent:\n extra_hosts:\n - localhost:host-gateway\n", + "services:\n agent:\n extra_hosts:\n localhost: host-gateway\n", + ], + ids=["list", "dict"], +) +def host_gateway_detected(tmp_path, text): + assert _facts(tmp_path, text).host_gateway + + +@pytest.mark.parametrize( + "text", + [ + "services:\n agent:\n image: x\n", + "services:\n other:\n extra_hosts: ['localhost:host-gateway']\n", + "services: []\n", + "- not a mapping\n", + "services: [unclosed\n", + ], +) +def host_gateway_absent(tmp_path, text): + assert not _facts(tmp_path, text).host_gateway + + +def ca_bundle_short_syntax(tmp_path): + text = ( + "services:\n agent:\n volumes:\n" + " - ./databases.json:/config/config.json\n" + f" - ./ca.crt:{CA_BUNDLE_IN_CONTAINER}:ro\n" + ) + assert _facts(tmp_path, text).ca_bundle == "./ca.crt" + + +def ca_bundle_long_syntax(tmp_path): + text = ( + "services:\n agent:\n volumes:\n" + " - type: bind\n source: /etc/ca.crt\n" + f" target: {CA_BUNDLE_IN_CONTAINER}\n" + ) + assert _facts(tmp_path, text).ca_bundle == "/etc/ca.crt" + + +def ca_bundle_absent(tmp_path): + text = "services:\n agent:\n volumes:\n - ./db.json:/config/config.json\n" + assert _facts(tmp_path, text).ca_bundle is None diff --git a/tests/services/docker.py b/tests/services/docker.py new file mode 100644 index 0000000..183aa03 --- /dev/null +++ b/tests/services/docker.py @@ -0,0 +1,23 @@ +import pytest + +from core.errors import DockerError +from services import docker +from services.docker import DockerRunner + + +def project_name_is_the_slugified_folder(tmp_path): + folder = tmp_path / "My Agent" + folder.mkdir() + assert DockerRunner.project_name(folder) == "my-agent" + + +def binary_missing(monkeypatch): + monkeypatch.setattr(docker.shutil, "which", lambda _: None) + runner = DockerRunner() + assert not runner.available() + with pytest.raises(DockerError, match="Docker not found"): + _ = runner.binary + + +def binary_explicit(): + assert DockerRunner("/opt/docker").binary == "/opt/docker" diff --git a/tests/services/envfile.py b/tests/services/envfile.py new file mode 100644 index 0000000..aa9fa0c --- /dev/null +++ b/tests/services/envfile.py @@ -0,0 +1,125 @@ +import pytest + +from services.envfile import EnvFile + +SAMPLE = r"""# comment +export A=1 +B = "two words" +C='single # kept' +D=plain # trailing comment +E="esc \"q\" back\\slash" +#F=commented + +G= +""" + + +@pytest.fixture +def env(tmp_path): + path = tmp_path / ".env" + path.write_text(SAMPLE, encoding="utf-8") + return EnvFile.load(path) + + +def missing_file(tmp_path): + env = EnvFile.load(tmp_path / ".env") + assert not env.exists + assert env.as_dict() == {} + assert env.get("A", "default") == "default" + + +def parse(env): + assert env.as_dict() == { + "A": "1", + "B": "two words", + "C": "single # kept", + "D": "plain", + "E": 'esc "q" back\\slash', + "G": "", + } + assert env.get("F") is None + + +def last_duplicate_wins(tmp_path): + path = tmp_path / ".env" + path.write_text("A=1\nA=2\n", encoding="utf-8") + assert EnvFile.load(path).get("A") == "2" + + +@pytest.mark.parametrize( + "value", + [ + "", + "simple", + "with space", + 'quote"inside', + "back\\slash", + "ends\\", + '\\"', + "hash # x", + "a=b", + "'single'", + ], +) +def set_save_load_round_trip(tmp_path, value): + env = EnvFile.load(tmp_path / ".env") + env.set("KEY", value) + env.save() + assert EnvFile.load(tmp_path / ".env").get("KEY") == value + + +def set_existing_key_keeps_position_and_comments(env): + env.set("B", "new") + env.set("Z", "added") + env.save() + lines = env.path.read_text(encoding="utf-8").splitlines() + assert lines[0] == "# comment" + assert lines[2] == 'B="new"' + assert lines[-1] == 'Z="added"' + + +def merge_overrides_and_adds(env): + env.merge({"A": "10", "NEW": "x"}) + assert env.get("A") == "10" + assert env.get("NEW") == "x" + + +def remove_keeps_index_consistent(env): + env.remove("B") + env.remove("missing") + env.set("D", "changed") + assert env.get("B") is None + assert env.get("A") == "1" + assert env.get("C") == "single # kept" + assert env.get("D") == "changed" + env.save() + assert "B =" not in env.path.read_text(encoding="utf-8") + assert EnvFile.load(env.path).get("D") == "changed" + + +def remove_prefix_only_removes_that_prefix(tmp_path): + env = EnvFile.load(tmp_path / ".env") + env.merge( + { + "AUTH_OIDC_KC": "kept", + "AUTH_OIDC_KC_ID": "kc", + "AUTH_OIDC_KC_SECRET": "s", + "AUTH_OIDC_KCX_ID": "kept", + "OTHER": "kept", + } + ) + env.remove_prefix("AUTH_OIDC_KC") + assert env.as_dict() == { + "AUTH_OIDC_KC": "kept", + "AUTH_OIDC_KCX_ID": "kept", + "OTHER": "kept", + } + + +def save_creates_parent_and_ends_with_newline(tmp_path): + env = EnvFile.load(tmp_path / "nested" / ".env") + env.set("A", "1") + env.save() + assert env.exists + assert env.path.read_text(encoding="utf-8") == 'A="1"\n' + assert not (tmp_path / "nested" / ".env.tmp").exists() diff --git a/tests/services/http.py b/tests/services/http.py new file mode 100644 index 0000000..c001919 --- /dev/null +++ b/tests/services/http.py @@ -0,0 +1,147 @@ +import pytest +import requests + +from core.errors import NetworkError +from services.http import HttpClient + + +class _Response: + def __init__(self, *, status=200, text="", json=None, chunks=(), headers=None): + self.status_code = status + self.text = text + self.headers = headers or {} + self._json = json + self._chunks = chunks + + def raise_for_status(self): + if self.status_code >= 400: + raise requests.HTTPError(f"{self.status_code} Error") + + def json(self): + if self._json is None: + raise ValueError("Expecting value") + return self._json + + def iter_content(self, chunk_size): + for chunk in self._chunks: + if isinstance(chunk, Exception): + raise chunk + yield chunk + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +class _Session: + def __init__(self, response=None, error=None): + self.response = response + self.error = error + self.calls = [] + + def _call(self, method, url, kwargs): + self.calls.append((method, url, kwargs)) + if self.error is not None: + raise self.error + return self.response + + def get(self, url, **kwargs): + return self._call("GET", url, kwargs) + + def head(self, url, **kwargs): + return self._call("HEAD", url, kwargs) + + +def _client(**session): + client = HttpClient(timeout=3) + client.session = _Session(**session) + return client + + +def user_agent_header(): + assert HttpClient(user_agent="ua/1").session.headers["User-Agent"] == "ua/1" + + +def get_json_returns_the_payload(): + client = _client(response=_Response(json={"a": 1})) + assert client.get_json("https://x") == {"a": 1} + assert client.session.calls == [("GET", "https://x", {"timeout": 3})] + + +@pytest.mark.parametrize( + ("session", "message"), + [ + ({"response": _Response(status=404)}, "GET https://x failed: 404"), + ({"error": requests.ConnectionError("down")}, "GET https://x failed: down"), + ({"response": _Response(json=None)}, "response is not JSON"), + ], + ids=["http-error", "connection-error", "not-json"], +) +def get_json_failures(session, message): + with pytest.raises(NetworkError, match=message): + _client(**session).get_json("https://x") + + +def get_text_returns_the_body(): + assert _client(response=_Response(text="hello")).get_text("https://x") == "hello" + + +def get_text_failure_has_a_hint(): + with pytest.raises(NetworkError) as exc: + _client(response=_Response(status=500)).get_text("https://x") + assert "internet connection" in (exc.value.hint or "") + + +def status_returns_the_code_without_raising(): + client = _client(response=_Response(status=503)) + assert client.status("https://x") == 503 + assert client.session.calls[0][2] == {"timeout": 3, "stream": True} + + +def status_connection_error(): + with pytest.raises(NetworkError): + _client(error=requests.Timeout("slow")).status("https://x") + + +def download_writes_every_chunk(tmp_path): + client = _client(response=_Response(chunks=[b"ab", b"", b"cde"])) + dest = tmp_path / "file" + progress = [] + assert client.download("https://x", dest, progress.append) == 5 + assert dest.read_bytes() == b"abcde" + assert progress == [2, 3] + assert client.session.calls[0][2] == {"stream": True, "timeout": 30.0} + + +def download_failure_removes_the_partial_file(tmp_path): + chunks = [b"ab", requests.ConnectionError("cut")] + dest = tmp_path / "file" + with pytest.raises(NetworkError, match="Download of https://x failed: cut"): + _client(response=_Response(chunks=chunks)).download("https://x", dest) + assert not dest.exists() + + +def download_http_error(tmp_path): + dest = tmp_path / "file" + with pytest.raises(NetworkError): + _client(response=_Response(status=404)).download("https://x", dest, timeout=5) + assert not dest.exists() + + +@pytest.mark.parametrize( + ("session", "expected"), + [ + ({"response": _Response(headers={"content-length": "42"})}, 42), + ({"response": _Response()}, None), + ({"response": _Response(headers={"content-length": "abc"})}, None), + ({"error": requests.ConnectionError("down")}, None), + ], + ids=["header", "no-header", "bad-header", "error"], +) +def content_length_cases(session, expected): + client = _client(**session) + assert client.content_length("https://x") == expected + assert client.session.calls[0][:2] == ("HEAD", "https://x") + assert client.session.calls[0][2]["allow_redirects"] is True diff --git a/tests/services/ports.py b/tests/services/ports.py new file mode 100644 index 0000000..018835b --- /dev/null +++ b/tests/services/ports.py @@ -0,0 +1,41 @@ +import pytest + +from services import ports +from services.ports import FixedPortAllocator, PortAllocator + + +def port_allocator_returns_distinct_free_ports(): + allocator = PortAllocator() + given = [allocator.free() for _ in range(5)] + assert len(set(given)) == 5 + assert all(1024 <= port <= 65535 for port in given) + + +def port_allocator_gives_up_when_the_os_repeats_a_port(monkeypatch): + class _Socket: + def __init__(self, *args): + pass + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def bind(self, address): + pass + + def getsockname(self): + return ("0.0.0.0", 45678) + + monkeypatch.setattr(ports.socket, "socket", _Socket) + allocator = PortAllocator() + assert allocator.free() == 45678 + with pytest.raises(RuntimeError, match="Could not allocate a free port"): + allocator.free() + + +def fixed_port_allocator_counts_up(): + allocator = FixedPortAllocator() + assert [allocator.free() for _ in range(3)] == [40000, 40001, 40002] + assert FixedPortAllocator(start=5000).free() == 5000 diff --git a/tests/services/project.py b/tests/services/project.py new file mode 100644 index 0000000..5a7c063 --- /dev/null +++ b/tests/services/project.py @@ -0,0 +1,382 @@ +import dataclasses +import json + +import pytest + +from core.errors import ConfigError, ValidationError +from engines import registry +from services.compose_facts import CA_BUNDLE_IN_CONTAINER +from services.envfile import EnvFile +from services.project import ( + AgentProject, + AuthProvider, + DashboardProject, + detect_kind, + spec_from_entry, +) +from tests.support import EXISTING_ANSWERS + +PG = registry.get("postgresql") +FIREBIRD = registry.get("firebird") +SQLITE = registry.get("sqlite") +VOLUME = registry.get("docker-volume") +KEYCLOAK = AuthProvider( + kind="oidc", + id="my-kc", + values={ + "issuer": "https://kc.example", + "client": "c", + "secret": "s", + "title": "", + "pkce": True, + "host": "", + }, +) +GITHUB = AuthProvider( + kind="oauth", + id="github", + values={"client": "gc", "secret": "gs", "title": "GitHub"}, +) + + +def detect_kind_cases(tmp_path): + agent, dashboard, empty = (tmp_path / name for name in ("a", "d", "e")) + for folder in (agent, dashboard, empty): + folder.mkdir() + (agent / "databases.json").write_text("{}", encoding="utf-8") + (dashboard / ".env").write_text("PROJECT_SECRET=s\n", encoding="utf-8") + assert detect_kind(agent) == "agent" + assert detect_kind(dashboard) == "dashboard" + with pytest.raises(ConfigError, match="not a Portabase agent or dashboard"): + detect_kind(empty) + + +def spec_from_entry_managed(tmp_path): + env = EnvFile(tmp_path / ".env") + env.merge({"DB_FB_AB12_PORT": "40001", "DB_FB_AB12_ROOT_PASS": "root"}) + entry = { + "name": "fb", + "database": "/data/mirror.fdb", + "type": "firebird", + "username": "alice", + "password": "pw", + "port": 3050, + "host": "db-fb-ab12", + "generated_id": "id-1", + "options": {"x": 1}, + } + spec = spec_from_entry(entry, env) + assert spec.managed + assert (spec.id, spec.engine, spec.host, spec.port, spec.host_port) == ( + "id-1", + "firebird", + "db-fb-ab12", + 3050, + 40001, + ) + assert (spec.password, spec.root_password) == ("pw", "root") + assert spec.options == {"x": 1} + assert spec.path is None + + +def spec_from_entry_external(tmp_path): + entry = {**EXISTING_ANSWERS, "type": "postgresql", "port": "5432", "password": ""} + spec = spec_from_entry(entry, EnvFile(tmp_path / ".env")) + assert not spec.managed + assert spec.host_port is None + assert spec.port == 5432 + assert spec.password is None + assert spec.id + + +def spec_from_entry_sqlite_and_volume(tmp_path): + env = EnvFile(tmp_path / ".env") + sqlite = spec_from_entry({"type": "sqlite", "database": "/config/app.sqlite"}, env) + volume = spec_from_entry( + {"type": "docker-volume", "volume_name": "v", "container_name": ""}, env + ) + assert (sqlite.path, sqlite.host) == ("/config/app.sqlite", None) + assert (volume.volume, volume.container) == ("v", None) + + +def agent_create_does_not_write_env(agent): + assert agent.path.is_dir() + assert not agent.env.exists + assert agent.env.get("POLLING") == "5" + + +def agent_add_managed_merges_env(agent, ports): + spec = PG.generate(auth=True, ports=ports, answers={}) + agent.add(spec, PG) + assert agent.managed == [spec] + assert agent.env.get(f"{spec.env_prefix}_PORT") == "40000" + assert agent.env.get(f"{spec.env_prefix}_PASS") == spec.password + + +def agent_add_external_leaves_env(agent): + before = agent.env.as_dict() + agent.add(PG.from_existing(EXISTING_ANSWERS), PG) + assert agent.env.as_dict() == before + assert agent.managed == [] + + +def agent_add_refuses_a_duplicate_service_name(agent, ports): + first = PG.generate(auth=True, ports=ports, answers={}) + agent.add(first, PG) + clash = dataclasses.replace( + PG.generate(auth=True, ports=ports, answers={}), host=first.host + ) + with pytest.raises(ConfigError, match="share the service name"): + agent.add(clash, PG) + + +def agent_remove_purges_its_env_prefix(agent, ports): + keep = PG.generate(auth=True, ports=ports, answers={}) + gone = PG.generate(auth=True, ports=ports, answers={}) + agent.add(keep, PG) + agent.add(gone, PG) + agent.remove(gone, PG) + assert agent.databases == [keep] + assert not any(key.startswith(gone.env_prefix) for key in agent.env.as_dict()) + assert agent.env.get(f"{keep.env_prefix}_PORT") == "40000" + assert agent.env.get("EDGE_KEY") == "x" + + +def agent_find(agent, ports): + first = dataclasses.replace( + PG.generate(auth=True, ports=ports, answers={}), id="aaaa-1111", name="main" + ) + second = dataclasses.replace( + PG.generate(auth=True, ports=ports, answers={}), id="aaab-2222", name="other" + ) + agent.add(first, PG) + agent.add(second, PG) + assert agent.find("aaaa-1111") is first + assert agent.find("aaab") is second + assert agent.find("main") is first + with pytest.raises(ValidationError, match="No database matching"): + agent.find("zzz") + with pytest.raises(ValidationError, match="matches several"): + agent.find("aaa") + + +def agent_settings(agent): + assert agent.setting("polling") == 5 + assert agent.setting("host_gateway") is False + assert agent.setting("retry_attempts") is None + agent.set("polling", 30) + agent.set("host_gateway", True) + agent.set("retry_attempts", 3) + assert agent.env.get("POLLING") == "30" + assert agent.host_gateway is True + assert agent.extra_env == ["RETRY_ATTEMPTS"] + assert set(agent.settings()) == set(agent.registry.names()) + agent.unset("retry_attempts") + assert agent.env.get("RETRY_ATTEMPTS") is None + assert agent.extra_env == [] + + +@pytest.mark.parametrize("name", ["key", "tz", "polling", "log_level", "host_gateway"]) +def agent_core_settings_cannot_be_unset(agent, name): + with pytest.raises(ValidationError, match="cannot be unset"): + agent.unset(name) + + +def agent_ca_bundle(agent): + (agent.path / "ca.crt").write_text("pem", encoding="utf-8") + agent.ca_bundle = "ca.crt" + agent.validate() + assert agent.env.get("SSL_CERT_FILE") == CA_BUNDLE_IN_CONTAINER + agent.ca_bundle = None + assert agent.ca_bundle is None + assert agent.env.get("SSL_CERT_FILE") is None + + +def agent_missing_ca_bundle(agent): + agent.ca_bundle = "missing.crt" + with pytest.raises(ValidationError, match="CA bundle not found"): + agent.save_state() + assert not agent.env.exists + + +def agent_docker_socket_and_sqlite_mounts(agent, ports): + assert not agent.needs_docker_socket + agent.add(VOLUME.from_existing({"volume": "v"}), VOLUME) + for _ in range(2): + agent.add( + SQLITE.generate(auth=False, ports=ports, answers={"name": "app"}), SQLITE + ) + agent.add(SQLITE.from_existing({"path": "/abs/app.db"}), SQLITE) + assert agent.needs_docker_socket + assert agent.sqlite_mounts == [("./app.sqlite", "/config/app.sqlite")] + + +def agent_load_round_trip(agent, ports, renderer): + spec = FIREBIRD.generate(auth=True, ports=ports, answers={}) + agent.add(spec, FIREBIRD) + agent.add(VOLUME.from_existing({"volume": "v", "container": "c"}), VOLUME) + agent.host_gateway = True + (agent.path / "ca.crt").write_text("pem", encoding="utf-8") + agent.ca_bundle = "./ca.crt" + renderer.render_agent(agent).write(agent.path) + agent.save_state() + + loaded = AgentProject.load(agent.path) + assert loaded.host_gateway + assert loaded.ca_bundle == "./ca.crt" + assert loaded.env.as_dict() == agent.env.as_dict() + firebird, volume = loaded.databases + assert firebird.managed + assert (firebird.id, firebird.host, firebird.host_port) == ( + spec.id, + spec.host, + spec.host_port, + ) + assert (firebird.password, firebird.root_password) == ( + spec.password, + spec.root_password, + ) + assert (volume.volume, volume.container) == ("v", "c") + + +def agent_load_rejects_non_agent_folders(tmp_path): + with pytest.raises(ConfigError, match="Not a Portabase agent folder"): + AgentProject.load(tmp_path) + (tmp_path / ".env").write_text("", encoding="utf-8") + (tmp_path / "databases.json").write_text("{oops", encoding="utf-8") + with pytest.raises(ConfigError, match="not valid JSON"): + AgentProject.load(tmp_path) + + +def agent_load_skips_odd_entries(tmp_path): + (tmp_path / ".env").write_text("", encoding="utf-8") + entries = {"databases": ["junk", {"type": "sqlite", "database": "/config/a"}]} + (tmp_path / "databases.json").write_text(json.dumps(entries), encoding="utf-8") + assert [database.engine for database in AgentProject.load(tmp_path).databases] == [ + "sqlite" + ] + + +def dashboard_load(dashboard, tmp_path): + dashboard.env.save() + loaded = DashboardProject.load(dashboard.path) + assert loaded.env.as_dict() == dashboard.env.as_dict() + with pytest.raises(ConfigError, match="Not a Portabase dashboard folder"): + DashboardProject.load(tmp_path) + + +@pytest.mark.parametrize( + ("host", "mode"), [(None, "internal"), ("db", "external"), ("pg.example", "custom")] +) +def dashboard_db_mode(dashboard, host, mode): + if host: + dashboard.env.set("POSTGRES_HOST", host) + assert dashboard.db_mode == mode + + +def dashboard_project_name(dashboard): + assert dashboard.project_name == "pb" + dashboard.env.remove("PROJECT_NAME") + assert dashboard.project_name == dashboard.path.name + + +def dashboard_settings(dashboard): + assert dashboard.setting("password_auth") is True + assert dashboard.setting("api") is False + assert dashboard.setting("url") == "https://d.example" + dashboard.set("api", True) + assert dashboard.env.get("API_ENABLED") == "true" + dashboard.unset("api") + assert dashboard.env.get("API_ENABLED") is None + + +def dashboard_add_providers(dashboard): + dashboard.add_provider(KEYCLOAK) + dashboard.add_provider(GITHUB) + env = dashboard.env.as_dict() + assert {key: value for key, value in env.items() if key.startswith("AUTH_")} == { + "AUTH_OIDC_MY_KC_ID": "my-kc", + "AUTH_OIDC_MY_KC_ISSUER_URL": "https://kc.example", + "AUTH_OIDC_MY_KC_CLIENT": "c", + "AUTH_OIDC_MY_KC_SECRET": "s", + "AUTH_OIDC_MY_KC_PKCE": "true", + "AUTH_SOCIAL_GITHUB_CLIENT": "gc", + "AUTH_SOCIAL_GITHUB_SECRET": "gs", + "AUTH_SOCIAL_GITHUB_TITLE": "GitHub", + } + assert dashboard.providers == [ + AuthProvider( + "oauth", "github", {"client": "gc", "secret": "gs", "title": "GitHub"} + ), + AuthProvider( + "oidc", + "my-kc", + { + "issuer": "https://kc.example", + "client": "c", + "secret": "s", + "pkce": "true", + }, + ), + ] + + +def dashboard_recovers_oidc_id_without_id_variable(dashboard): + dashboard.env.merge( + {"AUTH_OIDC_AZURE_AD_CLIENT": "c", "AUTH_OIDC_AZURE_AD_SECRET": "s"} + ) + assert [provider.id for provider in dashboard.providers] == ["azure-ad"] + + +def dashboard_add_duplicate_provider(dashboard): + dashboard.add_provider(GITHUB) + with pytest.raises(ValidationError, match="already exists"): + dashboard.add_provider(GITHUB) + + +def dashboard_remove_provider(dashboard): + dashboard.add_provider(KEYCLOAK) + dashboard.add_provider(GITHUB) + assert dashboard.remove_provider("my-kc").kind == "oidc" + assert [provider.id for provider in dashboard.providers] == ["github"] + assert not any(key.startswith("AUTH_OIDC_") for key in dashboard.env.as_dict()) + with pytest.raises(ValidationError, match="No provider named"): + dashboard.remove_provider("my-kc") + + +def dashboard_callback_url(dashboard): + assert ( + dashboard.callback_url("my-kc") + == "https://d.example/api/auth/sso/callback/my-kc" + ) + + +def dashboard_defaults_are_valid(dashboard): + dashboard.validate() + + +def dashboard_skip_onboarding_needs_an_account(dashboard): + dashboard.set("skip_onboarding", True) + dashboard.set("admin_email", "a@b.c") + with pytest.raises(ValidationError, match="needs an initial account"): + dashboard.validate() + dashboard.set("admin_password", "Abcdef1!") + dashboard.validate() + + +def dashboard_password_auth_off_needs_a_provider(dashboard): + dashboard.set("password_auth", False) + with pytest.raises(ValidationError, match="lock everyone out"): + dashboard.save_state() + assert not dashboard.env.exists + dashboard.add_provider(GITHUB) + dashboard.save_state() + assert dashboard.env.exists + + +@pytest.mark.parametrize("url", ["http://localhost:8887", "http://127.0.0.1"]) +def dashboard_providers_need_a_public_url(dashboard, url): + dashboard.add_provider(GITHUB) + dashboard.set("url", url) + with pytest.raises(ValidationError, match="need a public URL"): + dashboard.validate() diff --git a/tests/services/renderer.py b/tests/services/renderer.py new file mode 100644 index 0000000..8ebc363 --- /dev/null +++ b/tests/services/renderer.py @@ -0,0 +1,220 @@ +import dataclasses +import json +import re + +import pytest +import yaml + +from core.errors import TemplateError +from engines import registry +from services.compose_facts import CA_BUNDLE_IN_CONTAINER, GENERATED_MARKER +from services.renderer import LEGACY_BACKUP, RenderResult +from tests.support import agent_service + +TEMPLATED = [engine for engine in registry if engine.template is not None] +VARIANTS = [ + (engine, auth) + for engine in TEMPLATED + for auth in ((True, False) if engine.auth_variants else (True,)) +] +VAR = re.compile(r"\$\{([A-Za-z0-9_]+)\}") +# Values that break an unquoted "- KEY=value" item or a naive double-quoted one. +NASTY = ["abc:", "a: b", "#start", 'q"uote', "back\\slash", "ends\\", "{x}", "[y]"] + + +def _parse(result): + result.validate() + return yaml.safe_load(result.compose) + + +def _assert_vars_defined(compose, env): + missing = set(VAR.findall(compose)) - set(env.as_dict()) + assert not missing, f"compose references undefined variables: {missing}" + + +def _password_values(service): + values = [] + for item in service.get("environment") or []: + assert isinstance(item, str), f"environment item parsed as {item!r}" + key, _, value = item.partition("=") + if key.endswith("PASSWORD") and key != "MYSQL_RANDOM_ROOT_PASSWORD": + values.append(value) + command = service.get("command") + if isinstance(command, list) and "--requirepass" in command: + values.append(command[command.index("--requirepass") + 1]) + return values + + +def header_marks_the_file_as_generated(renderer): + assert renderer.header() == f"{GENERATED_MARKER} test. Do not edit.\n" + + +def agent_without_databases(agent, renderer): + result = renderer.render_agent(agent) + assert _parse(result) == { + "services": {"agent": agent_service()}, + "networks": {"portabase": {"name": "portabase_network", "external": True}}, + } + assert result.databases == [] + + +def agent_inline(agent, renderer): + result = renderer.render_agent(agent, inline=True) + assert _parse(result)["services"] == {"agent": agent_service(inline=True)} + assert "${" not in result.compose + + +def agent_with_every_option(agent, renderer, ports): + sqlite, volume = registry.get("sqlite"), registry.get("docker-volume") + agent.host_gateway = True + agent.add(sqlite.generate(auth=False, ports=ports, answers={"name": "x"}), sqlite) + agent.add(volume.from_existing({"volume": "v"}), volume) + agent.ca_bundle = "./ca.crt" + agent.set("retry_attempts", 3) + result = renderer.render_agent(agent) + expected = agent_service( + "./x.sqlite:/config/x.sqlite", + "/var/run/docker.sock:/var/run/docker.sock", + f"./ca.crt:{CA_BUNDLE_IN_CONTAINER}:ro", + ) + expected["extra_hosts"] = ["localhost:host-gateway"] + expected["environment"]["RETRY_ATTEMPTS"] = "${RETRY_ATTEMPTS}" + expected["environment"]["SSL_CERT_FILE"] = "${SSL_CERT_FILE}" + assert _parse(result)["services"] == {"agent": expected} + assert [entry["type"] for entry in result.databases or []] == [ + "sqlite", + "docker-volume", + ] + _assert_vars_defined(result.compose, agent.env) + + +def agent_with_every_engine(agent, renderer, ports): + specs = [] + for engine, auth in VARIANTS: + spec = engine.generate(auth=auth, ports=ports, answers={}) + agent.add(spec, engine) + specs.append(spec) + result = renderer.render_agent(agent) + doc = _parse(result) + assert set(doc["services"]) == {"agent", *(spec.host for spec in specs)} + assert set(doc["volumes"]) == {f"{spec.host}-data" for spec in specs} + assert result.databases == [ + registry.get(spec.engine).agent_entry(spec) for spec in specs + ] + _assert_vars_defined(result.compose, agent.env) + + +@pytest.mark.parametrize("password", NASTY) +@pytest.mark.parametrize("engine", TEMPLATED, ids=lambda engine: engine.key) +def agent_inline_keeps_passwords_intact(engine, password, agent, renderer, ports): + spec = dataclasses.replace( + engine.generate(auth=True, ports=ports, answers={}), + password=password, + root_password=password if engine.key == "firebird" else None, + ) + agent.add(spec, engine) + doc = _parse(renderer.render_agent(agent, inline=True)) + values = _password_values(doc["services"][spec.host]) + assert values + assert all(value == password for value in values) + + +@pytest.mark.parametrize( + ("mode", "services", "volumes"), + [ + ("external", {"portabase", "db"}, {"postgres-data", "portabase-data"}), + ("internal", {"portabase"}, {"portabase-data"}), + ("custom", {"portabase"}, {"portabase-data"}), + ], +) +def dashboard_modes(dashboard_for, renderer, mode, services, volumes): + project = dashboard_for(mode) + result = renderer.render_dashboard(project) + doc = _parse(result) + assert doc["name"] == "pb" + assert set(doc["services"]) == services + assert set(doc["volumes"]) == volumes + assert ("depends_on" in doc["services"]["portabase"]) is (mode == "external") + assert result.databases is None + _assert_vars_defined(result.compose, project.env) + + +def dashboard_inline(dashboard_for, renderer): + result = renderer.render_dashboard(dashboard_for("external"), inline=True) + doc = _parse(result) + assert "${" not in result.compose + assert doc["services"]["portabase"]["ports"] == ["8887:80"] + assert "POSTGRES_PASSWORD=p" in doc["services"]["db"]["environment"] + + +@pytest.mark.parametrize("password", NASTY) +def dashboard_inline_keeps_passwords_intact(password, dashboard_for, renderer): + project = dashboard_for( + "external", POSTGRES_PASSWORD=password, PROJECT_SECRET=password + ) + doc = _parse(renderer.render_dashboard(project, inline=True)) + assert _password_values(doc["services"]["db"]) == [password] + assert f"PROJECT_SECRET={password}" in doc["services"]["portabase"]["environment"] + + +@pytest.mark.parametrize("compose", ["services: [unclosed", "name: x\n", "- a\n"]) +def validate_rejects_broken_compose(compose): + with pytest.raises(TemplateError): + RenderResult(compose).validate() + + +def write_new_folder(tmp_path): + folder = tmp_path / "new" + result = RenderResult("services: {}\n", databases=[{"type": "sqlite"}]) + report = result.write(folder) + compose, databases = folder / "docker-compose.yml", folder / "databases.json" + assert report.wrote == [compose, databases] + assert report.backed_up is None + assert compose.read_text(encoding="utf-8") == "services: {}\n" + assert json.loads(databases.read_text(encoding="utf-8")) == { + "databases": [{"type": "sqlite"}] + } + assert not list(folder.glob("*.tmp")) + + +def write_skips_databases_for_dashboards(tmp_path): + RenderResult("services: {}\n").write(tmp_path) + assert not (tmp_path / "databases.json").exists() + + +def write_backs_up_a_hand_written_compose_once(tmp_path, renderer): + compose = tmp_path / "docker-compose.yml" + compose.write_text("services: {legacy: {}}\n", encoding="utf-8") + generated = RenderResult(renderer.header() + "services: {}\n") + + report = generated.write(tmp_path) + assert report.backed_up == tmp_path / LEGACY_BACKUP + assert report.backed_up.read_text(encoding="utf-8") == "services: {legacy: {}}\n" + + compose.write_text("services: {edited: {}}\n", encoding="utf-8") + assert generated.write(tmp_path).backed_up is None + backup = (tmp_path / LEGACY_BACKUP).read_text(encoding="utf-8") + assert backup == "services: {legacy: {}}\n" + + +def write_leaves_a_generated_compose_alone(tmp_path, renderer): + generated = RenderResult(renderer.header() + "services: {}\n") + generated.write(tmp_path) + assert generated.write(tmp_path).backed_up is None + assert not (tmp_path / LEGACY_BACKUP).exists() + + +def write_refuses_an_invalid_compose(tmp_path): + with pytest.raises(TemplateError): + RenderResult("nope: 1\n").write(tmp_path) + assert not (tmp_path / "docker-compose.yml").exists() + + +def diff_against_the_current_file(tmp_path): + result = RenderResult("services:\n a: {}\n") + assert "+services:" in result.diff_against(tmp_path) + result.write(tmp_path) + assert result.diff_against(tmp_path) == "" + changed = RenderResult("services:\n b: {}\n").diff_against(tmp_path) + assert "- a: {}" in changed + assert "+ b: {}" in changed diff --git a/tests/services/settings.py b/tests/services/settings.py new file mode 100644 index 0000000..3c5c802 --- /dev/null +++ b/tests/services/settings.py @@ -0,0 +1,144 @@ +import base64 +import json + +import pytest + +from core.errors import ValidationError +from core.fields import Field +from services import settings as cfg +from tests.support import EDGE_KEY_PAYLOAD + + +def _setting(kind, default=None): + return cfg.Setting(Field("x", "X", kind, default=default), "X", "s") + + +def strong_password_accepts(): + assert cfg.strong_password("Abcdef1!") == "Abcdef1!" + + +@pytest.mark.parametrize( + ("value", "missing"), + [ + ("Ab1!", "at least 8 characters"), + ("ABCDEFG1!", "a lowercase letter"), + ("abcdefg1!", "an uppercase letter"), + ("Abcdefgh!", "a digit"), + ("Abcdefgh1", "a special character"), + ], +) +def strong_password_rejects(value, missing): + with pytest.raises(ValidationError) as exc: + cfg.strong_password(value) + assert missing in (exc.value.hint or "") + + +def strong_password_lists_every_missing_rule(): + with pytest.raises(ValidationError) as exc: + cfg.strong_password("") + assert exc.value.hint == ( + "It needs at least 8 characters, a lowercase letter, an uppercase letter, " + "a digit, a special character." + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("https://portabase.example", "https://portabase.example"), + ("https://portabase.example/", "https://portabase.example"), + ("http://localhost:8887", "http://localhost:8887"), + ("https://x.example/sub/", "https://x.example/sub"), + ], +) +def public_url_accepts(value, expected): + assert cfg.public_url(value) == expected + + +@pytest.mark.parametrize( + "value", ["", "portabase.example", "ftp://x", "https://", "https:// x"] +) +def public_url_rejects(value): + with pytest.raises(ValidationError, match="Invalid URL"): + cfg.public_url(value) + + +def edge_key_validator(): + key = base64.b64encode(json.dumps(EDGE_KEY_PAYLOAD).encode()).decode() + assert cfg.edge_key(key) == key + with pytest.raises(ValidationError, match="Invalid Edge Key"): + cfg.edge_key("bad") + + +def positive_validator(): + assert cfg.positive(1) == 1 + for value in (0, -5): + with pytest.raises(ValidationError): + cfg.positive(value) + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("true", True), + ("TRUE", True), + (" yes ", True), + ("1", True), + ("on", True), + ("false", False), + ("0", False), + ("", False), + ], +) +def bool_from_env(raw, expected): + assert _setting("bool").from_env(raw) is expected + + +def bool_to_env(): + assert _setting("bool").to_env(True) == "true" + assert _setting("bool").to_env(False) == "false" + + +@pytest.mark.parametrize( + ("raw", "expected"), [("42", 42), (" 7 ", 7), ("-3", -3), ("abc", "abc")] +) +def int_from_env(raw, expected): + assert _setting("int").from_env(raw) == expected + + +@pytest.mark.parametrize("kind", ["bool", "int", "text"]) +def from_env_none_is_the_default(kind): + assert _setting(kind, default="d").from_env(None) == "d" + + +def text_round_trip(): + assert _setting("text").to_env(5) == "5" + assert _setting("text").from_env(" raw ") == " raw " + + +@pytest.mark.parametrize( + "registry", [cfg.AGENT, cfg.DASHBOARD], ids=["agent", "dashboard"] +) +def registry_is_consistent(registry): + names = [setting.name for setting in registry] + envs = [setting.env for setting in registry if setting.env] + assert len(names) == len(set(names)) + assert len(envs) == len(set(envs)) + assert registry.names() == names + for setting in registry: + assert setting.section in registry.sections + assert registry.get(setting.name) is setting + assert sum(len(registry.in_section(key)) for key in registry.sections) == len(names) + + +def registry_unknown_setting(): + with pytest.raises(ValidationError, match="Unknown setting 'nope'") as exc: + cfg.AGENT.get("nope") + assert "polling" in (exc.value.hint or "") + + +def dashboard_wizard_names_exist(): + for section, names in cfg.DASHBOARD_WIZARD: + assert section in cfg.DASHBOARD.sections + for name in names: + assert cfg.DASHBOARD.get(name).section == section diff --git a/tests/services/telemetry.py b/tests/services/telemetry.py new file mode 100644 index 0000000..ae0a69c --- /dev/null +++ b/tests/services/telemetry.py @@ -0,0 +1,29 @@ +import pytest + +from services.telemetry import NoopTelemetry, Telemetry + + +def telemetry_is_abstract(): + with pytest.raises(TypeError): + Telemetry() + + +def noop_session_and_span_are_context_managers(): + telemetry = NoopTelemetry() + with ( + telemetry.session(command="agent create") as session, + telemetry.span("render", engine="postgresql") as span, + ): + assert (session, span) == (None, None) + + +def noop_span_lets_exceptions_through(): + with pytest.raises(ValueError, match="boom"), NoopTelemetry().span("render"): + raise ValueError("boom") + + +def noop_records_nothing(): + telemetry = NoopTelemetry() + assert telemetry.event("created", engine="redis") is None + assert telemetry.error(ValueError("x"), unexpected=True) is None + assert telemetry.flush() is None diff --git a/tests/services/templates.py b/tests/services/templates.py new file mode 100644 index 0000000..1ba6fd7 --- /dev/null +++ b/tests/services/templates.py @@ -0,0 +1,46 @@ +import pytest + +from core.errors import TemplateError +from services.renderer import ComposeRenderer +from services.templates import TemplateRepository +from tests.support import ROOT + + +def names_are_sorted_and_complete(templates): + names = templates.names() + assert "agent.yml.j2" in names + assert "dashboard.yml.j2" in names + assert "engines/postgresql.yml.j2" in names + assert names == sorted(names) + + +def bundled_defaults_to_repo_templates(monkeypatch): + monkeypatch.delenv("PORTABASE_TEMPLATES_DIR", raising=False) + assert TemplateRepository.bundled().root == ROOT / "templates" + + +def bundled_honours_override(monkeypatch, tmp_path): + monkeypatch.setenv("PORTABASE_TEMPLATES_DIR", str(tmp_path)) + assert TemplateRepository.bundled().root == tmp_path + + +def empty_folder(tmp_path): + with pytest.raises(TemplateError, match="No templates found"): + TemplateRepository(tmp_path).get("agent.yml.j2") + + +def missing_template(templates): + with pytest.raises(TemplateError, match="'engines/nope.yml.j2' is missing"): + templates.get("engines/nope.yml.j2") + + +def broken_template(tmp_path): + (tmp_path / "agent.yml.j2").write_text("services: {}\n", encoding="utf-8") + (tmp_path / "bad.j2").write_text("{% if %}", encoding="utf-8") + with pytest.raises(TemplateError, match="failed to load"): + TemplateRepository(tmp_path).get("bad.j2") + + +def undefined_variable_is_an_error(templates): + with pytest.raises(TemplateError, match="rendering failed"): + ComposeRenderer._render_template(templates.get("agent.yml.j2"), {}) diff --git a/tests/services/updater.py b/tests/services/updater.py new file mode 100644 index 0000000..dc6fc0e --- /dev/null +++ b/tests/services/updater.py @@ -0,0 +1,271 @@ +import hashlib +import json +import sys +import tempfile +import time +from pathlib import Path + +import pytest + +from core.config import GlobalConfig +from core.errors import UpdateError +from core.version import UNKNOWN +from services import updater +from services.updater import ( + RELEASES_URL, + Release, + UpdateChecker, + Updater, + platform_asset_name, +) +from tests.support import FakeHttp + +LATEST = f"{RELEASES_URL}/latest" +PAYLOAD = b"new binary" + + +def _api_release(tag, prerelease=False, assets=()): + return { + "tag_name": tag, + "prerelease": prerelease, + "assets": [ + {"name": name, "browser_download_url": f"https://dl/{name}"} + for name in assets + ], + } + + +def _sha(data=PAYLOAD): + return hashlib.sha256(data).hexdigest() + + +def _published(checksums=None, *, binary=True): + name = platform_asset_name() + assets = {} + http = FakeHttp() + if binary: + assets[name] = f"https://dl/{name}" + http.files[assets[name]] = PAYLOAD + if checksums is not None: + assets["checksums.txt"] = "https://dl/checksums.txt" + http.text["https://dl/checksums.txt"] = checksums + return Updater(http, "1.0.0"), Release("2.0.0", assets, False) + + +@pytest.fixture +def config(tmp_path): + return GlobalConfig(tmp_path / "config.json") + + +@pytest.fixture +def downloads(monkeypatch, tmp_path): + folder = tmp_path / "downloads" + folder.mkdir() + monkeypatch.setattr(tempfile, "tempdir", str(folder)) + return folder + + +def release_from_api(): + data = _api_release("v1.2.3", prerelease=True, assets=["a", "b"]) + assert Release.from_api(data) == Release( + "1.2.3", {"a": "https://dl/a", "b": "https://dl/b"}, True + ) + assert Release.from_api({}) == Release("", {}, False) + + +@pytest.mark.parametrize( + ("system", "machine", "expected"), + [ + ("Linux", "x86_64", "portabase_linux_amd64"), + ("Linux", "aarch64", "portabase_linux_arm64"), + ("Darwin", "arm64", "portabase_macos_arm64"), + ("Darwin", "x86_64", "portabase_macos_amd64"), + ("Windows", "AMD64", "portabase_windows_amd64.exe"), + ], +) +def asset_name_per_platform(monkeypatch, system, machine, expected): + monkeypatch.setattr(updater.platform, "system", lambda: system) + monkeypatch.setattr(updater.platform, "machine", lambda: machine) + assert platform_asset_name() == expected + + +def frozen_flag(monkeypatch): + assert not updater.is_frozen() + monkeypatch.setattr(sys, "frozen", True, raising=False) + assert updater.is_frozen() + + +@pytest.mark.parametrize( + ("channel", "current", "expected"), + [ + (None, "1.0.0", False), + (None, "1.0.0rc1", True), + ("beta", "1.0.0", True), + ("stable", "1.0.0rc1", False), + ], +) +def checker_include_prerelease(config, channel, current, expected): + if channel: + config.set("update_channel", channel) + assert UpdateChecker(FakeHttp(), config, current).include_prerelease is expected + + +def checker_stable_uses_latest(config): + http = FakeHttp(json={LATEST: _api_release("1.1.0")}) + assert UpdateChecker(http, config, "1.0.0").available() == "1.1.0" + assert http.calls == [LATEST] + + +def checker_beta_uses_the_first_release(config): + config.set("update_channel", "beta") + releases = [_api_release("1.1.0rc1", True), _api_release("1.0.0")] + http = FakeHttp(json={RELEASES_URL: releases}) + assert UpdateChecker(http, config, "1.0.0").available() == "1.1.0rc1" + + +def checker_beta_without_releases(config): + config.set("update_channel", "beta") + http = FakeHttp(json={RELEASES_URL: []}) + assert UpdateChecker(http, config, "1.0.0").available() is None + + +@pytest.mark.parametrize("tag", ["1.0.0", "0.9.0", "1.0.0rc1"]) +def checker_nothing_newer(config, tag): + http = FakeHttp(json={LATEST: _api_release(tag)}) + assert UpdateChecker(http, config, "1.0.0").available() is None + + +def checker_unknown_version_never_checks(config): + http = FakeHttp() + assert UpdateChecker(http, config, UNKNOWN).available() is None + assert http.calls == [] + + +def checker_network_error_is_silent(config): + assert UpdateChecker(FakeHttp(), config, "1.0.0").available() is None + assert not (config.cache_dir / "release.json").exists() + + +def checker_caches_the_release(config): + http = FakeHttp(json={LATEST: _api_release("1.1.0")}) + checker = UpdateChecker(http, config, "1.0.0") + assert checker.latest() == Release("1.1.0", {}, False) + assert checker.latest() == Release("1.1.0", {}, False) + assert http.calls == [LATEST] + checker.latest(force=True) + assert http.calls == [LATEST, LATEST] + + +def checker_refetches_an_expired_cache(config): + http = FakeHttp(json={LATEST: _api_release("1.1.0")}) + checker = UpdateChecker(http, config, "1.0.0") + checker.latest() + data = json.loads(checker.cache_file.read_text(encoding="utf-8")) + data["checked_at"] = time.time() - updater.CACHE_TTL - 1 + checker.cache_file.write_text(json.dumps(data), encoding="utf-8") + checker.latest() + assert http.calls == [LATEST, LATEST] + + +def checker_channel_change_invalidates_the_cache(config): + http = FakeHttp( + json={ + LATEST: _api_release("1.1.0"), + RELEASES_URL: [_api_release("1.2.0rc1", True)], + } + ) + checker = UpdateChecker(http, config, "1.0.0") + assert checker.available() == "1.1.0" + config.set("update_channel", "beta") + assert checker.available() == "1.2.0rc1" + + +def checker_survives_a_corrupt_cache(config): + http = FakeHttp(json={LATEST: _api_release("1.1.0")}) + checker = UpdateChecker(http, config, "1.0.0") + checker.cache_file.parent.mkdir(parents=True) + checker.cache_file.write_text("{oops", encoding="utf-8") + assert checker.available() == "1.1.0" + + +@pytest.mark.parametrize("line", ["{sha} {name}", "{sha} *{name}", "{SHA} {name}"]) +def updater_download_verified(downloads, line): + name = platform_asset_name() + listed = line.format(sha=_sha(), SHA=_sha().upper(), name=name) + updater, release = _published(f"deadbeef other\n{listed}\n") + progress = [] + path = updater.download(release, progress.append) + assert path.read_bytes() == PAYLOAD + assert path.parent == downloads + assert progress == [len(PAYLOAD)] + + +@pytest.mark.parametrize( + ("checksums", "message"), + [ + (None, "has no checksums.txt"), + ("abc other_asset\n", "not listed in checksums.txt"), + ("{bad} {name}\n", "Checksum mismatch"), + ], +) +def updater_download_refused(downloads, checksums, message): + if checksums is not None: + checksums = checksums.format(bad=_sha(b"tampered"), name=platform_asset_name()) + updater, release = _published(checksums) + with pytest.raises(UpdateError, match=message): + updater.download(release) + assert list(downloads.iterdir()) == [] + + +def updater_no_binary_for_this_platform(): + updater, release = _published(f"{_sha()} x\n", binary=False) + with pytest.raises(UpdateError, match="No binary for this platform") as exc: + updater.download(release) + assert exc.value.hint == "Available: checksums.txt" + updater, empty = _published(None, binary=False) + with pytest.raises(UpdateError) as exc: + updater.download(empty) + assert exc.value.hint is None + + +def updater_expected_size(): + updater, release = _published(None) + assert updater.expected_size(release) == len(PAYLOAD) + assert updater.expected_size(Release("2.0.0", {}, False)) is None + + +def updater_install_replaces_and_keeps_a_backup(tmp_path): + target = tmp_path / "bin" / "portabase" + target.parent.mkdir() + target.write_bytes(b"old") + tmp = tmp_path / "download" + tmp.write_bytes(PAYLOAD) + Updater(FakeHttp(), "1.0.0").install(tmp, target) + assert target.read_bytes() == PAYLOAD + assert (target.stat().st_mode & 0o777) == 0o755 + assert target.with_name("portabase.old").read_bytes() == b"old" + assert not tmp.exists() + + +def updater_install_fresh(tmp_path): + target = tmp_path / "new" / "bin" / "portabase" + tmp = tmp_path / "download" + tmp.write_bytes(PAYLOAD) + Updater(FakeHttp(), "1.0.0").install(tmp, target) + assert target.read_bytes() == PAYLOAD + assert not target.with_name("portabase.old").exists() + + +def updater_target_path_frozen(monkeypatch, tmp_path): + exe = tmp_path / "portabase" + exe.write_bytes(b"") + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "executable", str(exe)) + assert Updater(FakeHttp(), "1.0.0").target_path() == exe.resolve() + + +def updater_target_path_windows(monkeypatch, tmp_path): + monkeypatch.setattr(updater.platform, "system", lambda: "Windows") + monkeypatch.setenv("APPDATA", str(tmp_path)) + expected = Path(tmp_path) / "Portabase" / "portabase.exe" + assert Updater(FakeHttp(), "1.0.0").target_path() == expected diff --git a/tests/structure.py b/tests/structure.py new file mode 100644 index 0000000..c3c95fd --- /dev/null +++ b/tests/structure.py @@ -0,0 +1,89 @@ +import ast + +from engines import registry +from tests.support import ROOT + +TESTS = ROOT / "tests" +PACKAGES = ("core", "services", "engines") +# Modules whose tests live under another name. +ALIASES = {"engines/__init__.py": "engines/registry.py"} +SHARED = {"conftest.py", "support.py", "structure.py"} +ENGINE_SKELETON = [ + "attributes", + "generate", + "env_vars", + "agent_entry", + "fields", + "from_existing", + "compose_service", + "compose_service_inline", +] + + +def _functions(path): + tree = ast.parse(path.read_text(encoding="utf-8")) + return [ + node.name + for node in tree.body + if isinstance(node, ast.FunctionDef) and not node.name.startswith("_") + ] + + +def every_module_has_a_test_file(): + missing = [] + for package in PACKAGES: + for module in sorted((ROOT / package).glob("*.py")): + rel = f"{package}/{module.name}" + if module.name == "__init__.py" and rel not in ALIASES: + continue + if not (TESTS / ALIASES.get(rel, rel)).exists(): + missing.append(rel) + assert not missing, f"modules without tests: {missing}" + + +def every_engine_has_a_test_file(): + missing = [ + engine.key + for engine in registry + if not (TESTS / "engines" / f"{engine.key.replace('-', '_')}.py").exists() + ] + assert not missing, f"engines without tests/engines/.py: {missing}" + + +def engine_files_follow_the_same_skeleton(): + wrong = {} + for engine in registry: + path = TESTS / "engines" / f"{engine.key.replace('-', '_')}.py" + if not path.exists(): + continue + head = _functions(path)[: len(ENGINE_SKELETON)] + if head != ENGINE_SKELETON: + wrong[path.name] = head + assert not wrong, f"expected {ENGINE_SKELETON} first, got {wrong}" + + +def every_test_file_mirrors_a_module_or_an_engine(): + engines = {engine.key.replace("-", "_") for engine in registry} + stray = [] + for path in sorted(TESTS.rglob("*.py")): + rel = path.relative_to(TESTS).as_posix() + if path.name == "__init__.py" or rel in SHARED: + continue + if (ROOT / rel).exists() or rel in ALIASES.values(): + continue + if path.parent.name == "engines" and path.stem in engines: + continue + stray.append(rel) + assert not stray, f"test files matching no module: {stray}" + + +def tests_have_no_prefix_and_no_classes(): + offenders = [] + for path in sorted(TESTS.rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in tree.body: + if isinstance(node, ast.FunctionDef) and node.name.startswith("test_"): + offenders.append(f"{path.name}::{node.name}") + if isinstance(node, ast.ClassDef) and node.name.startswith("Test"): + offenders.append(f"{path.name}::{node.name}") + assert not offenders, f"use plain names, no test_/Test prefix: {offenders}" diff --git a/tests/support.py b/tests/support.py new file mode 100644 index 0000000..b8df74c --- /dev/null +++ b/tests/support.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import json +import os +import struct +from collections.abc import Callable, Iterable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from core.errors import NetworkError +from core.fields import Field +from core.specs import DatabaseSpec + +ROOT = Path(__file__).resolve().parent.parent + +EDGE_KEY_PAYLOAD = {"serverUrl": "http://x", "agentId": "a", "masterKeyB64": "k"} +AGENT_ENV = {"TZ": "UTC", "EDGE_KEY": "x", "LOG_LEVEL": "info", "POLLING": "5"} +DASHBOARD_BASE = { + "HOST_PORT": "8887", + "PROJECT_SECRET": "s", + "PROJECT_URL": "http://localhost:8887", + "PROJECT_NAME": "pb", + "TZ": "UTC", + "LOG_LEVEL": "info", +} +DASHBOARD_PG = { + "POSTGRES_DB": "pb", + "POSTGRES_USER": "pb", + "POSTGRES_PASSWORD": "p", + "PG_PORT": "5433", + "DATABASE_URL": "postgresql://pb:p@db:5432/pb", +} +DASHBOARD_MODES = { + "external": {**DASHBOARD_BASE, **DASHBOARD_PG, "POSTGRES_HOST": "db"}, + "internal": DASHBOARD_BASE, + "custom": {**DASHBOARD_BASE, **DASHBOARD_PG, "POSTGRES_HOST": "remote"}, +} +EXISTING_ANSWERS = { + "host": "db.example", + "port": "1234", + "database": "app", + "username": "u", + "password": "p", +} + + +@dataclass +class FakeHttp: + json: dict[str, Any] = field(default_factory=dict) + text: dict[str, str] = field(default_factory=dict) + files: dict[str, bytes] = field(default_factory=dict) + calls: list[str] = field(default_factory=list) + + def _lookup(self, table: dict[str, Any], url: str) -> Any: + self.calls.append(url) + if url not in table: + raise NetworkError(f"GET {url} failed: 404") + return table[url] + + def get_json(self, url: str) -> Any: + return self._lookup(self.json, url) + + def get_text(self, url: str) -> str: + return self._lookup(self.text, url) + + def download( + self, + url: str, + dest: Path, + on_progress: Callable[[int], None] | None = None, + *, + timeout: float = 30.0, + ) -> int: + data = self._lookup(self.files, url) + dest.write_bytes(data) + if on_progress: + on_progress(len(data)) + return len(data) + + def content_length(self, url: str) -> int | None: + data = self.files.get(url) + return len(data) if data is not None else None + + +@dataclass +class Rendered: + spec: DatabaseSpec + doc: dict[str, Any] + env: dict[str, str] + databases: list[dict[str, Any]] + + @property + def agent(self) -> dict[str, Any]: + return self.doc["services"]["agent"] + + @property + def service(self) -> dict[str, Any]: + return self.doc["services"][self.spec.host] + + def var(self, suffix: str) -> str: + return f"${{{self.spec.env_prefix}_{suffix}}}" + + +def encrypt(plain: bytes, key: bytes, chunk_size: int = 4) -> bytes: + """Build a .enc file the way the Portabase agent writes one.""" + base_nonce = os.urandom(8) + header = { + "cipher": "AES-256-GCM", + "base_nonce": list(base_nonce), + "chunk_size": chunk_size, + } + out = json.dumps(header).encode() + b"\n" + aes = AESGCM(key) + for index, start in enumerate(range(0, len(plain), chunk_size)): + nonce = base_nonce + struct.pack(">I", index) + ciphertext = aes.encrypt(nonce, plain[start : start + chunk_size], None) + out += struct.pack(">I", len(ciphertext)) + ciphertext + return out + + +def field_specs(fields: Iterable[Field]) -> list[tuple[str, str, Any]]: + return [(field.name, field.kind, field.default) for field in fields] + + +def agent_service(*volumes: str, inline: bool = False) -> dict[str, Any]: + """The agent service as rendered with no option turned on.""" + environment = ( + dict(AGENT_ENV) if inline else {key: f"${{{key}}}" for key in AGENT_ENV} + ) + return { + "restart": "unless-stopped", + "image": "portabase/agent:latest", + "volumes": ["./databases.json:/config/config.json", *volumes], + "environment": environment, + "networks": ["portabase"], + } diff --git a/ui/components/progress.py b/ui/components/progress.py index 3082328..94f1826 100644 --- a/ui/components/progress.py +++ b/ui/components/progress.py @@ -31,4 +31,4 @@ def download(self, description: str, total: int) -> Iterator[Callable[[int], Non console=self.console, ) as progress: task = progress.add_task(description, total=total or None) - yield lambda n: progress.update(task, advance=n) + yield lambda amount: progress.update(task, advance=amount) diff --git a/ui/components/prompt.py b/ui/components/prompt.py index 28a926f..02ba91a 100644 --- a/ui/components/prompt.py +++ b/ui/components/prompt.py @@ -21,8 +21,8 @@ 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" + validate=lambda value: ( + value.strip().lstrip("-").isdigit() or "Enter a whole number" ), style=self.style, ).ask() diff --git a/ui/components/table.py b/ui/components/table.py index 193fc91..64ac262 100644 --- a/ui/components/table.py +++ b/ui/components/table.py @@ -12,8 +12,8 @@ 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 index, col in enumerate(columns): + table.add_column(col, style=_STYLES[index % len(_STYLES)]) for row in rows: - table.add_row(*[str(c) for c in row]) + table.add_row(*[str(cell) for cell in row]) self.console.print(table) diff --git a/ui/form.py b/ui/form.py index 3f2003a..3727d72 100644 --- a/ui/form.py +++ b/ui/form.py @@ -16,14 +16,18 @@ 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 + "text": lambda field: self.prompt.text(field.prompt, default=field.default), + "int": lambda field: self.prompt.integer( + field.prompt, default=field.default ), - "path": lambda f: self.prompt.path(f.prompt, default=f.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: @@ -41,7 +45,7 @@ def ask(self, field: Field, value: Any | None = None) -> Any: 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} + 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" @@ -84,8 +88,8 @@ def _ask_until_valid(self, field: Field) -> Any: raise UserAbort() try: return self._coerce_and_validate(field, answer) - except ValidationError as e: - self.prompt.console.print(f"[danger]✖ {e.message}[/danger]") + 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) @@ -103,15 +107,15 @@ 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: + except ValueError as error: raise ValidationError( f"{field.flag} must be a whole number, got {value!r}" - ) from e + ) from error if field.kind == "bool" and not isinstance(value, bool): - s = str(value).strip().lower() - if s in _TRUE: + normalized = str(value).strip().lower() + if normalized in _TRUE: return True - if s in _FALSE: + 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"): From 788e9e569dd735f3a68b6a55cbdb3fa8d36decfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 14 Sep 2026 15:47:01 +0200 Subject: [PATCH 114/124] ci: track the build action, drop SARIF upload, pass gitleaks config via env --- .github/actions/build/action.yml | 45 ++++++++++++++++++++++++++++++++ .github/workflows/plumber.yml | 2 +- .github/workflows/security.yml | 3 +-- .gitignore | 4 +-- 4 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 .github/actions/build/action.yml 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/workflows/plumber.yml b/.github/workflows/plumber.yml index 5dc10b4..4aba54b 100644 --- a/.github/workflows/plumber.yml +++ b/.github/workflows/plumber.yml @@ -19,10 +19,10 @@ jobs: permissions: contents: read security-events: write + id-token: write steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: getplumber/plumber@7ad9d267ee5a00163cec9e5c749a088d5f565167 # v0.4.26 with: score-push: true - upload-sarif: true soft-fail: true diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 7a83a05..e4aac39 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -42,5 +42,4 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} - with: - config-path: .gitleaks.toml + GITLEAKS_CONFIG: .gitleaks.toml diff --git a/.gitignore b/.gitignore index e2556d1..fcb772d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -dist/ -build/ +/dist/ +/build/ .venv/ __pycache__/ # Keep portabase.spec: it is the build definition. From db29462eae6570eead71189d19edcbee72034648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 14 Sep 2026 15:58:16 +0200 Subject: [PATCH 115/124] Create .gitleaksignore --- .gitleaksignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitleaksignore 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 From 1b0d326bdee6f01bc6aaa1c07735099be8602f23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 14 Sep 2026 16:01:38 +0200 Subject: [PATCH 116/124] Update plumber.yml --- .github/workflows/plumber.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/plumber.yml b/.github/workflows/plumber.yml index 4aba54b..e29abf7 100644 --- a/.github/workflows/plumber.yml +++ b/.github/workflows/plumber.yml @@ -18,11 +18,11 @@ jobs: timeout-minutes: 10 permissions: contents: read - security-events: write 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 From 4f5e6f604fd98ac46092b5301adf8ca86c60fdec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 14 Sep 2026 16:12:17 +0200 Subject: [PATCH 117/124] fix --- .github/CONTRIBUTING.md | 4 ++-- .github/workflows/bump.yml | 9 ++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 735b9dc..b5916c9 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -254,8 +254,8 @@ 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. +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 the `RELEASE_TOKEN` repository secret (a fine-grained PAT with *Contents: read and write*). A tag pushed with the default `GITHUB_TOKEN` would not trigger the release workflows. +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/workflows/bump.yml b/.github/workflows/bump.yml index f8fe0f8..9a71216 100644 --- a/.github/workflows/bump.yml +++ b/.github/workflows/bump.yml @@ -23,10 +23,17 @@ jobs: 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: ${{ secrets.RELEASE_TOKEN }} + token: ${{ steps.app-token.outputs.token }} - name: Validate version against channel env: From 971ac95c73ae0a0611c479f977b6f41856f7b72e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:22:17 +0000 Subject: [PATCH 118/124] chore(release): 26.09.01 --- CITATION.cff | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 69b7e28..3953ae8 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -24,5 +24,5 @@ keywords: - management - integration license: Apache-2.0 -version: 26.08.12 -date-released: "2026-08-12" +version: 26.09.01 +date-released: "2026-09-14" diff --git a/pyproject.toml b/pyproject.toml index 3c41f35..f0fe0cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "portabase-cli" -version = "26.08.12" +version = "26.09.01" description = "The official command line interface (CLI) for managing and deploying Portabase instances with ease." readme = "README.md" requires-python = ">=3.12" From 79676f94260946453933ac5e72a17e7fe0009c58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 14 Sep 2026 16:27:20 +0200 Subject: [PATCH 119/124] fix --- pyproject.toml | 3 ++- uv.lock | 36 +++++++++++++++++++++++++++++++----- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3c41f35..16d26cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,8 @@ dependencies = [ "questionary>=2.1.0", "requests>=2.32.5", "pyyaml>=6.0.3", - "cryptography>=44.0.0", + "cryptography>=44.0.0; sys_platform != 'darwin' or platform_machine != 'x86_64'", + "cryptography>=44.0.0,<49; sys_platform == 'darwin' and platform_machine == 'x86_64'", "jinja2>=3.1", ] diff --git a/uv.lock b/uv.lock index 19cda1b..973426f 100644 --- a/uv.lock +++ b/uv.lock @@ -2,8 +2,10 @@ version = 1 revision = 3 requires-python = ">=3.12" resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version < '3.15'", + "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]] @@ -276,12 +278,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "48.0.1" +source = { registry = "https://pypi.org/simple" } +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' and sys_platform == 'darwin'", +] +dependencies = [ + { name = "cffi", marker = "platform_machine == 'x86_64' and platform_python_implementation != 'PyPy' and sys_platform == 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, +] + [[package]] name = "cryptography" version = "50.0.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "(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') or (python_full_version < '3.15' and sys_platform != 'darwin')", +] dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "cffi", marker = "(platform_machine != 'x86_64' and platform_python_implementation != 'PyPy') or (platform_python_implementation != 'PyPy' and sys_platform != 'darwin')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } wheels = [ @@ -657,7 +681,8 @@ name = "portabase-cli" version = "26.8.12" source = { virtual = "." } dependencies = [ - { name = "cryptography" }, + { name = "cryptography", version = "48.0.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "cryptography", version = "50.0.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, { name = "jinja2" }, { name = "pyyaml" }, { name = "questionary" }, @@ -676,7 +701,8 @@ dev = [ [package.metadata] requires-dist = [ - { name = "cryptography", specifier = ">=44.0.0" }, + { name = "cryptography", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'", specifier = ">=44.0.0" }, + { name = "cryptography", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'", specifier = ">=44.0.0,<49" }, { name = "jinja2", specifier = ">=3.1" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "questionary", specifier = ">=2.1.0" }, From 47013de67425cfab0f4a9da964dcb1b1bc7338b9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:28:07 +0000 Subject: [PATCH 120/124] chore(release): 26.09.1 --- CITATION.cff | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 3953ae8..c0254c6 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -24,5 +24,5 @@ keywords: - management - integration license: Apache-2.0 -version: 26.09.01 +version: 26.09.1 date-released: "2026-09-14" diff --git a/pyproject.toml b/pyproject.toml index 3214bc5..43b22e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "portabase-cli" -version = "26.09.01" +version = "26.09.1" description = "The official command line interface (CLI) for managing and deploying Portabase instances with ease." readme = "README.md" requires-python = ">=3.12" From 9154583007c90f0e149ae43b67bd5b849e0a81fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 14 Sep 2026 16:33:36 +0200 Subject: [PATCH 121/124] build: replace cryptography with pycryptodome for AES-GCM --- core/crypto.py | 11 +-- pyproject.toml | 3 +- tests/support.py | 8 +- uv.lock | 196 +++++++---------------------------------------- 4 files changed, 40 insertions(+), 178 deletions(-) diff --git a/core/crypto.py b/core/crypto.py index dfb4b2d..18b4a57 100644 --- a/core/crypto.py +++ b/core/crypto.py @@ -5,8 +5,7 @@ import struct from pathlib import Path -from cryptography.exceptions import InvalidTag -from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from Crypto.Cipher import AES from core.errors import PortabaseError @@ -81,7 +80,6 @@ def _read_header(handle) -> tuple[bytes, int]: def decrypt_enc_file(enc_path: Path, out_path: Path, key: bytes) -> None: - aesgcm = AESGCM(key) tmp_path = out_path.with_name(out_path.name + ".part") try: @@ -119,9 +117,12 @@ def decrypt_enc_file(enc_path: Path, out_path: Path, key: bytes) -> None: ) nonce = base_nonce + struct.pack(">I", chunk_index) + cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) try: - plaintext = aesgcm.decrypt(nonce, ciphertext, None) - except InvalidTag as exc: + 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)." diff --git a/pyproject.toml b/pyproject.toml index 16d26cd..32390f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,8 +10,7 @@ dependencies = [ "questionary>=2.1.0", "requests>=2.32.5", "pyyaml>=6.0.3", - "cryptography>=44.0.0; sys_platform != 'darwin' or platform_machine != 'x86_64'", - "cryptography>=44.0.0,<49; sys_platform == 'darwin' and platform_machine == 'x86_64'", + "pycryptodome>=3.23.0", "jinja2>=3.1", ] diff --git a/tests/support.py b/tests/support.py index b8df74c..33c9bc3 100644 --- a/tests/support.py +++ b/tests/support.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import Any -from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from Crypto.Cipher import AES from core.errors import NetworkError from core.fields import Field @@ -113,11 +113,11 @@ def encrypt(plain: bytes, key: bytes, chunk_size: int = 4) -> bytes: "chunk_size": chunk_size, } out = json.dumps(header).encode() + b"\n" - aes = AESGCM(key) for index, start in enumerate(range(0, len(plain), chunk_size)): nonce = base_nonce + struct.pack(">I", index) - ciphertext = aes.encrypt(nonce, plain[start : start + chunk_size], None) - out += struct.pack(">I", len(ciphertext)) + ciphertext + 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 diff --git a/uv.lock b/uv.lock index 973426f..357a540 100644 --- a/uv.lock +++ b/uv.lock @@ -99,91 +99,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] -[[package]] -name = "cffi" -version = "2.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, - { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, - { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, - { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, - { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, - { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, - { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, - { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, - { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, - { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, - { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, - { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, - { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, - { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, - { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, - { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, - { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, - { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, - { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, - { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, - { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, - { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, - { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, - { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, - { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, - { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, - { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, - { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, - { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, - { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, - { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, - { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, - { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, - { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, - { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, - { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, - { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, - { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, - { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, - { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, - { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, - { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, - { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, - { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, - { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, - { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, - { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, - { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, - { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, - { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, - { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, - { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, - { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, - { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, - { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, - { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, - { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, - { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, - { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, - { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, - { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, - { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, - { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, - { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, - { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, - { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, -] - [[package]] name = "charset-normalizer" version = "3.4.7" @@ -278,78 +193,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "cryptography" -version = "48.0.1" -source = { registry = "https://pypi.org/simple" } -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' and sys_platform == 'darwin'", -] -dependencies = [ - { name = "cffi", marker = "platform_machine == 'x86_64' and platform_python_implementation != 'PyPy' and sys_platform == 'darwin'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, - { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" }, - { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, -] - -[[package]] -name = "cryptography" -version = "50.0.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "(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') or (python_full_version < '3.15' and sys_platform != 'darwin')", -] -dependencies = [ - { name = "cffi", marker = "(platform_machine != 'x86_64' and platform_python_implementation != 'PyPy') or (platform_python_implementation != 'PyPy' and sys_platform != 'darwin')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, - { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, - { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, - { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, - { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, - { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, - { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, - { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, - { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, - { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, - { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, - { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, - { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, - { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, - { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, - { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, - { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, - { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, - { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, - { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, - { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, - { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, - { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, - { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, - { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, - { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, - { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, - { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, - { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, - { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, - { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, - { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, - { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, - { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, - { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, - { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, - { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, - { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, -] - [[package]] name = "idna" version = "3.15" @@ -681,9 +524,8 @@ name = "portabase-cli" version = "26.8.12" source = { virtual = "." } dependencies = [ - { name = "cryptography", version = "48.0.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, - { name = "cryptography", version = "50.0.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, { name = "jinja2" }, + { name = "pycryptodome" }, { name = "pyyaml" }, { name = "questionary" }, { name = "requests" }, @@ -701,9 +543,8 @@ dev = [ [package.metadata] requires-dist = [ - { name = "cryptography", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'", specifier = ">=44.0.0" }, - { name = "cryptography", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'", specifier = ">=44.0.0,<49" }, { 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" }, @@ -732,12 +573,33 @@ wheels = [ ] [[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +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]] From 3f9b8e5eeb616485aba49b07ef58932c3d84964d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:35:44 +0000 Subject: [PATCH 122/124] chore(release): 26.09.2 --- CITATION.cff | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index c0254c6..973d32f 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -24,5 +24,5 @@ keywords: - management - integration license: Apache-2.0 -version: 26.09.1 +version: 26.09.2 date-released: "2026-09-14" diff --git a/pyproject.toml b/pyproject.toml index dc58c36..d0fea3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "portabase-cli" -version = "26.09.1" +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" From 6c5e0ae4a0db0a1030ce136736874cb56ed94dc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Tue, 15 Sep 2026 14:42:59 +0200 Subject: [PATCH 123/124] add: mongo srv compatibility --- commands/db.py | 5 ++- engines/mongodb.py | 44 +++++++++++++++++++++++++++ tests/engines/mongodb.py | 66 ++++++++++++++++++++++++++++++++++++++-- uv.lock | 2 +- 4 files changed, 113 insertions(+), 4 deletions(-) diff --git a/commands/db.py b/commands/db.py index bbee8ab..a08da1d 100644 --- a/commands/db.py +++ b/commands/db.py @@ -82,7 +82,10 @@ def run( 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") + int | None, + typer.Option( + "--port", help="Port of an existing database" + ), ] = None, database: Annotated[ str | None, typer.Option("--database", help="Database name") diff --git a/engines/mongodb.py b/engines/mongodb.py index 09232ba..83c5c0b 100644 --- a/engines/mongodb.py +++ b/engines/mongodb.py @@ -3,17 +3,46 @@ 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: @@ -41,3 +70,18 @@ def env_vars(self, spec: DatabaseSpec) -> dict[str, str]: 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/tests/engines/mongodb.py b/tests/engines/mongodb.py index dce63a4..36c1061 100644 --- a/tests/engines/mongodb.py +++ b/tests/engines/mongodb.py @@ -2,6 +2,7 @@ import pytest +from core.errors import ValidationError from core.specs import DatabaseSpec from engines import registry from engines.mongodb import MongoEngine @@ -75,8 +76,8 @@ def fields(): ("host", "text", "localhost"), ("port", "int", 27017), ("database", "text", None), - ("username", "text", None), - ("password", "secret", None), + ("username", "text", ""), + ("password", "secret", ""), ] assert MONGO.fields_new() == [] assert MONGO.option_fields() == [] @@ -130,3 +131,64 @@ def compose_service_inline(render_engine): 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/uv.lock b/uv.lock index 357a540..de95f97 100644 --- a/uv.lock +++ b/uv.lock @@ -521,7 +521,7 @@ wheels = [ [[package]] name = "portabase-cli" -version = "26.8.12" +version = "26.9.2" source = { virtual = "." } dependencies = [ { name = "jinja2" }, From 6967a6310ef560d34ee7ed8c4a612991ae6ac7bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:48:05 +0000 Subject: [PATCH 124/124] fix: satisfy ruff format check in db add command Co-authored-by: Asuniia <43389096+Asuniia@users.noreply.github.com> --- commands/db.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/commands/db.py b/commands/db.py index a08da1d..2ff0646 100644 --- a/commands/db.py +++ b/commands/db.py @@ -83,9 +83,7 @@ def run( ] = None, port: Annotated[ int | None, - typer.Option( - "--port", help="Port of an existing database" - ), + typer.Option("--port", help="Port of an existing database"), ] = None, database: Annotated[ str | None, typer.Option("--database", help="Database name")