From 377cf24f55d9f6b657e97207c97f9bb647eb12a6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 10 Oct 2026 13:37:30 +0700 Subject: [PATCH] build: add schema, validator and listing policy --- .github/CODEOWNERS | 1 + .github/ISSUE_TEMPLATE/config.yml | 11 + .github/ISSUE_TEMPLATE/report-integration.yml | 47 ++ .github/ISSUE_TEMPLATE/request-removal.yml | 32 + .../ISSUE_TEMPLATE/suggest-integration.yml | 41 ++ .github/dependabot.yml | 24 + .github/pull_request_template.md | 23 + .github/workflows/stale.yml | 35 + .github/workflows/triage.yml | 89 +++ .github/workflows/validate.yml | 127 ++++ .gitignore | 4 + CONTRIBUTING.md | 39 ++ LICENSE | 21 + LICENSE-DATA | 141 ++++ POLICY.md | 114 ++++ README.md | 23 +- SECURITY.md | 16 + _owner/ruleset-checks.json | 21 + _owner/ruleset-review.json | 22 + _owner/setup.sh | 63 ++ curation/official-publishers.json | 6 + curation/partners.json | 4 + curation/protected-marks.json | 11 + curation/removed.json | 4 + curation/reserved.json | 39 ++ curation/scan-allowlist.json | 4 + integrations/.gitkeep | 0 requirements.in | 3 + requirements.txt | 625 +++++++++++++++++ schema/curation.schema.json | 206 ++++++ schema/index.schema.json | 270 ++++++++ schema/integration.schema.json | 571 ++++++++++++++++ scripts/registry.py | 433 ++++++++++++ .../integrations/claude-code/icon.png | Bin 0 -> 2122 bytes .../integrations/claude-code/integration.json | 72 ++ .../integrations/claude-desktop/icon.png | Bin 0 -> 2123 bytes .../claude-desktop/integration.json | 72 ++ .../integrations/command-line/icon.png | Bin 0 -> 2112 bytes .../command-line/integration.json | 64 ++ .../fixtures/integrations/cursor/icon.png | Bin 0 -> 2112 bytes .../integrations/cursor/integration.json | 72 ++ .../integrations/ddev-manager/icon.png | Bin 0 -> 2111 bytes .../ddev-manager/integration.json | 63 ++ .../tests/fixtures/integrations/ddev/icon.png | Bin 0 -> 2110 bytes .../integrations/ddev/integration.json | 68 ++ .../fixtures/integrations/localwp/icon.png | Bin 0 -> 2123 bytes .../integrations/localwp/integration.json | 76 +++ .../fixtures/integrations/raycast/icon.png | Bin 0 -> 102853 bytes .../integrations/raycast/integration.json | 78 +++ .../fixtures/integrations/shortcuts/icon.png | Bin 0 -> 2118 bytes .../integrations/shortcuts/integration.json | 69 ++ .../tests/fixtures/integrations/zed/icon.png | Bin 0 -> 2123 bytes .../integrations/zed/integration.json | 72 ++ scripts/tests/test_validate.py | 628 ++++++++++++++++++ scripts/validate.py | 536 +++++++++++++++ 55 files changed, 4938 insertions(+), 2 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/report-integration.yml create mode 100644 .github/ISSUE_TEMPLATE/request-removal.yml create mode 100644 .github/ISSUE_TEMPLATE/suggest-integration.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/stale.yml create mode 100644 .github/workflows/triage.yml create mode 100644 .github/workflows/validate.yml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 LICENSE-DATA create mode 100644 POLICY.md create mode 100644 SECURITY.md create mode 100644 _owner/ruleset-checks.json create mode 100644 _owner/ruleset-review.json create mode 100755 _owner/setup.sh create mode 100644 curation/official-publishers.json create mode 100644 curation/partners.json create mode 100644 curation/protected-marks.json create mode 100644 curation/removed.json create mode 100644 curation/reserved.json create mode 100644 curation/scan-allowlist.json create mode 100644 integrations/.gitkeep create mode 100644 requirements.in create mode 100644 requirements.txt create mode 100644 schema/curation.schema.json create mode 100644 schema/index.schema.json create mode 100644 schema/integration.schema.json create mode 100644 scripts/registry.py create mode 100644 scripts/tests/fixtures/integrations/claude-code/icon.png create mode 100644 scripts/tests/fixtures/integrations/claude-code/integration.json create mode 100644 scripts/tests/fixtures/integrations/claude-desktop/icon.png create mode 100644 scripts/tests/fixtures/integrations/claude-desktop/integration.json create mode 100644 scripts/tests/fixtures/integrations/command-line/icon.png create mode 100644 scripts/tests/fixtures/integrations/command-line/integration.json create mode 100644 scripts/tests/fixtures/integrations/cursor/icon.png create mode 100644 scripts/tests/fixtures/integrations/cursor/integration.json create mode 100644 scripts/tests/fixtures/integrations/ddev-manager/icon.png create mode 100644 scripts/tests/fixtures/integrations/ddev-manager/integration.json create mode 100644 scripts/tests/fixtures/integrations/ddev/icon.png create mode 100644 scripts/tests/fixtures/integrations/ddev/integration.json create mode 100644 scripts/tests/fixtures/integrations/localwp/icon.png create mode 100644 scripts/tests/fixtures/integrations/localwp/integration.json create mode 100644 scripts/tests/fixtures/integrations/raycast/icon.png create mode 100644 scripts/tests/fixtures/integrations/raycast/integration.json create mode 100644 scripts/tests/fixtures/integrations/shortcuts/icon.png create mode 100644 scripts/tests/fixtures/integrations/shortcuts/integration.json create mode 100644 scripts/tests/fixtures/integrations/zed/icon.png create mode 100644 scripts/tests/fixtures/integrations/zed/integration.json create mode 100644 scripts/tests/test_validate.py create mode 100644 scripts/validate.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..ad461ff --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @TableProApp/maintainers diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a4245c5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Report a security problem in a listed integration + url: https://github.com/TableProApp/integrations/security/advisories/new + about: Malware, credential theft or a vulnerability. Reported privately. + - name: A bug in TablePro itself + url: https://github.com/TableProApp/TablePro/issues/new/choose + about: This repository lists integrations. TablePro bugs go to the app repository. + - name: A bug in an integration + url: https://github.com/TableProApp/integrations/tree/main/integrations + about: Open the entry and use the issue tracker in its links. The integration's author fixes it, not TablePro. diff --git a/.github/ISSUE_TEMPLATE/report-integration.yml b/.github/ISSUE_TEMPLATE/report-integration.yml new file mode 100644 index 0000000..bd8b1f1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/report-integration.yml @@ -0,0 +1,47 @@ +name: Report a listed integration +description: An entry is broken, misleading, or breaks the listing policy. +labels: ["report"] +body: + - type: markdown + attributes: + value: | + Malware, credential theft or a vulnerability: do not file it here. Use **Report a security problem** on the previous page, which is private. + A bug in the integration itself goes to its own issue tracker first. + - type: input + id: slug + attributes: + label: Entry + description: The last part of the entry's URL, for example localwp in tablepro.app/integrations/localwp. + validations: + required: true + - type: dropdown + id: reason + attributes: + label: Problem + options: + - It does not work with the current TablePro release + - A link is broken or goes somewhere else + - It reads TablePro's private files + - It uses the TablePro name or icon in a confusing way + - A disclosure on the page is wrong + - It pretends to be someone else + - Something else + validations: + required: true + - type: textarea + id: details + attributes: + label: Details + description: What you saw, with versions and links. + validations: + required: true + - type: dropdown + id: contacted + attributes: + label: Did you tell the author + options: + - Yes, with no reply after 7 days + - Yes, recently + - "No" + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/request-removal.yml b/.github/ISSUE_TEMPLATE/request-removal.yml new file mode 100644 index 0000000..84e60ab --- /dev/null +++ b/.github/ISSUE_TEMPLATE/request-removal.yml @@ -0,0 +1,32 @@ +name: Request removal or archive +description: For publishers who want their entry archived or removed. +labels: ["removal request"] +body: + - type: input + id: slug + attributes: + label: Entry + description: The last part of the entry's URL. + validations: + required: true + - type: dropdown + id: action + attributes: + label: What should happen + options: + - Archive it. The page stays with a notice + - Remove it. The page answers 410 Gone + validations: + required: true + - type: textarea + id: reason + attributes: + label: Reason + description: Optional. + - type: checkboxes + id: owner + attributes: + label: Ownership + options: + - label: I am the publisher shown on the entry, or I can act for them. A maintainer checks this against the GitHub account on the entry. + required: true diff --git a/.github/ISSUE_TEMPLATE/suggest-integration.yml b/.github/ISSUE_TEMPLATE/suggest-integration.yml new file mode 100644 index 0000000..eff2e2f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/suggest-integration.yml @@ -0,0 +1,41 @@ +name: Suggest an integration +description: Tell us about an integration that should be listed. Its author can also open a pull request. +labels: ["suggestion"] +body: + - type: markdown + attributes: + value: | + Authors: a pull request is faster. See CONTRIBUTING.md. + Everyone else: we ask the author before listing anything. + - type: input + id: name + attributes: + label: Name + placeholder: DDEV Manager + validations: + required: true + - type: input + id: url + attributes: + label: Link + description: Store page, repository or homepage. + placeholder: https:// + validations: + required: true + - type: textarea + id: what + attributes: + label: What it does with TablePro + description: One or two sentences. + validations: + required: true + - type: dropdown + id: relation + attributes: + label: Who are you + options: + - I made it + - I work for the company that makes it + - I use it + validations: + required: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5f39f1f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,24 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 + commit-message: + prefix: ci + groups: + actions: + patterns: ["*"] + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 + commit-message: + prefix: build + groups: + python: + patterns: ["*"] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..7046e88 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,23 @@ + + +## Entry + + + +## Checklist + +- [ ] I read the [listing policy](https://github.com/TableProApp/integrations/blob/main/POLICY.md) and the entry follows it. +- [ ] `python3 scripts/validate.py` passes on my machine. +- [ ] I am the publisher, a member of the publishing organization, or a major contributor. If not, the owner gave consent in a comment on this pull request (link it below). +- [ ] The integration uses only documented TablePro interfaces. It never reads `connections.json`, `com.TablePro.plist`, `mcp-handshake.json`, TablePro's Keychain items or its SQLite files. +- [ ] The name does not include TablePro, and the icon is not the TablePro app icon. A small TablePro badge in a corner is fine. +- [ ] The disclosures are true: what it reads and writes, Safe Mode, network, account, payment. +- [ ] I own the icon and screenshots, or may use them, and I let TablePro show them in the directory and its published data. +- [ ] I wrote this pull request myself. If an AI tool helped, I say how below and I checked every claim. + +## Consent or notes + + diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..d196104 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,35 @@ +name: Stale + +on: + schedule: + - cron: "41 3 * * *" + workflow_dispatch: + +permissions: {} + +concurrency: + group: stale + +jobs: + stale: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + actions: write + issues: write + pull-requests: write + steps: + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 + with: + days-before-issue-stale: -1 + days-before-issue-close: -1 + only-pr-labels: waiting on author + days-before-pr-stale: 14 + days-before-pr-close: 7 + stale-pr-label: stale + remove-pr-stale-when-updated: true + operations-per-run: 100 + stale-pr-message: > + No reply for 14 days. This pull request closes in 7 days unless you push a change or comment. + close-pr-message: > + Closed after 21 days without a reply. Open a new pull request when you are ready. diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml new file mode 100644 index 0000000..0fd6ef6 --- /dev/null +++ b/.github/workflows/triage.yml @@ -0,0 +1,89 @@ +name: Triage + +# Labels only. It never checks out or runs pull request code; it reads changed files through the API as data. +on: # zizmor: ignore[dangerous-triggers] + pull_request_target: + types: [opened, reopened, synchronize, ready_for_review] + +permissions: {} + +concurrency: + group: triage-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + label: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + pull-requests: write + issues: write + steps: + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const pr = context.payload.pull_request; + const { owner, repo } = context.repo; + const maintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association); + const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: pr.number, per_page: 100 }); + const labels = new Set(); + const slugs = new Map(); + for (const file of files) { + const entry = /^integrations\/([a-z0-9-]{1,40})\/(.+)$/.exec(file.filename); + if (entry) { + if (entry[2] === 'integration.json') { + slugs.set(entry[1], file.status); + labels.add(file.status === 'added' ? 'new integration' : file.status === 'removed' ? 'removal' : 'update'); + } else { + labels.add('update'); + } + } else if (file.filename.startsWith('curation/')) { + labels.add('curation'); + } else { + labels.add('tooling'); + } + } + async function readJson(repoOwner, repoName, ref, path) { + try { + const { data } = await github.rest.repos.getContent({ owner: repoOwner, repo: repoName, path, ref }); + return JSON.parse(Buffer.from(data.content, 'base64').toString('utf8')); + } catch { + return null; + } + } + const tiers = ['official', 'partner', 'community']; + let newName = null; + for (const [slug, status] of slugs) { + const path = `integrations/${slug}/integration.json`; + const head = pr.head.repo && status !== 'removed' + ? await readJson(pr.head.repo.owner.login, pr.head.repo.name, pr.head.sha, path) : null; + const base = status === 'added' ? null : await readJson(owner, repo, pr.base.sha, path); + if (head && tiers.includes(head.tier)) labels.add(`tier: ${head.tier}`); + if (head && base && head.tier !== base.tier) labels.add('tier change'); + if (head && head.compliance) labels.add('grace period'); + if (status === 'added' && head && typeof head.name === 'string') newName = head.name.slice(0, 40); + if (!maintainer && head) { + const author = pr.user.login.toLowerCase(); + const publisher = String((head.publisher && head.publisher.github) || '').toLowerCase(); + const sourceRepo = head.source && head.source.type === 'github' ? String(head.source.repo || '') : ''; + const repoOwner = sourceRepo.split('/')[0].toLowerCase(); + let owns = author === publisher || (repoOwner !== '' && author === repoOwner); + if (!owns && repoOwner) { + try { + await github.rest.orgs.checkPublicMembershipForUser({ org: repoOwner, username: pr.user.login }); + owns = true; + } catch {} + } + if (!owns) labels.add('needs owner consent'); + } + } + if (!maintainer && slugs.size > 1) labels.add('multiple entries'); + if (labels.size) { + await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [...labels] }); + } + const conventional = /^(feat|fix|docs|chore|ci|build|refactor|test|revert)(\([a-z0-9-]+\))?!?: \S/; + if (context.payload.action === 'opened' && slugs.size === 1 && newName && !conventional.test(pr.title)) { + const [slug] = slugs.keys(); + await github.rest.pulls.update({ owner, repo, pull_number: pr.number, title: `feat(${slug}): add ${newName}` }); + } diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..f0528a4 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,127 @@ +name: Validate + +on: + pull_request: + push: + branches: [main] + +permissions: {} + +concurrency: + group: validate-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + tests: + name: Validator tests + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + - run: python -m pip install --require-hashes -r requirements.txt + - run: python -W error scripts/tests/test_validate.py + + entries: + name: Entries + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + - run: python -m pip install --require-hashes -r requirements.txt + - name: Schemas + run: | + shopt -s nullglob + check-jsonschema --check-metaschema schema/*.json + check-jsonschema --schemafile schema/curation.schema.json curation/*.json + manifests=(integrations/*/integration.json) + if [ "${#manifests[@]}" -gt 0 ]; then + check-jsonschema --schemafile schema/integration.schema.json "${manifests[@]}" + fi + check-jsonschema --builtin-schema vendor.github-workflows .github/workflows/*.yml + check-jsonschema --builtin-schema vendor.github-issue-forms .github/ISSUE_TEMPLATE/[!c]*.yml + check-jsonschema --builtin-schema vendor.github-issue-config .github/ISSUE_TEMPLATE/config.yml + check-jsonschema --builtin-schema vendor.dependabot .github/dependabot.yml + - name: Registry rules + id: rules + env: + GITHUB_TOKEN: ${{ github.token }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_ASSOCIATION: ${{ github.event.pull_request.author_association }} + run: | + args=(--online --scan-sources --annotations --urls-out build/urls.txt) + if [ -n "$BASE_SHA" ]; then + args+=(--base "$BASE_SHA" --author "$PR_AUTHOR" --author-association "$PR_ASSOCIATION") + fi + python scripts/validate.py "${args[@]}" + if [ -s build/urls.txt ]; then + echo "links=true" >> "$GITHUB_OUTPUT" + fi + - name: Links + if: steps.rules.outputs.links == 'true' + uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0 + with: + args: --no-progress --require-https --max-retries 2 --accept 200,429 build/urls.txt + fail: true + + workflows: + name: Workflow lint + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: actionlint + env: + ACTIONLINT_VERSION: 1.7.12 + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 + run: | + archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + curl -fsSL --retry 3 -o "$RUNNER_TEMP/$archive" \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/$archive" + echo "$ACTIONLINT_SHA256 $RUNNER_TEMP/$archive" | sha256sum --check --strict + tar -xzf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP" actionlint + "$RUNNER_TEMP/actionlint" -color + - name: shellcheck + run: shellcheck _owner/setup.sh + - uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 + with: + version: 1.30.1 + advanced-security: false + annotations: true + + gate: + name: Registry Gate + if: always() + needs: [tests, entries, workflows] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} + steps: + - env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + for result in $RESULTS; do + if [ "$result" != "success" ]; then + echo "A required job ended with: $result" + exit 1 + fi + done diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f910f3a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +build/ +.DS_Store diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7a57ecf --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# Listing an integration + +Read the [listing policy](POLICY.md) first. Open one pull request per entry. + +## Add an entry + +1. Fork this repository and create `integrations//`. The slug is lowercase words joined by hyphens, does not contain `tablepro`, and never changes. +2. Copy `integration.json` from an entry like yours, or from `scripts/tests/fixtures/integrations/`, and edit it. The `$schema` line gives your editor completion and checks. +3. Add `icon.png`: 512x512 PNG, at most 512 KB, not the TablePro app icon. +4. Optional: up to four `screenshot-N.png` files, 1600x1000 PNG, at most 1 MB each, listed under `screenshots` with alt text. +5. Run the checks with Python 3.10 or later: + + ```bash + python3 -m venv .venv + .venv/bin/pip install --require-hashes -r requirements.txt + .venv/bin/python scripts/validate.py + ``` + +6. Open the pull request and fill in the checklist. + +Set `addedAt` and `lastVerifiedAt` to today. A maintainer updates `lastVerifiedAt` after checking the integration. + +The [publishing guide](https://docs.tablepro.app/developers/submit) walks through the fields. + +## Fields that need a maintainer + +`tier`, `compliance`, `lastVerifiedAt`, `source.repoId` and `publisher.githubId` change only in a maintainer's pull request. `tier: official` needs a publisher in `curation/official-publishers.json`, and `tier: partner` needs a row in `curation/partners.json`. + +## Update an entry + +Change the files in your entry's folder and open a pull request. The slug and `addedAt` stay as they are. + +## Archive or remove an entry + +Open a **Request removal or archive** issue. A maintainer makes the change and records it. + +## Not the author? + +Open a **Suggest an integration** issue instead. We ask the author before listing it. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ea50e93 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ngo Quoc Dat + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LICENSE-DATA b/LICENSE-DATA new file mode 100644 index 0000000..71a9318 --- /dev/null +++ b/LICENSE-DATA @@ -0,0 +1,141 @@ +Data license + +The text in integrations/*/integration.json and curation/*.json, and the +index.json this repository publishes, is dedicated to the public domain under +CC0 1.0 Universal (SPDX: CC0-1.0). The legal code follows, unmodified. + +Not covered: + +- Icons and screenshots (integrations/*/icon.png, integrations/*/screenshot-*.png + and the re-encoded copies this repository publishes) remain the property of + their owners. Each contributor grants Ngo Quoc Dat and the maintainers of this + repository a non-exclusive, worldwide, royalty-free license to host, re-encode + and display them in the TablePro integrations directory and in this + repository's published data. +- Product names and logos are trademarks of their owners. TablePro is a + trademark of Ngo Quoc Dat (https://tablepro.app/brand). CC0 grants no + trademark rights. + +------------------------------------------------------------------------------ + +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/POLICY.md b/POLICY.md new file mode 100644 index 0000000..27b27e0 --- /dev/null +++ b/POLICY.md @@ -0,0 +1,114 @@ +# Listing policy + +Version 1, effective 2026-10-10. + +This policy decides what appears in the TablePro integration directory at [tablepro.app/integrations](https://tablepro.app/integrations) and in this repository's published data. + +## 1. What a listing means + +A listing says the integration works with TablePro and meets this policy when it was reviewed. It is not an endorsement or a security audit. TablePro does not support third-party integrations; their authors do. + +## 2. Labels + +| Label | Meaning | +| --- | --- | +| Official | Built and maintained by TablePro. | +| Partner | Built by the company that makes the other product, verified by its GitHub organization or a DNS record on its domain. Only a maintainer assigns it. | +| Community | Built by anyone else. | + +Archived is a status, not a label. Every entry shows **By <publisher>**. + +## 3. Who may submit + +The repository owner, a member of the owning organization, or a major contributor may open the pull request. A major contributor has at least a third of the commits of the top contributor. Anyone else needs the owner's consent in a comment on the pull request. + +## 4. Documented interfaces only + +An integration uses only the interfaces in the [developer docs](https://docs.tablepro.app/developers): the URL scheme, database URLs, the `tablepro` command, AppleScript, the MCP server, pairing and tokens, and Shortcuts. + +It never reads, writes or deletes TablePro's private files: `connections.json`, `com.TablePro.plist`, `mcp-handshake.json`, TablePro's Keychain items or its SQLite databases. There is no exception and no grace period for new entries. CI scans open-source entries for these paths. + +It finds TablePro by bundle identifier `com.TablePro`, not by a fixed install path. + +## 5. License and source + +An open-source entry has a LICENSE file with an SPDX license, and the license in the manifest matches it. + +Closed source is allowed. The entry shows a **Closed source** label, and its disclosures are the publisher's word. + +There is no minimum number of stars, installs or downloads. + +## 6. Safety settings + +An integration may open connections with Safe Mode below the user's default only for local development environments, such as `safeModeLevel=0` for a database on the same Mac. The entry discloses it, and the directory shows it on the entry page. + +## 7. Disclosures + +Every entry states: + +- what it reads from TablePro (connection list, schema, row data) and what it writes (data, schema); +- whether it lowers Safe Mode; +- where it sends data: nowhere, this Mac only, or the internet, and to whom; +- whether it needs an account with another service, with a privacy policy link; +- whether it is free, has optional payment, or is paid. + +A wrong disclosure is a policy violation. + +## 8. Names and icons + +- Name a third-party product **<Name> for TablePro**. **TablePro** alone and **TablePro for <X>** are reserved for TablePro's own products. +- The directory entry's name does not include TablePro, because the directory already says it. +- Do not use the TablePro app icon as your icon. A small TablePro badge in a corner of your own icon is fine. +- Do not call an entry official, verified or certified. +- The brand guidelines at [tablepro.app/brand](https://tablepro.app/brand) apply. + +## 9. Entry content + +- Text is English, plain, and factual. No HTML, no Markdown, no emoji. +- The summary is one sentence of 20 to 120 characters ending with a period. +- The icon is a 512x512 PNG of at most 512 KB. SVG is not accepted. +- Up to 4 screenshots, each a 1600x1000 PNG of at most 1 MB, each with alt text. +- One entry per pull request. + +## 10. Security + +Malware, credential theft, hidden data collection or a deceptive install link is removed at once, without the steps in section 12. Report it privately through [security advisories](https://github.com/TableProApp/integrations/security/advisories/new). + +## 11. Review + +- First response within 7 days. This is best effort. +- A pull request waiting on its author closes after 21 days without a reply. Open a new one at any time. +- CI checks the schema, links, license, repository state, images and ownership. A maintainer checks the rest of this policy by hand. + +## 12. Maintenance and archiving + +- A weekly check opens one issue listing broken links, moved or archived repositories, removed licenses and private-file access. It never edits or deletes an entry. +- When an entry breaks the policy or stops working, a maintainer contacts the author in the integration's own tracker. With no reply after 30 days, the entry is archived. +- An archived entry leaves the directory list and search. Its page stays, with a notice. +- An author can fix the problem and ask for the entry to return. + +## 13. Entries listed before this policy + +An entry listed before this policy took effect gets 60 days from the day its author is contacted to fix any problem in sections 4 to 8 that it already had. The entry page says so. If it is not fixed by then, the entry is archived. + +## 14. Removal + +An entry is removed when its publisher asks, or for the reasons in section 10. A removed slug is never reused, and its page answers 410 Gone. + +## 15. Reports and appeals + +Report a problem with an integration to its author first. If there is no reply after 7 days, or the problem is a policy violation, use **Report a listed integration** in this repository's issues. + +To appeal an archive or removal, reply on the issue that recorded it, or fix the problem and open a new pull request. + +## 16. Links + +Links to Community entries carry `rel="ugc nofollow"`. TablePro never asks a listed project to link back. + +## 17. Data and images + +Manifest text is released under CC0-1.0 (see LICENSE-DATA). Icons and screenshots stay their owners' property. Submitting them lets TablePro show them in the directory and in this repository's published data. + +## 18. Changes to this policy + +Each change gets a new version and date here, and is announced in the repository. Existing entries must meet a new rule within 60 days of its effective date. diff --git a/README.md b/README.md index e976ef1..4269fdb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,21 @@ -# integrations -Registry of integrations that work with TablePro +# TablePro integrations + +The registry behind the TablePro integrations directory: apps, extensions and tools that work with [TablePro](https://tablepro.app). + +Each entry is a folder in `integrations/` with an `integration.json` manifest and a 512x512 `icon.png`. A pull request adds or changes one entry, CI checks it, and a maintainer reviews it against the [listing policy](POLICY.md). + +To list your integration, read [CONTRIBUTING.md](CONTRIBUTING.md). + +## Layout + +| Path | Holds | +| --- | --- | +| `integrations//` | `integration.json`, `icon.png`, optional `screenshot-1.png` to `screenshot-4.png` | +| `schema/` | JSON Schema 2020-12 for manifests, curation files and the published index | +| `curation/` | Maintainer files: official publishers, partners, reserved names, removed entries, protected icons, accepted scan hits | +| `scripts/` | `validate.py`, the shared `registry.py`, and their tests | +| `_owner/` | Repository settings and rulesets, applied once by an org owner | + +## License + +Code and schemas: [MIT](LICENSE). Manifest text: [CC0-1.0](LICENSE-DATA). Icons and screenshots stay their owners' property. TablePro is a trademark of Ngo Quoc Dat, see [tablepro.app/brand](https://tablepro.app/brand). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ba636ae --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,16 @@ +# Security + +## Report a problem + +- **A listed integration is malicious, steals credentials or has a vulnerability:** report it privately through [security advisories](https://github.com/TableProApp/integrations/security/advisories/new). A confirmed report removes the entry at once, as section 10 of the [listing policy](POLICY.md) says. +- **This repository's workflows, scripts or data:** report it the same way, or email hello@tablepro.app. +- **TablePro itself:** see [TablePro's security policy](https://github.com/TableProApp/TablePro/security/policy). + +Do not open a public issue for any of these. + +## What this repository protects + +- Pull request checks run with a read-only token and never see secrets. Labeling runs on `pull_request_target` and never checks out pull request code. +- Every action is pinned to a full commit SHA. +- Entries are pinned to numeric repository and account IDs, so a renamed or re-created repository is caught. +- Entry data is only parsed, never run. Images are decoded only in CI. SVG is not accepted. diff --git a/_owner/ruleset-checks.json b/_owner/ruleset-checks.json new file mode 100644 index 0000000..8ef83b2 --- /dev/null +++ b/_owner/ruleset-checks.json @@ -0,0 +1,21 @@ +{ + "name": "main: required checks", + "target": "branch", + "enforcement": "active", + "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } }, + "bypass_actors": [], + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": false, + "do_not_enforce_on_create": false, + "required_status_checks": [ + { "context": "Registry Gate", "integration_id": 15368 } + ] + } + } + ] +} diff --git a/_owner/ruleset-review.json b/_owner/ruleset-review.json new file mode 100644 index 0000000..9194baf --- /dev/null +++ b/_owner/ruleset-review.json @@ -0,0 +1,22 @@ +{ + "name": "main: reviewed pull requests", + "target": "branch", + "enforcement": "active", + "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } }, + "bypass_actors": [ + { "actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "pull_request" } + ], + "rules": [ + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "require_code_owner_review": true, + "dismiss_stale_reviews_on_push": true, + "require_last_push_approval": true, + "required_review_thread_resolution": true, + "allowed_merge_methods": ["squash"] + } + } + ] +} diff --git a/_owner/setup.sh b/_owner/setup.sh new file mode 100755 index 0000000..3c7b620 --- /dev/null +++ b/_owner/setup.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Run by an org owner after the scaffold pull request merges and before the publish pull request merges. +# The checks ruleset requires Registry Gate, which exists once validate.yml has run on a pull request. +set -euo pipefail + +repo=TableProApp/integrations +here=$(cd "$(dirname "$0")" && pwd) + +gh api -X PATCH "repos/$repo" \ + -F has_wiki=false -F has_projects=false -F has_discussions=false \ + -F allow_squash_merge=true -F allow_merge_commit=false -F allow_rebase_merge=false \ + -f squash_merge_commit_title=PR_TITLE -f squash_merge_commit_message=BLANK \ + -F delete_branch_on_merge=true -F allow_auto_merge=false >/dev/null + +gh api -X PUT "repos/$repo/private-vulnerability-reporting" +gh api -X PUT "repos/$repo/immutable-releases" +gh api -X PUT "repos/$repo/actions/permissions" -F enabled=true -f allowed_actions=all -F sha_pinning_required=true +gh api -X PUT "repos/$repo/actions/permissions/workflow" \ + -f default_workflow_permissions=read -F can_approve_pull_request_reviews=false +gh api -X PUT "repos/$repo/actions/permissions/fork-pr-contributor-approval" \ + -f approval_policy=first_time_contributors + +while IFS='|' read -r name color description; do + gh label create "$name" --repo "$repo" --color "$color" --description "$description" --force +done <<'LABELS' +new integration|0e8a16|Adds an entry +update|1d76db|Changes an entry +removal|b60205|Removes an entry +curation|5319e7|Changes curation files +tooling|c5def5|Changes scripts, schemas or workflows +tier: official|0052cc|Entry is Official +tier: partner|006b75|Entry is Partner +tier: community|bfdadc|Entry is Community +tier change|d93f0b|Changes an entry's tier +grace period|fbca04|Entry has a pre-policy grace period +needs owner consent|e99695|Author is not the owner; needs consent in a comment +multiple entries|e99695|Touches more than one entry +waiting on author|fef2c0|Closes after 21 days without a reply +stale|ededed|No reply for 14 days +health|f9d0c4|Weekly health check +report|d73a4a|Report about a listed entry +removal request|d73a4a|Publisher asks to archive or remove +suggestion|c2e0c6|Suggested integration +LABELS + +existing=$(gh api "repos/$repo/rulesets" --jq '.[].name') +for file in "$here/ruleset-checks.json" "$here/ruleset-review.json"; do + name=$(jq -r .name "$file") + if grep -Fxq "$name" <<<"$existing"; then + echo "Ruleset already exists: $name" + else + gh api -X POST "repos/$repo/rulesets" --input "$file" >/dev/null + echo "Created ruleset: $name" + fi +done + +if ! gh api "repos/$repo/pages" >/dev/null 2>&1; then + gh api -X POST "repos/$repo/pages" -f build_type=workflow >/dev/null +fi +gh api -X PUT "repos/$repo/pages" -f build_type=workflow -f cname=integrations.tablepro.app + +echo "Next: add the CNAME integrations -> tableproapp.github.io (DNS only). Once the certificate is issued:" +echo " gh api -X PUT repos/$repo/pages -F https_enforced=true" diff --git a/curation/official-publishers.json b/curation/official-publishers.json new file mode 100644 index 0000000..394c332 --- /dev/null +++ b/curation/official-publishers.json @@ -0,0 +1,6 @@ +{ + "$schema": "../schema/curation.schema.json", + "publishers": [ + { "github": "TableProApp", "githubId": 260450047, "name": "TablePro" } + ] +} diff --git a/curation/partners.json b/curation/partners.json new file mode 100644 index 0000000..de4a16e --- /dev/null +++ b/curation/partners.json @@ -0,0 +1,4 @@ +{ + "$schema": "../schema/curation.schema.json", + "partners": [] +} diff --git a/curation/protected-marks.json b/curation/protected-marks.json new file mode 100644 index 0000000..0bd0843 --- /dev/null +++ b/curation/protected-marks.json @@ -0,0 +1,11 @@ +{ + "$schema": "../schema/curation.schema.json", + "algorithm": "dhash16-alphabox", + "maxDistance": 40, + "marks": [ + { "id": "app-icon", "hash": "ff00d0f4871ade0c98648f0c8f0c88ac807c98e48f0c80ecce0ca07ad0e4fc00", "source": "TablePro app icon, default appearance" }, + { "id": "app-icon-dark", "hash": "ff80d0f4a71adf0c98e48f0c8f0c884c807c98e48f0c806ccf0ca0fad0e4fc00", "source": "TablePro app icon, dark appearance" }, + { "id": "app-icon-tinted", "hash": "ffc0d0f4a71a9e0c98a48f0c8f0c884c807c98e48f0c806c8c0ea07ad0ecf800", "source": "TablePro app icon, tinted appearance" }, + { "id": "app-icon-padded", "hash": "7f00d0f4871ade0c98648f0c8f0c88ac807c98e48f0c80ecce0ca07ad0e4fc00", "source": "TablePro app icon on the macOS icon grid" } + ] +} diff --git a/curation/removed.json b/curation/removed.json new file mode 100644 index 0000000..abcbc6e --- /dev/null +++ b/curation/removed.json @@ -0,0 +1,4 @@ +{ + "$schema": "../schema/curation.schema.json", + "removed": [] +} diff --git a/curation/reserved.json b/curation/reserved.json new file mode 100644 index 0000000..da9117a --- /dev/null +++ b/curation/reserved.json @@ -0,0 +1,39 @@ +{ + "$schema": "../schema/curation.schema.json", + "slugs": [ + "about", + "admin", + "api", + "archived", + "brand", + "categories", + "category", + "community", + "docs", + "images", + "index", + "integrations", + "new", + "official", + "partner", + "partners", + "platform", + "schema", + "search", + "settings", + "submit", + "tier", + "v1", + "v2" + ], + "names": [ + "TablePro", + "Official", + "Verified", + "Certified" + ], + "nameFragments": [ + "tablepro", + "tableproapp" + ] +} diff --git a/curation/scan-allowlist.json b/curation/scan-allowlist.json new file mode 100644 index 0000000..1f5a1c9 --- /dev/null +++ b/curation/scan-allowlist.json @@ -0,0 +1,4 @@ +{ + "$schema": "../schema/curation.schema.json", + "allow": [] +} diff --git a/integrations/.gitkeep b/integrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..c26f280 --- /dev/null +++ b/requirements.in @@ -0,0 +1,3 @@ +check-jsonschema==0.38.2 +jsonschema[format-nongpl]==4.26.0 +pillow==12.3.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3ae5237 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,625 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --generate-hashes --strip-extras requirements.in +# +arrow==1.4.0 \ + --hash=sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205 \ + --hash=sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7 + # via isoduration +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ + --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 + # via + # jsonschema + # referencing +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 + # via requests +charset-normalizer==3.5.2 \ + --hash=sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e \ + --hash=sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf \ + --hash=sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5 \ + --hash=sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56 \ + --hash=sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26 \ + --hash=sha256:0891b9d3903c5571c03771ca669a4b0ec5618ca722a5c957d3d29cd4e5062848 \ + --hash=sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718 \ + --hash=sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93 \ + --hash=sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640 \ + --hash=sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3 \ + --hash=sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875 \ + --hash=sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e \ + --hash=sha256:1461ac396c4fdb983a675f20aa555624f0ee18ac83d832b9244ffff3d8055275 \ + --hash=sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204 \ + --hash=sha256:15bb4005af6320d259dc7593ca84a38d7fe06a421dbcf7b910ae23979101e787 \ + --hash=sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234 \ + --hash=sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3 \ + --hash=sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98 \ + --hash=sha256:195c26fb65950f8fce54e26349852b7bdd7c5f120aeefbcc440b8a20faaed4a3 \ + --hash=sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187 \ + --hash=sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d \ + --hash=sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f \ + --hash=sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7 \ + --hash=sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011 \ + --hash=sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f \ + --hash=sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869 \ + --hash=sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1 \ + --hash=sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d \ + --hash=sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847 \ + --hash=sha256:28a15fdad492a99b6eccfaaed66ef3f74050680545ea61ec8b2f4c538f1f1320 \ + --hash=sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9 \ + --hash=sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93 \ + --hash=sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd \ + --hash=sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00 \ + --hash=sha256:2cc961b171b3f3440f410489ab3573e86aea8736134ebbb40ea1338b7f0831bc \ + --hash=sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0 \ + --hash=sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09 \ + --hash=sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac \ + --hash=sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621 \ + --hash=sha256:30fcd120b732aa79317f08dee04d7de0847822e4cf7ee0e9f445bb958832252c \ + --hash=sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8 \ + --hash=sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a \ + --hash=sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51 \ + --hash=sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0 \ + --hash=sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef \ + --hash=sha256:3d14b50de6bf4d0edf857a9386836846f982b8f524e188e2e68b96d702bcf4aa \ + --hash=sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6 \ + --hash=sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649 \ + --hash=sha256:3ddacd27458c45bdacd6bd6db644bfb730efbf9e830310186e3045c9c5be8fb2 \ + --hash=sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229 \ + --hash=sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e \ + --hash=sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd \ + --hash=sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115 \ + --hash=sha256:447441e76ec720b15e64418d32e092297340387053047c7c694f579efb0ee1d9 \ + --hash=sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c \ + --hash=sha256:44bd4fbb29dfbeba60e7d2bd000c59e4b21ddb3cc53912b14048d37092706d7c \ + --hash=sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab \ + --hash=sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253 \ + --hash=sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995 \ + --hash=sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438 \ + --hash=sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0 \ + --hash=sha256:50e3adfb96fc189eb27b1cf62d3b598b89b4bb0420d93a3d3e42e137409011be \ + --hash=sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b \ + --hash=sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7 \ + --hash=sha256:55ea99acb17b9325618de155a0cd6a2e8f5d10be008113e1d433bbb58db543b2 \ + --hash=sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a \ + --hash=sha256:588461c2e8384d309bd63e5826019b6977bc66d629b99ac8737bb795d7b2cb5a \ + --hash=sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a \ + --hash=sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c \ + --hash=sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5 \ + --hash=sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37 \ + --hash=sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e \ + --hash=sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4 \ + --hash=sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800 \ + --hash=sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055 \ + --hash=sha256:619799369eeef6366ed3e8755a5670f4f2f0fb6b30a0fd7264dc0fdc2357058e \ + --hash=sha256:62588a277bfb59def052abd940703fa35107152bf479781a878617d60faf8fb5 \ + --hash=sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c \ + --hash=sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b \ + --hash=sha256:68eb192d85ab8e5f6ec69c2bc6ac0179fbf04a5ac1569d12fbef74883fe102d0 \ + --hash=sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80 \ + --hash=sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a \ + --hash=sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4 \ + --hash=sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2 \ + --hash=sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58 \ + --hash=sha256:75a3ceed0724d625d64b86ca20aba182e4df462e04c2414fc941c0f523f06aac \ + --hash=sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc \ + --hash=sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639 \ + --hash=sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf \ + --hash=sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d \ + --hash=sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f \ + --hash=sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c \ + --hash=sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc \ + --hash=sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4 \ + --hash=sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253 \ + --hash=sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade \ + --hash=sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858 \ + --hash=sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26 \ + --hash=sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96 \ + --hash=sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8 \ + --hash=sha256:8a893cc101149f80a653f82062ebc95b34525a2614382e1da5458fe7c6997249 \ + --hash=sha256:8b2bfab86aa71ae13aa41a6a26aab338e0db2b8bc75434b05aea89e011ff35a4 \ + --hash=sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13 \ + --hash=sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1 \ + --hash=sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03 \ + --hash=sha256:93223adc95033dd47133a46ccfc316a0139176fd79085762e27202ec56018f03 \ + --hash=sha256:9373ad13ef0d2c0fb761e04e55bfdee5a08b52cef2c882c8fbe9935b1517152e \ + --hash=sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364 \ + --hash=sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4 \ + --hash=sha256:9bde855991b7e362c146535e3136a50bfaffc0487d38b33ca7e5edefc6e23849 \ + --hash=sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0 \ + --hash=sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a \ + --hash=sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036 \ + --hash=sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3 \ + --hash=sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21 \ + --hash=sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3 \ + --hash=sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e \ + --hash=sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413 \ + --hash=sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21 \ + --hash=sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346 \ + --hash=sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429 \ + --hash=sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685 \ + --hash=sha256:b736353c0a625bbd5fcec108576e2385db3496f4f771f785ff32e108d3c3bc45 \ + --hash=sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f \ + --hash=sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c \ + --hash=sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d \ + --hash=sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad \ + --hash=sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400 \ + --hash=sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb \ + --hash=sha256:c6708715abcf3c73b99508253e961a9967f02fe536532834149574eda6de0d1c \ + --hash=sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc \ + --hash=sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c \ + --hash=sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74 \ + --hash=sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf \ + --hash=sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604 \ + --hash=sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f \ + --hash=sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105 \ + --hash=sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a \ + --hash=sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d \ + --hash=sha256:d4a7319f304a774bed22115bc891618e45f85065ab44ea6acd07d274e750519a \ + --hash=sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1 \ + --hash=sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5 \ + --hash=sha256:d913de495d90407cd859d263bee2e5d1a4ed3eb6573c04e70d9ec619a7cbed7f \ + --hash=sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e \ + --hash=sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709 \ + --hash=sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874 \ + --hash=sha256:ddf19c062bea7a0cc80f519243d2c01dd091be0cf952a0750d4ad576709559f5 \ + --hash=sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc \ + --hash=sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95 \ + --hash=sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd \ + --hash=sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0 \ + --hash=sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d \ + --hash=sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3 \ + --hash=sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c \ + --hash=sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3 \ + --hash=sha256:e80e6c2f55656b4824d72065abb4ddd6a525c74bd78a0aab5d9fc2cf4fb5af50 \ + --hash=sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491 \ + --hash=sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5 \ + --hash=sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5 \ + --hash=sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655 \ + --hash=sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288 \ + --hash=sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd \ + --hash=sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084 \ + --hash=sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d \ + --hash=sha256:f5833ad231be5eb6553de524a70f48d71b2c8563101750531e0b80184e175cd4 \ + --hash=sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915 \ + --hash=sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1 \ + --hash=sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd \ + --hash=sha256:fc14a032f813bf5fe624d991960ea83e9715adc27e4c1830a2361eb1d02ac341 \ + --hash=sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424 \ + --hash=sha256:fd1fbe0f116b6e55da77aca2c6ddcddcfac2186cbf78bdebf40fc156efca389d \ + --hash=sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f + # via requests +check-jsonschema==0.38.2 \ + --hash=sha256:8edefe154b7117962f2e88318f315fc4bb9615827f932b37cb676a8d42a3a286 \ + --hash=sha256:967176475f9eddd2809baabf7e0d157f7eb482b06ac002a29a79788c0c8a5a90 + # via -r requirements.in +click==8.5.0 \ + --hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \ + --hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34 + # via check-jsonschema +fqdn==1.6.0 \ + --hash=sha256:6c793c312ccfa29981e1135b3ef5e1277ebe639731a6b0f7350d90e96e63f0b4 \ + --hash=sha256:e39bf62e2a9481aa2cb780554fdfd944dd8ee5f230eb831684d539cbafed8de2 + # via jsonschema +idna==3.20 \ + --hash=sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44 \ + --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c + # via + # jsonschema + # requests +isoduration==20.11.0 \ + --hash=sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9 \ + --hash=sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042 + # via jsonschema +jsonpointer==3.2.0 \ + --hash=sha256:3e25c9bcadf5ae20c3420922234eaba1ae36ab9bef64cd72454aeaceb0b29aad \ + --hash=sha256:807db557622fbe07a0d49e19cf4795a269d55eee5ba345fb9959d148eba5ef94 + # via jsonschema +jsonschema==4.26.0 \ + --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ + --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce + # via + # -r requirements.in + # check-jsonschema +jsonschema-specifications==2025.9.1 \ + --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ + --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d + # via jsonschema +lark==1.3.1 \ + --hash=sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905 \ + --hash=sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12 + # via rfc3987-syntax +pillow==12.3.0 \ + --hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \ + --hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \ + --hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \ + --hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \ + --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \ + --hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \ + --hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \ + --hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \ + --hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \ + --hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \ + --hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \ + --hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \ + --hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \ + --hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \ + --hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \ + --hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \ + --hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \ + --hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \ + --hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \ + --hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \ + --hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \ + --hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \ + --hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \ + --hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \ + --hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \ + --hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \ + --hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \ + --hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \ + --hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \ + --hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \ + --hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \ + --hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \ + --hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \ + --hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \ + --hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \ + --hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \ + --hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \ + --hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \ + --hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \ + --hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \ + --hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \ + --hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \ + --hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \ + --hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \ + --hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \ + --hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \ + --hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \ + --hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \ + --hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \ + --hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \ + --hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \ + --hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \ + --hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \ + --hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \ + --hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \ + --hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \ + --hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \ + --hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \ + --hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \ + --hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \ + --hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \ + --hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \ + --hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \ + --hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \ + --hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \ + --hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \ + --hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \ + --hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \ + --hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \ + --hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \ + --hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \ + --hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \ + --hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \ + --hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \ + --hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \ + --hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \ + --hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \ + --hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \ + --hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \ + --hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \ + --hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \ + --hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \ + --hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \ + --hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \ + --hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \ + --hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \ + --hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7 + # via -r requirements.in +python-dateutil==2.9.0.post0 \ + --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ + --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 + # via arrow +referencing==0.37.0 \ + --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ + --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 + # via + # jsonschema + # jsonschema-specifications +regress==2026.9.1 \ + --hash=sha256:00ed84607b4c92a070f28749de04b6c89bcd5f991e705052e15a28301759f8cf \ + --hash=sha256:02131b8cd07b22ca4891cea2fd0f0235e5e779ab9b60b5245a23d6a5656dfcf3 \ + --hash=sha256:024f8529f8cd00483ef9456115556d0182f50f10cb790f61149132a58bd041c7 \ + --hash=sha256:05fe55c73e5cc9ec7fecb448014d35a451938a9d9efbff223d34fdbbff826806 \ + --hash=sha256:088b957e51ad340e0de6daa6831dcab9be254abb9637add494c8ab0c65ccf567 \ + --hash=sha256:0c98674d5ebca4df826339bb34892bbfcb42a708edec4672acd009dfd4935331 \ + --hash=sha256:0d1441bd0aa303ceae32d480a375c1fee45dd5338f524612d19a7b7ce8881570 \ + --hash=sha256:107d4e79a9cb6125c3bd20e00d81478c98d4d07621417393da769aee8c84dfff \ + --hash=sha256:10a6a5f477583f2945a11bb15faee4ff61175e6fe2e848ce7625623c49ff3594 \ + --hash=sha256:1234118978d7a37102465f1d33d77b31d342a05add7f5f045153f33134a7340d \ + --hash=sha256:14044614223f2ffd2ff809e460cc6aeba298ca7d6c4c3e95c14be8499727fc35 \ + --hash=sha256:17caf8a6c3373d58c6cc3bb4178644a39fd918192bca41eee11fc6d5f0c45857 \ + --hash=sha256:1908de89bb448b343d3f1612809b4c944fdc8b563d77b93148d90438d2873bd7 \ + --hash=sha256:1a52f11d0d6f3e70384c23eab7a1b3aba3a4b03880e56cddef5004a6544cc63a \ + --hash=sha256:2764fc8c487c3757854488781e152309d64a7f79d838a722e5614e83e78bbe15 \ + --hash=sha256:27f20ebe3fc50d1abed6c5a7916a86e8148670017a7d19687c805270b85c3698 \ + --hash=sha256:2d9b8dc4dab679f4d09587b668395e61311dc4a7131b459201af0a0de74bf699 \ + --hash=sha256:30a3f57666c3baa353c81149f4bf1d802bd7555ffdaece88f3efe147b8c07cd7 \ + --hash=sha256:331ef4269adaefb13de18a758b151ad595dd4bdff0c60213fdbc9711eb723905 \ + --hash=sha256:358b09bb2f5fc4dbbd660b3af6fd2eae56b5c0fbdb71228fa6d593cfdbbc7738 \ + --hash=sha256:374be83f033df1a9e3693f5f93227423e3917198d138bdc20e95e2734fd67612 \ + --hash=sha256:38838e5548af34590ecc089d086333a90eab0609771aa5efce6d0e072eaa0ee0 \ + --hash=sha256:39257ecd2aa7d0cc3f87c179308be2652ed70f07933e84ef0fab42d7c79480dd \ + --hash=sha256:43e6394e741a2a0830c460eef99749c24f5bc09f434a64abff8c7fa09275783f \ + --hash=sha256:4586eb9ad9b7e6ef2ce01ff1984c0691880df2026d4550a57d2b1dc51244d190 \ + --hash=sha256:488f6569bf76dd63b902a6f417e229ae8cd856fff72a682fde3e9c0ca37bc5ac \ + --hash=sha256:4bc2a64c7491df6a545dd145c7fca25cca6488c6f7b46b57c490de2d322be60f \ + --hash=sha256:4c64f5b32feb2a9d969d78e02ec75e53c77c90c55dd64732f53458745870023f \ + --hash=sha256:4ce9328b23613856db1527e7d6d4e31beb0aadfeeecb2eaca731da566db6409e \ + --hash=sha256:4cea8127d7f4ba880d2c106e9baaf2ab7649eb2410440c87a5228b963d02fa79 \ + --hash=sha256:4d0c0397e51b31368631461f3f0485876160519fe946ae5e59583642896fb88f \ + --hash=sha256:4f657613af7714deff03242e6b3616a9cebb5ee193eaf2a190a83e569018e38a \ + --hash=sha256:51ec004d9fff5c0a2a52c3f0a65b90c85c3241473240f5a676e57ee005524e75 \ + --hash=sha256:52d5b5bc0400451283d8e41a65200682abcd6850f39b7e6b0ad924911468a75a \ + --hash=sha256:534700fc32d81f0bfd49b5899720d550f6e597ce9f69b61dff68bfbb9365c6e1 \ + --hash=sha256:53a8d3a114b67c9198b80396b0f8b3f466dccb603c71479bf9d2b16b75a323b5 \ + --hash=sha256:561eb21ad1312bddafe42cd4e08b19d0f39088139d4fb5fa63d3a5f2aba130f7 \ + --hash=sha256:563feb739013a6b5ffa0f2afdd83ed2967d5afdd2e0af84b3857b2e47c82a0a3 \ + --hash=sha256:56716962a7c42f1ddb2d9d5467282f1f4855538e9f221cb0c5f51f1c9a2056be \ + --hash=sha256:5915dfb0114c13bd7a2da2b7d4865321ace6598d765f0c23c1c488ea7d539635 \ + --hash=sha256:5c6cb1deaf37df69ec9ca04de5779d8e66cde96176521cc5d7f56659aafa571c \ + --hash=sha256:5e060e1975639f8a51587a1c45718eafbfae15f019e7e8db0db58d1a8eac0cf7 \ + --hash=sha256:619a161d66eea126676345e4c1278cfe7b70380a33045c4cc1e2db298cebd9cf \ + --hash=sha256:62ed635ce4c48abe4cde5d9351b0184756fd92a46040ce4f3887988196e5ee6e \ + --hash=sha256:633735832b7e6703cb7da0e3c09017226c32d49ee40dab839b52be623364ebd1 \ + --hash=sha256:679ca22bae6fd9a35aaf3c2be0cf0319d79dcc356ca5158dd8632e658e1d5837 \ + --hash=sha256:67a2838a170b8ddad4fbe405887248ee6fbf5e96879e074f2e78c71db5b055bf \ + --hash=sha256:6f5d8499cbce3963e345387a32a42ab63643011b52f793c3e510f13bc3c30b05 \ + --hash=sha256:71dc02455d92e38e2dd56de7d20e6716be07f0203c5d3e32336f94385466068d \ + --hash=sha256:76a365d51730a18e3431c402fc66e85bcfdad72e97cc0914063012fd90a5bee7 \ + --hash=sha256:77c06b89550e796ecc444a990f2499ef3a23ece6366b6acfc77a1dd6142ca756 \ + --hash=sha256:7b22cc785e04845f39af7de2363e287ade48bfa5fb36e1fe64e31dd393eb8d04 \ + --hash=sha256:800892721402c720181602d9b8023e4aa4e82af4a8227d412272f7aa8338e3d3 \ + --hash=sha256:82a83d8329b235383cd080ba299ced1ffa007d0340a68900b8a1aaeaf4b4b52e \ + --hash=sha256:832961859896e09f95177fb3a23105a694fcb36047ebbd785a8a60bd98f08c0c \ + --hash=sha256:8352c634d7c57d83cb55808d630dc1b2ed0138bec016dbea0f3b84b1de3771db \ + --hash=sha256:83f4da111e702dc767cd0905c39ad1e65de4c74417c2e56b22e9393cdba7d960 \ + --hash=sha256:852693954573aa76e695b684b2cff893a17c60c697291f109ad7e6b1b705500a \ + --hash=sha256:85bf7465cfc137d565d51efc4b210643dc0f739b347b49ecfaee4656dcf3d89f \ + --hash=sha256:88ee03d13ec31a2e59f68f72dc1d891bb6ac67a6095e052fa98550fd3cb5e4ff \ + --hash=sha256:89a91987820de879d2f254a4c87b425881b394b3a094c29be716a9bce236f8e9 \ + --hash=sha256:8afbf5d63444502d785e49669680638cf266c1a0462f403f24f95fb7ab8c74fc \ + --hash=sha256:8ed258eef4698f17b2b34b23f28a7b0b26717c521ae1adfc72509c106166e36f \ + --hash=sha256:9299d2da3f243a68732a910610962b02773c50263f242b2d1c8a5846fa77d41a \ + --hash=sha256:95396d09658c282f002221b4e0f67bdd2e0cfa6261322904065e98673a7a99b8 \ + --hash=sha256:9656d0ac6c13468e396e85ec10f2284ed4783eba01176176af8fd4c21beba91f \ + --hash=sha256:9742619bf72fece25a8b1f8dc1d07b6702d56ac93fcf0a6d349a15915b9e6951 \ + --hash=sha256:976ee2dcf94b42dbc722c0c34c193c0c9aeddfa0e75a0478753296bd0825e240 \ + --hash=sha256:9ce0eaa8ac412fad2f2a8e7b28f711911f2f4131e678d9e861a0a53225c6656a \ + --hash=sha256:9d806b33a8ce23b76b906144e6c26155535cadfdb63db68f124dc8938867c480 \ + --hash=sha256:a335c61d8d8f0b113d59b474177a99925c7f3f2916b93556ab91c386790e6c18 \ + --hash=sha256:a33828bd3d35422c4a7d6104d702d36d2ad5a35f55c0d239be7d52be388ccc46 \ + --hash=sha256:aa94e3d3ca2f8b2b9c4f933543b69d5f59bdc46b7b0665e4cb25f1acdeba7683 \ + --hash=sha256:abb06329ebf8e9d67425a1d8cbe29587ce86b544e951734ff8a57076f0646c27 \ + --hash=sha256:ae4d4b08d4b3c5181979255784213192c94c9c3bde327acb9043ab6973d5452e \ + --hash=sha256:ae6a26346cc36d36c7fd05729c9b249ef45815ab028bbdefb50b72ac4d6a6ac1 \ + --hash=sha256:aef0f7451d69b4475fbb437a2b8a0a301dc72b59d508ae4711c43d560c7cdec1 \ + --hash=sha256:af932e9db80e8a7b69b7dec514159aa561c6e63b0ef5479acbbbf0012d2bcff1 \ + --hash=sha256:b0fc18709fd518fa164bee4ab4bf1dd7ec436acfe9c4ba156ed6182b5c4c7936 \ + --hash=sha256:b151164c8a27610967ccd08560a7a90b52716b4108c03bd86d6471481c6621a3 \ + --hash=sha256:b1757d937b5e4ccdbec787d6ca4a725a7bddc1daad339fe892ec53d2537bdd5b \ + --hash=sha256:b26fa862de1ad19af2826601cb2f61f2961b7171afb7df7205b35917f1ed9feb \ + --hash=sha256:b475335a71d16a5ac6a2b06ee19eee1c6c5cb0d4ee055dcc3a8774b4403bba0c \ + --hash=sha256:b514d526119ab58db29bf911a17c1fee82c71e2bb81531d3a2097479eb9fe8e5 \ + --hash=sha256:c27af5007b38d30c18b4db0da96f8cda0923624eca75e1d72e1384a9d556b95d \ + --hash=sha256:c6a107bd3d08e34a5337b5b1e11fdadbe837f922f8ba9e19ee74f4337aa20809 \ + --hash=sha256:c6c38738d973ee4680da9884c11bbd473841be6d660dbd00142192c279b01789 \ + --hash=sha256:c7e6e0196ac61436d5d80ea457e24b9e94bbbf807ee6852e391e388504ea0bcb \ + --hash=sha256:c8702e8f63e7edca2bb9231c1964566f4c10865023731483ad3bbaef2eec11a3 \ + --hash=sha256:c8c39a6229d84bc63a6b30798c593cfa84ddf977b3196e2f38670dee1c24609d \ + --hash=sha256:cb43e6daa63d68133e125b550f66cc9a1ce6830fede5a1eb87ece9607c7bb622 \ + --hash=sha256:cc081b00a33cda5f209a9e8a96a9a4c6ca19c6834bdf5dc40b300c2760252477 \ + --hash=sha256:cc3e49ad2aa02a74038d577a9c9d304068f749e62930074c98772bb039cd816d \ + --hash=sha256:cdb43a1998eca27dda6d15a85fbae894819fe5cef67498731c7839e632bf4f91 \ + --hash=sha256:cee77dfc09f0cb808d7714a379a2d932f3c1977b67de460e2bfd6c5d9b77c828 \ + --hash=sha256:cf60b147c11bdad55a88349948404f29454f0ee626e8f2c1c84a08f779ee8892 \ + --hash=sha256:d2df372f9ddddf63db7f4ca13c754e9e566a00ce202e73b2b441736721189862 \ + --hash=sha256:d30627f95b49c8a468c335b41af8480352c3c0caf8ab05d09fab68ba5246625c \ + --hash=sha256:d30635078c0c2c8adb8f12643a474decd929c4848f6c99ecc4a4a32e2531e9cf \ + --hash=sha256:d36b5a47782d41a358efa9554e79b6f83f92e08190b6d84135dab3b72522b2b7 \ + --hash=sha256:d508164d30f7fc87e5c2ddb4f49e1b5fc266ad015aca2ee43905aff10f33d78c \ + --hash=sha256:e2993f6b00ed267c7c027b4d9e6ef1a2af49d6367b10cd0c3f2f346fb14294cd \ + --hash=sha256:e7b547386bf97b82a23e62471272742492c0b2de62cf0ee98247fd37cc42f169 \ + --hash=sha256:ea1948c5d502e829287a15ab48897f558344d9140b45eaa2a2bfc68e6642a6b2 \ + --hash=sha256:ecd5e59511b89b481592fd410d7a9152ef85213673a6a93aba462726837445a1 \ + --hash=sha256:ed1d8dff8269590a23538d73c9b8def4e24330a2bcfc079af93e70db21696fdb \ + --hash=sha256:f0278c97d27f210b9c6b6729736ccc97ce86a4bf6b2f37611220713c0f83e293 \ + --hash=sha256:f0388f859661480f9e2fc5d618ef0c3612e0904600035657f54b9cd23dd26e85 \ + --hash=sha256:f3617c07bc42ee11041439cd05554222a12b28cb36d40337acc8cbec0022089d \ + --hash=sha256:f3ffa280c0ca9bc3946922e86ffc0c61038e9bd849f0f928d3ef32f42d6e40f1 \ + --hash=sha256:f41339a19a837dd67c23ef5aef4c968be6a95e99f4c99555bb7583964fad01a9 \ + --hash=sha256:f5a0586fcd3f630af280d807bf81f61a2a653c83f7aefc7d4c1625d3e3b93159 \ + --hash=sha256:f5d41349e76357211e18d5b6c1a5a65ede6e65e5d19ed725d6d765def0a72af4 \ + --hash=sha256:f72b40ee6bd01ac00757d5699585ddfd8c4f72002ab73effd2e3719659f85dd7 \ + --hash=sha256:fdc3dcf0ba541aaaa15e2af4f297baeebb8c326111d30555dece3f091b0392a7 \ + --hash=sha256:ff1b64c5750cb37a479f3f8375a0305c801caffd3b736c04d852e0f1263155ec + # via check-jsonschema +requests==2.34.2 \ + --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ + --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed + # via check-jsonschema +rfc3339-validator==0.1.4 \ + --hash=sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b \ + --hash=sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa + # via jsonschema +rfc3986-validator==0.1.1 \ + --hash=sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9 \ + --hash=sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055 + # via jsonschema +rfc3987-syntax==1.1.0 \ + --hash=sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f \ + --hash=sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d + # via jsonschema +rpds-py==2026.9.1 \ + --hash=sha256:00ba2d8c7dd4ee537978ddf4b3fbd712bef2d8751603f7f3146b3f4287768e25 \ + --hash=sha256:01445c8d194aa032a08e944f16567672da1c62dbdbefd8b6d0693032e290cf68 \ + --hash=sha256:028ad274ea951dac64491b5d1e65712a4aeabfdbdb9fccf797b57bd899b0c495 \ + --hash=sha256:0483515261947e4e8b8e1375bf7463e7eb6ccfb3d86e7b554d90cd5285f20f32 \ + --hash=sha256:068c37bba854ec2fe42f7365c640af11dd9895890ccbf2df5070d0c059bd7f96 \ + --hash=sha256:074a4d198bc34d9a8ea425114fc3ded6d11ec01f6a314a8db67454a5152d8834 \ + --hash=sha256:07deecbfce94c78473018bc7d10b337cc651d12df87a1eb2cb3e4024bc9c33d0 \ + --hash=sha256:08dae4a4095150a7c4545a1fb40b98e1ab1744fbc2770d92c977b9dadaa49ab6 \ + --hash=sha256:0da298fb372dc192610a4b9ecbc68a0cd8b675bbbd1fc519d01b41cfd658333e \ + --hash=sha256:0f045bb053c9057720d72c56dffe30dffdc05997b2897a827b9325f0ab6623fa \ + --hash=sha256:10e208f2425d973938afcd56e28a7c4be32e27b6a60b5d381f49fb9d8acf9759 \ + --hash=sha256:136a1c3fe4402b7008bc81cb62ee538481795b61a7e83df88dff3b3f02b726ff \ + --hash=sha256:159a7aab5c5e8b112c8830f54717ce56da1252ebdbb526f5be2df2309280b9e7 \ + --hash=sha256:172e47169583f46ce118cbec68e6795d0da0f4606b488b6434f8276bca0a058c \ + --hash=sha256:1c2d1f6da5128eabf34e963d7163a818846075a52568250d006c4c953b40f903 \ + --hash=sha256:1d55198263bb51f557550c6ed2e6d1cb6a6fed6eb5c9120b741c5926bef8a45d \ + --hash=sha256:1d77b649e6f7cdf12ca5c2a98dad0ad37f9ea9b6f960408a92f0cb12bb3d04d9 \ + --hash=sha256:1e8d4d79d828299bf44a55db22a9388ab967b49d17132c88eab0f4360b48da8e \ + --hash=sha256:22ffd29a63d71fb1b81552c21f2c2b734949b7ac751a9be70675a939a900839b \ + --hash=sha256:2693b2728bbcc48d09a981a356954b0c47c53ff25b545856f28a889ea619f69a \ + --hash=sha256:270bdcdaac5d5b6f73c5e22e7e135c7f2a50e789f71d9e241d5be8d90026e19a \ + --hash=sha256:2711d29b653b3bce48a63d18b9c6b53274669e6d6c4094dddeb4d9a0e45128b2 \ + --hash=sha256:2c16ab111bc27c646ba8aa005d0527754edc538ebb636f0b1bf8e244b48d1945 \ + --hash=sha256:306ee1850d8105b5baf977e78d45fcadd12c1a54678d614c9baf217708446e91 \ + --hash=sha256:3231c4c0e521dafa5be0c9f114ee2c2ad46650836f2d72caa86801950c3e7044 \ + --hash=sha256:3890a6aa36e6baa53d5258a2a25d3ef8b37ad165a6ab27a892d7c3e3a432cd69 \ + --hash=sha256:3a72c11530d71abfb66c8d7696a2f86c43e63fca8b948f1a784ac490f4ec688e \ + --hash=sha256:3b5a6f40f0a1486b4b36c888123afc67acdbd9f33235927acf5ff295429a0ba3 \ + --hash=sha256:3c91c210ae7645626c608400e3519b4a642f837cce09ca830db3beb2e9f274d4 \ + --hash=sha256:3cd182d7291d29b92c521a0069d9c01ba6193628a9a105531d11b40a6d731a33 \ + --hash=sha256:3e524c7874ac72884d28e16dd5b8d839fd09e0fe76b020d3fbca23212a7b8c52 \ + --hash=sha256:3e93b2cd69a9830be33e03945cd7cda940a0a8bfcfbff41d6144f0cb0d3d8bd9 \ + --hash=sha256:3edae8c5ddfdb6985d49ae9d150516e5076888879022f91a26c2de9276ce0bdb \ + --hash=sha256:3f0e9ac28fc067d4d34b88ae43c48e9489455c97fee9633d851f7eeed5a05d35 \ + --hash=sha256:42e75466f83cd43f6026c81eab74246efb2bdadafb307b85700632d06c68f299 \ + --hash=sha256:44b32a7c4f0da3d28af31c259e38ddcff096f855e205ed0671d02fcf44f1ea1c \ + --hash=sha256:457866b85daf5034296666168b84a69e0b2e89dc4f1af102b46f6448a60b9063 \ + --hash=sha256:45bc6bccf78b20fd834237d18db64965d7ee68ba7f60440a26c7ab71e7b8d51a \ + --hash=sha256:46d80bc76b51a6c24f9944368c28d38b8bcbcea1da4f2f8d3ebc31a67e8c6ec6 \ + --hash=sha256:4793ef7f78268b124b73fa933440f01d258bbae01de9fa53e9080c9ab0425a12 \ + --hash=sha256:492e5e428cbe126221611f47e068f01660352feec4ad18bc0f5ea9b2ae88fb14 \ + --hash=sha256:4b26b03d9d2658ee2fa234f8f4f19f38a09773fe5261028025032e26d4d35af0 \ + --hash=sha256:4c0d2cb595a420b34d5086db0add011e26e2c09d6a024afbac4228bf8f863a30 \ + --hash=sha256:4cfaf02209061880210819934de2f4f6aa83dc04dafe6770276acc240a56da31 \ + --hash=sha256:501909f2e4a1e2dee528ef766fe3c469060ebc17e54a8383d404ba07a81a6f02 \ + --hash=sha256:50906f5aea24b5a865cbd0a589698288631d9f3a54c3a937c83aefa95a0d14af \ + --hash=sha256:54ac2158a6f96cfbabff0b2eedaf94b90c5ec7ca8317fcadc61e1c2b2e0ff6ef \ + --hash=sha256:56c6952a9b15047466d0c2347c446a761d4527f89976156341e68f0ce5cc08b0 \ + --hash=sha256:56cd8b3f77d7b6812f533b662186a1f28316931166ddc00fb893b1b0db7e9888 \ + --hash=sha256:57492a550a1d88d29d003247e5f78dd8cf04a701fac0e4c8db8745a6d2504e0a \ + --hash=sha256:5943980471829f6de242a20b109de3111ba6b77e3af0ffc587028ac854b05e6c \ + --hash=sha256:5c6ee90dee3e85e055ddfd502d611643d9b0fd94c818220bda84ec3dacd9b27b \ + --hash=sha256:5c90e7fa02e8f5de0d10c17595c568ada48c5302e749462c0ea1a4c362111a86 \ + --hash=sha256:5ce8943f79c2210f7abcc28e86367b03b28d95027fd01c46d2472373ae70c86f \ + --hash=sha256:617f59cde379b4f648a09797b7f683d04b90a46344cddab85639da5aff0f5531 \ + --hash=sha256:6307a0da524939decb8ca4a3933b8ab62525794411d6984fca6726e732804af6 \ + --hash=sha256:684fd492fff4fead00587544e059be2bbcb6f93454f21fa2a91b66fc7508be82 \ + --hash=sha256:6b5b393eda5ea42cca1c1a6665f2a4882b4fd5d1777e41ce0545a107fb008c9d \ + --hash=sha256:6b723eb406dec5bc9ec516c73ab9c3239a3284e017f7eb89ee2b3258bd504fb7 \ + --hash=sha256:6b9bf3135b4ad5981df9a73d71a35272d650a2985ae9c2746357b24d59de2448 \ + --hash=sha256:6beb738155fe8ab8091afdfa5a3226b21c2b1593f1e50ebb90eb25b44dbc0391 \ + --hash=sha256:6c0dbbcc19735fe5f8b0a54c07659d154a9e69f47e15d0a6ab7299215daf62cb \ + --hash=sha256:6cdc537c8633d7fd92a82e2e0d2ab74320a3f63d5e59fb9cf08711e08fe151c4 \ + --hash=sha256:6eae33003518fd4cb4f83a218d5371469dd3001aa3b87128c005b07762f7fe5e \ + --hash=sha256:740d0a99cf9de0b17a3943388e9294a59becf75e7c43421f387bd3c7a9901f7c \ + --hash=sha256:75c38c50ab9aca840225d9a9a3810bf11d04bd5c1f186cabbb8aee56db3e9b15 \ + --hash=sha256:761fdae6728ceb99ab182fad2f0cc1e262f610834dc891aea1d1a2a2e634776f \ + --hash=sha256:7664419f27db41d4f1c43a78dccda7dd6e8ef2428df3ee01d0c2a07a6b071297 \ + --hash=sha256:76d3af9732d2dab69f28179b40ba2d87e2f1d5824b4a694780aa787d685e8f36 \ + --hash=sha256:78326f4cb4427a56ba4996c0762b63be45f06b85f086526420d2b3a66e40f84d \ + --hash=sha256:7868b85224291c6cb6759f9b5adb9745f486d226f62b16a614dd5a2a5ab2b35b \ + --hash=sha256:815d26356930846a40c7bc1366e7b1b0320ab8a063e66c11298a208bed0fd237 \ + --hash=sha256:8171b44a054e5c67fd748ada04187f1250bf35b95f85e52ab64bcf3331a923bb \ + --hash=sha256:821b2755db9194409254012f429c56643416fb96ef9be090be82ec8826b7f477 \ + --hash=sha256:837c6b305e26fe0f75b15c92cf3b2ba29e0ae19dc40b1c557b026cb426347d0c \ + --hash=sha256:839dde845559254f34885267c6878f60d61d5205180226d976fe488d45fa128e \ + --hash=sha256:84a6ecc0c940169190d2c23bd969debd48c94dbc855acd60188a68d71d421608 \ + --hash=sha256:8601470267d938bcb7f3ab1a336100af51a4fd5b6ed030ef52461bb3ef5e7e07 \ + --hash=sha256:88b5268892fde430d5531f95bc560b6efbbd67c929662c586afd729a96e7461c \ + --hash=sha256:8aa5dda18d39b6143eb24809d158f9252c88f402749b6f1b62a506cc7d96cc35 \ + --hash=sha256:926bdd3e3b5998ddf70cc64bc8cf57209571f9044542913afb673799fec77dd0 \ + --hash=sha256:96beca19ec79de272e8668585380ff9092c47077c1d7a1e098e00bbd921f4785 \ + --hash=sha256:9a0460d43603d1fd9ef59c30278531e15d78581721ddb538fa560aa7817ea4ad \ + --hash=sha256:a03d57b86d2a51d0a66c92177e2be154ad015f357791d306e714569999cdb4cc \ + --hash=sha256:a36b70596407634ca82d4b989a3729074a008537a0522e4c8046a67c729103e9 \ + --hash=sha256:a3a52a3ba86436ab3aef510fbe21512abc2ddd1993005dfe50514bd2284ef025 \ + --hash=sha256:a3dbc5ed9514908d5046107d7b1346bde71eea61de6e0e4919c19354f97e769f \ + --hash=sha256:a431156bb41865fc14cd5d79bb9d7bbed83110b0159e34e62ae30951f96c0009 \ + --hash=sha256:a575404ebc9cf2e91edd32eaf570ec1430eb900d4f56724ba7dd4bc1fc9c176d \ + --hash=sha256:a5cf77eb04f20b720be95265a3e00eb2a14814074255cc27069c551b2db53118 \ + --hash=sha256:a8763f20692da7df39b0afdd1ba3042b004c50a45994f76c2d9a25641f7673db \ + --hash=sha256:ab4b2fda7c2b542f7f9d886cc6a838c5079d2b76f72e6081411faba11adde2c9 \ + --hash=sha256:addeda51556dac7c1a2f14cda62db8b621cd12afba3091d03a96c72932387eab \ + --hash=sha256:b242c27c8f836305a4a72df9cdd564386ac57b807bd252a063223331c9316b37 \ + --hash=sha256:b4f062343e7ad3fa94f2c66e5ae667dee47ee74dd41a9057c4fbe163236a123d \ + --hash=sha256:b5b8b0753718d258fd454283fbd57e14545d3b40583fa672e27cb4f987626bcc \ + --hash=sha256:be3e47e2d91aa3942ff9bf4077a505226005abfc39b6f7554a91c1b9393986b9 \ + --hash=sha256:befc2d6a953e563f8a7bfd87a42c22ebf8a3e980dcb7b6a4d17b70b0e914e8a3 \ + --hash=sha256:bf35d0568abda97233239ce32896d3ad53fccc537832c104e30c94aa5fb93569 \ + --hash=sha256:c933c6678c6f116ff8af47a4c6db0868b8ace74af0343016c0ef00f00272ea69 \ + --hash=sha256:c9d1aca01f49170fdcf5c92761b1fafe97f554b721ca4570c5949fff778f0d4b \ + --hash=sha256:cdeaa99ce822dca76cfb1b993e9120c5ea212f2eb66d48950ad63c349668a018 \ + --hash=sha256:ce4d4f52e2a4324396caddbd45a97d8d7be5f42edd25d2355282a9c34f9b2f7f \ + --hash=sha256:d1028417bb44037eb3069c1009bd7b7277212876cda22fbe565b0bca9fab6d2c \ + --hash=sha256:d151e148117294133bf8af7eeace085e7e87432db15ab6adf640330298a47f6f \ + --hash=sha256:d7841166b7fa64c9c56404617ae4341448847482d45933b13135d26c130519e5 \ + --hash=sha256:d7fca4eb6df565e2a928f1c7dad92d27db8f9df0f449e76423ed5d7e713ed445 \ + --hash=sha256:d95a354e02393eada6d7351184671aced9d4cce109dabf927cb7aa99624352a1 \ + --hash=sha256:d9edf30457d74eebfd76b045535e36f1cd89062566a128a0db2145ca042d787e \ + --hash=sha256:dbc2673f9223d420c91145599b3ba45a8a50c207d1976908e5fb5ddb0c9b9429 \ + --hash=sha256:e01b3c878c8641913e688edd1b3f08658c6783d29cf6b826bd3c0d1ae7a1ffaa \ + --hash=sha256:e21c1429e205828ea886a2293a4a2c8e01f4c25d9893ca330e97a6cf73f52e7b \ + --hash=sha256:e43d4a1f673e8a1cbd8533e809e02b4bf9d4f2280269bb640436556312121250 \ + --hash=sha256:e6d198bad4e49dd6732fbd636e2fc5c082f45c8cad0b4acb756b00c82c76072e \ + --hash=sha256:e6ea1cda8d8c688278430e4268a42f5e5da3bdd74578dfadc0820c3f1766ce83 \ + --hash=sha256:ea394a937f17a54c51239348bdbe2e3518124c8d4a8951ba04a311d3095bd18f \ + --hash=sha256:eac2f5dbafd585dfe31f86a23ebf0d3ba480a9d49ebc87947267b5608d4ea0cd \ + --hash=sha256:eb61be926bb81567c1f48bdc8aa22b9855048dc2efd53871f9f7e6e9a5632346 \ + --hash=sha256:eba5d173f7d5708b22a93815017a4611873ed54db9f268077c0dd1ed99cfc858 \ + --hash=sha256:ec450527cbf485e13c8d3602a54f428ab0432fdade0ede75efd74b735421c871 \ + --hash=sha256:eef6a03b0b6d08d0835ccfa8ec8d1bc70525e3801387567137b50c557695e6da \ + --hash=sha256:ef0d8c843e2827d6c120ab4687e9423fb1d893db1df27b7c1506615bcb9734a0 \ + --hash=sha256:ef6b65b03247c54692ad4fd9ee97cb772781927db72e3cb05e70b3db6d1ff14f \ + --hash=sha256:f3d6ed6a98cfd19155996605474982cc470d7601746a6439078f1a5a3fa8b050 \ + --hash=sha256:fce4b85234a0cbad67bf8e6e1201ee815d172c9aebad75f25645bc4d834f8e31 \ + --hash=sha256:fda1d96e542c37b6c804547dbf489c129fe7c97183a76a5ec275909ba1a063df \ + --hash=sha256:fdcd198979b4ecffcc1beba366a7fbcf4eb41243691a82fe52ceb0b902f09c12 \ + --hash=sha256:fe5ad0664ec772b02c45859041aa17655709cced7a31005817fbbbd988c25567 + # via + # jsonschema + # referencing +ruamel-yaml==0.19.1 \ + --hash=sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93 \ + --hash=sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993 + # via check-jsonschema +six==1.17.0 \ + --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ + --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 + # via + # python-dateutil + # rfc3339-validator +tzdata==2026.5 \ + --hash=sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7 \ + --hash=sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac + # via arrow +uri-template==1.3.0 \ + --hash=sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7 \ + --hash=sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363 + # via jsonschema +urllib3==2.8.0 \ + --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 \ + --hash=sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63 + # via requests +webcolors==25.10.0 \ + --hash=sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d \ + --hash=sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf + # via jsonschema diff --git a/schema/curation.schema.json b/schema/curation.schema.json new file mode 100644 index 0000000..4a319a1 --- /dev/null +++ b/schema/curation.schema.json @@ -0,0 +1,206 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://integrations.tablepro.app/schema/v1/curation.schema.json", + "title": "TablePro integrations curation files", + "description": "Maintainer-owned files in curation/. Each file matches exactly one $defs entry, chosen by file name.", + "oneOf": [ + { "$ref": "#/$defs/officialPublishers" }, + { "$ref": "#/$defs/partners" }, + { "$ref": "#/$defs/reserved" }, + { "$ref": "#/$defs/removed" }, + { "$ref": "#/$defs/protectedMarks" }, + { "$ref": "#/$defs/scanAllowlist" } + ], + "$defs": { + "slug": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "maxLength": 40 }, + "githubLogin": { + "type": "string", + "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$" + }, + "date": { + "type": "string", + "format": "date", + "pattern": "^20[2-9][0-9]-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])$" + }, + "httpsUrl": { + "type": "string", + "format": "uri", + "maxLength": 300, + "pattern": "^https://(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\\.)+[A-Za-z]{2,63}(?:[/?#][^\\s<>\"'\\\\]*)?$" + }, + "plainLine": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "pattern": "^(?=\\S)(?!.*\\s$)[^\\u0000-\\u001F\\u007F<>]+$" + }, + "officialPublishers": { + "description": "curation/official-publishers.json: GitHub accounts whose entries may use tier official.", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "publishers"], + "properties": { + "$schema": { "const": "../schema/curation.schema.json" }, + "publishers": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["github", "githubId", "name"], + "properties": { + "github": { "$ref": "#/$defs/githubLogin" }, + "githubId": { "type": "integer", "minimum": 1 }, + "name": { "$ref": "#/$defs/plainLine" } + } + } + } + } + }, + "partners": { + "description": "curation/partners.json: vendors verified for tier partner, one row per entry.", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "partners"], + "properties": { + "$schema": { "const": "../schema/curation.schema.json" }, + "partners": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["slug", "github", "githubId", "domain", "method", "verifiedAt", "evidence"], + "properties": { + "slug": { "$ref": "#/$defs/slug" }, + "github": { "$ref": "#/$defs/githubLogin" }, + "githubId": { "type": "integer", "minimum": 1 }, + "domain": { + "type": "string", + "pattern": "^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}$" + }, + "method": { + "description": "github-verified-org: the GitHub org shows the domain as verified. dns-txt: a TXT record on the domain holds the token from the verification issue.", + "enum": ["github-verified-org", "dns-txt"] + }, + "verifiedAt": { "$ref": "#/$defs/date" }, + "evidence": { "$ref": "#/$defs/httpsUrl" } + } + } + } + } + }, + "reserved": { + "description": "curation/reserved.json: slugs and names no third-party entry may use.", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "slugs", "names", "nameFragments"], + "properties": { + "$schema": { "const": "../schema/curation.schema.json" }, + "slugs": { "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/slug" } }, + "names": { + "description": "Compared after NFKC, casefold and removing spaces and punctuation.", + "type": "array", + "uniqueItems": true, + "items": { "$ref": "#/$defs/plainLine" } + }, + "nameFragments": { + "description": "Fragments that may not appear in a third-party slug or name, compared the same way.", + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-z0-9]{3,40}$" } + } + } + }, + "removed": { + "description": "curation/removed.json: tombstones. A removed slug cannot be reused.", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "removed"], + "properties": { + "$schema": { "const": "../schema/curation.schema.json" }, + "removed": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["slug", "name", "removedAt", "reason", "link"], + "properties": { + "slug": { "$ref": "#/$defs/slug" }, + "name": { "$ref": "#/$defs/plainLine" }, + "removedAt": { "$ref": "#/$defs/date" }, + "reason": { + "enum": [ + "author-request", + "unmaintained", + "broken", + "private-files", + "brand", + "license", + "security", + "malicious", + "impersonation", + "duplicate", + "superseded" + ] + }, + "link": { "$ref": "#/$defs/httpsUrl" }, + "publisherGithubId": { "type": "integer", "minimum": 1 } + } + } + } + } + }, + "protectedMarks": { + "description": "curation/protected-marks.json: perceptual hashes of TablePro icons. A third-party icon this close is rejected.", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "algorithm", "maxDistance", "marks"], + "properties": { + "$schema": { "const": "../schema/curation.schema.json" }, + "algorithm": { "const": "dhash16-alphabox" }, + "maxDistance": { "type": "integer", "minimum": 0, "maximum": 128 }, + "marks": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "hash", "source"], + "properties": { + "id": { "$ref": "#/$defs/slug" }, + "hash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "source": { "$ref": "#/$defs/plainLine" } + } + } + } + } + }, + "scanAllowlist": { + "description": "curation/scan-allowlist.json: source-scan hits a maintainer reviewed and accepted, such as a comment that names a private file.", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "allow"], + "properties": { + "$schema": { "const": "../schema/curation.schema.json" }, + "allow": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["slug", "file", "rule", "reason"], + "properties": { + "slug": { "$ref": "#/$defs/slug" }, + "file": { "type": "string", "maxLength": 300, "pattern": "^[^\\u0000-\\u001F]+$" }, + "rule": { "type": "string", "pattern": "^[a-z0-9-]+$" }, + "reason": { "$ref": "#/$defs/plainLine" } + } + } + } + } + } + } +} diff --git a/schema/index.schema.json b/schema/index.schema.json new file mode 100644 index 0000000..5e968a5 --- /dev/null +++ b/schema/index.schema.json @@ -0,0 +1,270 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://integrations.tablepro.app/schema/v1/index.schema.json", + "title": "TablePro integrations index, v1", + "description": "The published index, at https://integrations.tablepro.app/v1/index.json and as the index.json asset of each release. A release holds index.json, SHA256SUMS and every image this file names, each as a flat asset listed in SHA256SUMS. Consumers ignore unknown properties and treat unknown enum-like values (tier, category, surface, install type, platform) as absent or as other. New optional properties and new values do not change v1. A removed or renamed property, a new required property or a type change ships as v2 beside v1.", + "type": "object", + "required": [ + "schemaVersion", + "generatedAt", + "commit", + "contentSha256", + "integrations", + "removed" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "commit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "release": { + "description": "Release tag this file was attached to. Absent on Pages builds made before the release exists.", + "type": "string", + "pattern": "^v1\\.[0-9]{8}\\.[0-9]+$" + }, + "contentSha256": { + "description": "SHA-256 over everything except generatedAt, commit and release. Equal values mean the same data.", + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "integrations": { + "type": "array", + "items": { + "$ref": "#/$defs/entry" + } + }, + "removed": { + "description": "Tombstones. A consumer answers 410 Gone for these slugs.", + "type": "array", + "items": { + "type": "object", + "required": [ + "slug", + "removedAt", + "reason" + ], + "properties": { + "slug": { + "type": "string" + }, + "removedAt": { + "type": "string", + "format": "date" + }, + "reason": { + "type": "string" + } + } + } + } + }, + "$defs": { + "image": { + "type": "object", + "required": [ + "file", + "width", + "height", + "sha256" + ], + "properties": { + "file": { + "description": "Flat file name ending in the first 12 hex digits of its SHA-256, for example raycast-icon-512-0123456789ab.png. It resolves relative to index.json: a release asset of the same name, or the file beside index.json on Pages. Safe to cache forever.", + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-f]{12}\\.png$" + }, + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + }, + "entry": { + "type": "object", + "required": [ + "slug", + "name", + "summary", + "summarySha256", + "description", + "tier", + "status", + "publisher", + "source", + "closedSource", + "install", + "platforms", + "categories", + "surfaces", + "disclosures", + "links", + "icon", + "screenshots", + "addedAt", + "lastVerifiedAt" + ], + "properties": { + "slug": { + "type": "string" + }, + "name": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "summarySha256": { + "description": "SHA-256 of summary. A consumer that stores translations keeps this beside each one to spot a changed English source.", + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "description": { + "type": "string" + }, + "tier": { + "type": "string" + }, + "status": { + "type": "object", + "required": [ + "state" + ], + "properties": { + "state": { + "type": "string" + } + } + }, + "publisher": { + "type": "object", + "required": [ + "name", + "github", + "githubId" + ] + }, + "source": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "url": { + "description": "Browsable source URL, derived from repo and path.", + "type": "string" + } + } + }, + "closedSource": { + "type": "boolean" + }, + "license": { + "type": [ + "string", + "null" + ] + }, + "install": { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "type": "string" + }, + "url": { + "description": "Resolved destination for the install button, for every install type.", + "type": "string" + } + } + }, + "platforms": { + "type": "array", + "items": { + "type": "string" + } + }, + "categories": { + "type": "array", + "items": { + "type": "string" + } + }, + "surfaces": { + "type": "array", + "items": { + "type": "string" + } + }, + "disclosures": { + "type": "object" + }, + "links": { + "type": "object", + "required": [ + "homepage" + ] + }, + "icon": { + "type": "object", + "required": [ + "512", + "128" + ], + "properties": { + "512": { + "$ref": "#/$defs/image" + }, + "128": { + "$ref": "#/$defs/image" + } + } + }, + "screenshots": { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "#/$defs/image" + } + ], + "required": [ + "alt" + ], + "properties": { + "alt": { + "type": "string" + } + } + } + }, + "addedAt": { + "type": "string", + "format": "date" + }, + "lastVerifiedAt": { + "type": "string", + "format": "date" + } + } + } + } +} diff --git a/schema/integration.schema.json b/schema/integration.schema.json new file mode 100644 index 0000000..de770fb --- /dev/null +++ b/schema/integration.schema.json @@ -0,0 +1,571 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://integrations.tablepro.app/schema/v1/integration.schema.json", + "title": "TablePro integration manifest", + "description": "One integration in the TablePro directory. File: integrations//integration.json. Text is English and plain: no HTML, no Markdown.", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "schemaVersion", + "slug", + "name", + "summary", + "description", + "tier", + "status", + "publisher", + "source", + "install", + "platforms", + "categories", + "surfaces", + "disclosures", + "links", + "icon", + "addedAt", + "lastVerifiedAt" + ], + "properties": { + "$schema": { "const": "../../schema/integration.schema.json" }, + "schemaVersion": { "const": 1 }, + "slug": { + "description": "Folder name and URL path segment. Never changes after the first merge.", + "type": "string", + "minLength": 2, + "maxLength": 40, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "name": { + "description": "Shown as the entry title. Third-party entries do not include TablePro. At most 32 characters, so a page title stays within 60.", + "allOf": [{ "$ref": "#/$defs/plainLine" }], + "minLength": 2, + "maxLength": 32 + }, + "summary": { + "description": "One sentence for the card. Ends with a period.", + "allOf": [{ "$ref": "#/$defs/plainLine" }], + "minLength": 20, + "maxLength": 120, + "pattern": "[^.]\\.$" + }, + "description": { + "description": "Plain text for the detail page. Paragraphs are separated by one blank line.", + "allOf": [{ "$ref": "#/$defs/plainText" }], + "minLength": 80, + "maxLength": 1200 + }, + "tier": { + "description": "official: built by TablePro. partner: built by the vendor of the other product, listed in curation/partners.json. community: everyone else.", + "enum": ["official", "partner", "community"] + }, + "status": { + "type": "object", + "required": ["state"], + "properties": { "state": { "enum": ["active", "archived"] } }, + "allOf": [ + { + "if": { "properties": { "state": { "const": "active" } } }, + "then": { "$ref": "#/$defs/statusActive" } + }, + { + "if": { "properties": { "state": { "const": "archived" } } }, + "then": { "$ref": "#/$defs/statusArchived" } + } + ] + }, + "publisher": { + "description": "Shown as By . The GitHub account is how ownership is checked.", + "type": "object", + "additionalProperties": false, + "required": ["name", "github", "githubId"], + "properties": { + "name": { "allOf": [{ "$ref": "#/$defs/plainLine" }], "minLength": 1, "maxLength": 60 }, + "github": { "$ref": "#/$defs/githubLogin" }, + "githubId": { "type": "integer", "minimum": 1 }, + "url": { "$ref": "#/$defs/httpsUrl" } + } + }, + "source": { + "type": "object", + "required": ["type"], + "properties": { "type": { "enum": ["github", "closed"] } }, + "allOf": [ + { + "if": { "properties": { "type": { "const": "github" } } }, + "then": { "$ref": "#/$defs/sourceGitHub" } + }, + { + "if": { "properties": { "type": { "const": "closed" } } }, + "then": { "$ref": "#/$defs/sourceClosed" } + } + ] + }, + "license": { + "description": "SPDX license expression. Required for open source, absent for closed source.", + "type": "string", + "maxLength": 80, + "pattern": "^[A-Za-z0-9.+-]+(?: (?:AND|OR|WITH) [A-Za-z0-9.+-]+)*$" + }, + "install": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "enum": [ + "built-in", + "ships-with-host", + "raycast-store", + "vscode-marketplace", + "github-release", + "homebrew", + "app-store", + "alfred-gallery", + "page" + ] + } + }, + "allOf": [ + { "if": { "properties": { "type": { "const": "built-in" } } }, "then": { "$ref": "#/$defs/installBuiltIn" } }, + { "if": { "properties": { "type": { "const": "ships-with-host" } } }, "then": { "$ref": "#/$defs/installShipsWithHost" } }, + { "if": { "properties": { "type": { "const": "raycast-store" } } }, "then": { "$ref": "#/$defs/installRaycastStore" } }, + { "if": { "properties": { "type": { "const": "vscode-marketplace" } } }, "then": { "$ref": "#/$defs/installVSCodeMarketplace" } }, + { "if": { "properties": { "type": { "const": "github-release" } } }, "then": { "$ref": "#/$defs/installGitHubRelease" } }, + { "if": { "properties": { "type": { "const": "homebrew" } } }, "then": { "$ref": "#/$defs/installHomebrew" } }, + { "if": { "properties": { "type": { "const": "app-store" } } }, "then": { "$ref": "#/$defs/installAppStore" } }, + { "if": { "properties": { "type": { "const": "alfred-gallery" } } }, "then": { "$ref": "#/$defs/installAlfredGallery" } }, + { "if": { "properties": { "type": { "const": "page" } } }, "then": { "$ref": "#/$defs/installPage" } } + ] + }, + "host": { + "description": "The other product this integration lives in or connects to.", + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { "allOf": [{ "$ref": "#/$defs/plainLine" }], "minLength": 1, "maxLength": 40 }, + "url": { "$ref": "#/$defs/httpsUrl" }, + "minVersion": { "$ref": "#/$defs/version" }, + "note": { + "description": "A paid tier or add-on the host needs, for example Alfred Powerpack.", + "allOf": [{ "$ref": "#/$defs/plainLine" }], + "maxLength": 120 + } + } + }, + "minTableProVersion": { + "description": "Oldest TablePro release this works with, per platform. Keys must be in platforms.", + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "macos": { "$ref": "#/$defs/version" }, + "ios": { "$ref": "#/$defs/version" }, + "ipados": { "$ref": "#/$defs/version" } + } + }, + "platforms": { + "description": "TablePro apps this works with, not the host's platforms.", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "enum": ["macos", "ios", "ipados"] } + }, + "categories": { + "type": "array", + "minItems": 1, + "maxItems": 2, + "uniqueItems": true, + "items": { + "enum": [ + "ai-clients", + "automation", + "cloud-hosting", + "command-line", + "editors", + "launchers", + "local-development", + "other" + ] + } + }, + "surfaces": { + "description": "Documented TablePro interfaces the integration uses.", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": ["url-scheme", "mcp", "pairing", "applescript", "database-url", "cli", "shortcuts"] + } + }, + "disclosures": { "$ref": "#/$defs/disclosures" }, + "links": { + "type": "object", + "additionalProperties": false, + "required": ["homepage"], + "anyOf": [{ "required": ["issues"] }, { "required": ["support"] }], + "properties": { + "homepage": { "$ref": "#/$defs/httpsUrl" }, + "docs": { "$ref": "#/$defs/httpsUrl" }, + "issues": { "$ref": "#/$defs/httpsUrl" }, + "support": { "$ref": "#/$defs/httpsUrl" }, + "privacy": { "$ref": "#/$defs/httpsUrl" }, + "changelog": { "$ref": "#/$defs/httpsUrl" } + } + }, + "icon": { + "description": "PNG, exactly 512x512, at most 512 KB, in the entry folder.", + "const": "icon.png" + }, + "screenshots": { + "type": "array", + "minItems": 1, + "maxItems": 4, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["file", "alt"], + "properties": { + "file": { "type": "string", "pattern": "^screenshot-[1-4]\\.png$" }, + "alt": { "allOf": [{ "$ref": "#/$defs/plainLine" }], "minLength": 10, "maxLength": 200 } + } + } + }, + "keywords": { + "type": "array", + "maxItems": 8, + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-z0-9][a-z0-9 .+-]{0,23}$" } + }, + "addedAt": { + "description": "Date of the first merge. Never changes.", + "$ref": "#/$defs/date" + }, + "lastVerifiedAt": { + "description": "Date a maintainer last confirmed it works with the current TablePro release.", + "$ref": "#/$defs/date" + }, + "compliance": { + "description": "Pre-policy entries only. Set by a maintainer.", + "type": "object", + "additionalProperties": false, + "required": ["grace"], + "properties": { "grace": { "$ref": "#/$defs/grace" } } + } + }, + "allOf": [ + { + "if": { "properties": { "source": { "properties": { "type": { "const": "github" } } } } }, + "then": { "required": ["license"] }, + "else": { "not": { "required": ["license"] } } + }, + { + "if": { "properties": { "install": { "properties": { "type": { "const": "built-in" } } } } }, + "then": { "properties": { "tier": { "const": "official" } } } + }, + { + "if": { "properties": { "disclosures": { "properties": { "account": { "const": true } } } } }, + "then": { "properties": { "links": { "required": ["privacy"] } } } + }, + { + "if": { + "properties": { "disclosures": { "properties": { "lowersSafeMode": { "const": true } } } } + }, + "then": { + "properties": { + "categories": { "contains": { "const": "local-development" } } + } + } + } + ], + "$defs": { + "plainLine": { + "type": "string", + "pattern": "^(?=\\S)(?!.*\\s$)[^\\u0000-\\u001F\\u007F<>\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2069\\uFEFF]+$" + }, + "plainText": { + "type": "string", + "pattern": "^(?=\\S)(?![\\s\\S]*\\s$)(?![\\s\\S]*\\n{3})[^\\u0000-\\u0009\\u000B-\\u001F\\u007F<>\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2069\\uFEFF]+$" + }, + "httpsUrl": { + "type": "string", + "format": "uri", + "maxLength": 300, + "pattern": "^https://(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\\.)+[A-Za-z]{2,63}(?:[/?#][^\\s<>\"'\\\\]*)?$" + }, + "githubLogin": { + "type": "string", + "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$" + }, + "githubRepo": { + "type": "string", + "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}/(?!\\.{1,2}$)[A-Za-z0-9._-]{1,100}$" + }, + "date": { + "type": "string", + "format": "date", + "pattern": "^20[2-9][0-9]-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])$" + }, + "version": { + "type": "string", + "pattern": "^(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)(?:\\.(?:0|[1-9][0-9]*))?$" + }, + "statusActive": { + "type": "object", + "additionalProperties": false, + "required": ["state"], + "properties": { "state": { "const": "active" } } + }, + "statusArchived": { + "description": "Archived entries leave the hub and search. The detail page stays with a banner.", + "type": "object", + "additionalProperties": false, + "required": ["state", "since", "reason"], + "properties": { + "state": { "const": "archived" }, + "since": { "$ref": "#/$defs/date" }, + "reason": { + "enum": [ + "unmaintained", + "broken", + "source-archived", + "superseded", + "author-request", + "policy" + ] + }, + "replacement": { + "description": "Slug of the entry that replaces this one.", + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "note": { "allOf": [{ "$ref": "#/$defs/plainLine" }], "maxLength": 200 }, + "link": { "$ref": "#/$defs/httpsUrl" } + }, + "if": { "properties": { "reason": { "const": "superseded" } } }, + "then": { "required": ["replacement"] } + }, + "sourceGitHub": { + "type": "object", + "additionalProperties": false, + "required": ["type", "repo", "repoId"], + "properties": { + "type": { "const": "github" }, + "repo": { "$ref": "#/$defs/githubRepo" }, + "repoId": { + "description": "Numeric repository id. Survives renames and catches a repo re-created under the same name.", + "type": "integer", + "minimum": 1 + }, + "path": { + "description": "Folder inside a monorepo.", + "type": "string", + "maxLength": 200, + "pattern": "^(?!.*(?:^|/)\\.{1,2}(?:/|$))[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*$" + } + } + }, + "sourceClosed": { + "description": "Source is not public, or most of it is not. The directory shows a Closed source label.", + "type": "object", + "additionalProperties": false, + "required": ["type"], + "properties": { + "type": { "const": "closed" }, + "note": { "allOf": [{ "$ref": "#/$defs/plainLine" }], "maxLength": 200 } + } + }, + "installBuiltIn": { + "description": "Part of TablePro. The button opens the TablePro docs page.", + "type": "object", + "additionalProperties": false, + "required": ["type", "docs"], + "properties": { + "type": { "const": "built-in" }, + "docs": { + "allOf": [{ "$ref": "#/$defs/httpsUrl" }], + "pattern": "^https://docs\\.tablepro\\.app/" + } + } + }, + "installShipsWithHost": { + "description": "Included in the host product. The button opens the host's docs for it.", + "type": "object", + "additionalProperties": false, + "required": ["type", "url"], + "properties": { + "type": { "const": "ships-with-host" }, + "url": { "$ref": "#/$defs/httpsUrl" } + } + }, + "installRaycastStore": { + "type": "object", + "additionalProperties": false, + "required": ["type", "url"], + "properties": { + "type": { "const": "raycast-store" }, + "url": { + "type": "string", + "pattern": "^https://www\\.raycast\\.com/[A-Za-z0-9_-]{1,64}/[a-z0-9-]{1,64}$" + } + } + }, + "installVSCodeMarketplace": { + "type": "object", + "additionalProperties": false, + "required": ["type", "id"], + "properties": { + "type": { "const": "vscode-marketplace" }, + "id": { + "description": "publisher.name, as in the Marketplace itemName.", + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,62}\\.[A-Za-z0-9][A-Za-z0-9-]{0,62}$" + }, + "openVsx": { + "description": "Also published on Open VSX under the same id.", + "type": "boolean" + } + } + }, + "installGitHubRelease": { + "type": "object", + "additionalProperties": false, + "required": ["type", "url"], + "properties": { + "type": { "const": "github-release" }, + "url": { + "type": "string", + "pattern": "^https://github\\.com/[A-Za-z0-9-]{1,39}/[A-Za-z0-9._-]{1,100}/releases/latest$" + } + } + }, + "installHomebrew": { + "type": "object", + "additionalProperties": false, + "required": ["type", "kind", "token"], + "properties": { + "type": { "const": "homebrew" }, + "kind": { "enum": ["formula", "cask"] }, + "token": { + "description": "Name in homebrew/core or homebrew/cask. Third-party taps are not accepted.", + "type": "string", + "pattern": "^[a-z0-9][a-z0-9@+._-]{0,99}$" + } + } + }, + "installAppStore": { + "type": "object", + "additionalProperties": false, + "required": ["type", "url"], + "properties": { + "type": { "const": "app-store" }, + "url": { + "type": "string", + "pattern": "^https://apps\\.apple\\.com/(?:[a-z]{2}/)?app/(?:[a-z0-9-]+/)?id[0-9]{5,12}$" + } + } + }, + "installAlfredGallery": { + "type": "object", + "additionalProperties": false, + "required": ["type", "url"], + "properties": { + "type": { "const": "alfred-gallery" }, + "url": { + "type": "string", + "pattern": "^https://alfred\\.app/workflows/[a-z0-9-]{1,64}/[a-z0-9-]{1,64}/$" + } + } + }, + "installPage": { + "description": "Fallback for a download page with no typed store. Needs maintainer approval.", + "type": "object", + "additionalProperties": false, + "required": ["type", "url"], + "properties": { + "type": { "const": "page" }, + "url": { "$ref": "#/$defs/httpsUrl" } + } + }, + "disclosures": { + "description": "Shown under Uses on the detail page.", + "type": "object", + "additionalProperties": false, + "required": ["reads", "writes", "lowersSafeMode", "network", "account", "payment"], + "properties": { + "reads": { + "description": "What it reads from TablePro: the connection list, schema, or row data.", + "type": "array", + "uniqueItems": true, + "items": { "enum": ["connections", "schema", "data"] } + }, + "writes": { + "type": "array", + "uniqueItems": true, + "items": { "enum": ["data", "schema"] } + }, + "accessNote": { + "description": "What limits the access, for example the token scope chosen when pairing.", + "allOf": [{ "$ref": "#/$defs/plainLine" }], + "maxLength": 200 + }, + "lowersSafeMode": { + "description": "Opens connections with a Safe Mode level below the user's default, for example safeModeLevel=0.", + "type": "boolean" + }, + "safeModeNote": { "allOf": [{ "$ref": "#/$defs/plainLine" }], "maxLength": 200 }, + "network": { + "description": "Where the integration itself sends data, besides TablePro and the user's databases. local means this Mac only.", + "enum": ["none", "local", "internet"] + }, + "networkNote": { "allOf": [{ "$ref": "#/$defs/plainLine" }], "maxLength": 200 }, + "account": { + "description": "Needs an account with a service other than TablePro and the host.", + "type": "boolean" + }, + "payment": { + "description": "What the integration itself costs. The host's own pricing goes in host.note.", + "enum": ["free", "optional", "paid"] + }, + "paymentNote": { "allOf": [{ "$ref": "#/$defs/plainLine" }], "maxLength": 200 } + }, + "allOf": [ + { + "if": { "properties": { "lowersSafeMode": { "const": true } } }, + "then": { "required": ["safeModeNote"] } + }, + { + "if": { "properties": { "network": { "const": "internet" } } }, + "then": { "required": ["networkNote"] } + }, + { + "if": { "properties": { "payment": { "enum": ["optional", "paid"] } } }, + "then": { "required": ["paymentNote"] } + } + ] + }, + "grace": { + "description": "Time a pre-policy entry has to fix the listed problems. At most 60 days.", + "type": "object", + "additionalProperties": false, + "required": ["startedAt", "endsAt", "items", "tracking"], + "properties": { + "startedAt": { "$ref": "#/$defs/date" }, + "endsAt": { "$ref": "#/$defs/date" }, + "items": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": ["brand-name", "brand-icon", "license", "private-files", "disclosure"] + } + }, + "tracking": { + "description": "Issue in the integration's own tracker where the author was contacted.", + "type": "string", + "pattern": "^https://github\\.com/[A-Za-z0-9-]{1,39}/[A-Za-z0-9._-]{1,100}/issues/[1-9][0-9]{0,6}$" + } + } + } + } +} diff --git a/scripts/registry.py b/scripts/registry.py new file mode 100644 index 0000000..e9529d5 --- /dev/null +++ b/scripts/registry.py @@ -0,0 +1,433 @@ +from __future__ import annotations + +import datetime as dt +import hashlib +import json +import os +import re +import struct +import subprocess +import unicodedata +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Iterable, Optional + +from jsonschema import Draft202012Validator +from referencing import Registry, Resource + +SCHEMA_BASE = "https://integrations.tablepro.app/schema/v1/" +INTEGRATION_SCHEMA = SCHEMA_BASE + "integration.schema.json" +CURATION_SCHEMA = SCHEMA_BASE + "curation.schema.json" +INDEX_SCHEMA = SCHEMA_BASE + "index.schema.json" + +CURATION_FILES = { + "official-publishers.json": "officialPublishers", + "partners.json": "partners", + "reserved.json": "reserved", + "removed.json": "removed", + "protected-marks.json": "protectedMarks", + "scan-allowlist.json": "scanAllowlist", +} + +ENTRY_FILE = re.compile(r"^(?:integration\.json|icon\.png|screenshot-[1-4]\.png)$") +SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + +ICON_SIZE = (512, 512) +ICON_MAX_BYTES = 512 * 1024 +SHOT_SIZE = (1600, 1000) +SHOT_MAX_BYTES = 1024 * 1024 +GRACE_MAX_DAYS = 60 +SIMILAR_NAME_DISTANCE = 2 + +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + +# Characters that render as nothing or reorder text: zero-width, bidi controls, BOM. +INVISIBLE = re.compile("[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]") + +# Hosts that hide the real destination. +SHORTENER_HOSTS = {"bit.ly", "t.co", "tinyurl.com", "goo.gl", "ow.ly", "is.gd", "buff.ly", "rebrand.ly", "cutt.ly"} + +SCAN_RULES = [ + ("app-support-dir", re.compile(r"Application Support/TablePro\b")), + ("handshake-file", re.compile(r"mcp-handshake\.json")), + ("audit-db", re.compile(r"mcp-audit\.db")), + ("prefs-plist", re.compile(r"com\.TablePro\.plist")), + ("prefs-keys", re.compile(r"com\.TablePro\.(?:connections|groups|tags)\b")), + ("defaults-domain", re.compile(r"defaults\s+(?:read|export|write)\s+com\.TablePro\b")), + ("keychain", re.compile(r"(?:find-(?:generic|internet)-password|kSecAttrService)[^\n]{0,120}com\.TablePro\b")), +] +SCAN_SKIP_DIRS = {".git", "node_modules"} +SCAN_MAX_FILE_BYTES = 2 * 1024 * 1024 + + +@dataclass +class Finding: + level: str + file: str + message: str + line: int = 0 + + +@dataclass +class Report: + findings: list = field(default_factory=list) + + def error(self, file: str, message: str, line: int = 0) -> None: + self.findings.append(Finding("error", file, message, line)) + + def warning(self, file: str, message: str, line: int = 0) -> None: + self.findings.append(Finding("warning", file, message, line)) + + @property + def errors(self) -> list: + return [f for f in self.findings if f.level == "error"] + + @property + def warnings(self) -> list: + return [f for f in self.findings if f.level == "warning"] + + +@dataclass +class Entry: + slug: str + directory: Path + manifest: Optional[dict] + text: str + load_error: Optional[str] = None + + @property + def manifest_path(self) -> str: + return f"integrations/{self.slug}/integration.json" + + +def read_json(path: Path) -> tuple: + try: + text = path.read_text(encoding="utf-8") + except FileNotFoundError: + return None, "", "file is missing" + except UnicodeDecodeError: + return None, "", "file is not UTF-8" + try: + return json.loads(text), text, None + except json.JSONDecodeError as exc: + return None, text, f"invalid JSON at line {exc.lineno}: {exc.msg}" + + +def schema_registry(root: Path) -> Registry: + resources = [] + for name in ("integration.schema.json", "curation.schema.json", "index.schema.json"): + data = json.loads((root / "schema" / name).read_text(encoding="utf-8")) + resources.append((data["$id"], Resource.from_contents(data))) + return Registry().with_resources(resources) + + +def validator_for(root: Path, ref: str) -> Draft202012Validator: + registry = schema_registry(root) + return Draft202012Validator( + {"$ref": ref}, + registry=registry, + format_checker=Draft202012Validator.FORMAT_CHECKER, + ) + + +def check_schema_files(root: Path, report: Report) -> None: + for path in sorted((root / "schema").glob("*.json")): + data, _, error = read_json(path) + rel = f"schema/{path.name}" + if error: + report.error(rel, error) + continue + try: + Draft202012Validator.check_schema(data) + except Exception as exc: + report.error(rel, f"not a valid draft 2020-12 schema: {getattr(exc, 'message', exc)}") + + +def line_of(text: str, path: Iterable) -> int: + keys = [p for p in path if isinstance(p, str)] + if not keys: + return 1 + match = re.search(r'"' + re.escape(keys[-1]) + r'"\s*:', text) + return text.count("\n", 0, match.start()) + 1 if match else 1 + + +def schema_errors(validator: Draft202012Validator, data: Any, text: str, file: str, report: Report) -> int: + count = 0 + for error in sorted(validator.iter_errors(data), key=lambda e: list(e.absolute_path)): + if error.validator in ("allOf", "if") and error.context: + continue + where = "/".join(str(p) for p in error.absolute_path) or "(root)" + report.error(file, f"{where}: {error.message}", line_of(text, error.absolute_path)) + count += 1 + return count + + +def load_curation(root: Path, report: Report) -> dict: + curation = {} + for name, definition in CURATION_FILES.items(): + path = root / "curation" / name + rel = f"curation/{name}" + data, text, error = read_json(path) + if error: + report.error(rel, error) + continue + validator = validator_for(root, f"{CURATION_SCHEMA}#/$defs/{definition}") + schema_errors(validator, data, text, rel, report) + curation[name] = data + return curation + + +def load_entries(root: Path) -> dict: + entries = {} + base = root / "integrations" + if not base.is_dir(): + return entries + for directory in sorted(p for p in base.iterdir() if p.is_dir()): + data, text, error = read_json(directory / "integration.json") + entries[directory.name] = Entry(directory.name, directory, data, text, error) + return entries + + +def name_tokens(value: str) -> list: + value = unicodedata.normalize("NFKC", value) + value = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", value) + value = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", value) + return [t for t in re.split(r"[^0-9A-Za-z]+", value.casefold()) if t] + + +def contains_fragment(value: str, fragment: str) -> bool: + tokens = name_tokens(value) + for size in (1, 2, 3): + for start in range(0, len(tokens) - size + 1): + if "".join(tokens[start:start + size]) == fragment: + return True + return False + + +def normalized_name(value: str) -> str: + return re.sub(r"[^0-9a-z]", "", unicodedata.normalize("NFKC", value).casefold()) + + +def levenshtein(a: str, b: str) -> int: + if a == b: + return 0 + previous = list(range(len(b) + 1)) + for i, ca in enumerate(a, 1): + current = [i] + for j, cb in enumerate(b, 1): + current.append(min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (ca != cb))) + previous = current + return previous[-1] + + +def unsafe_characters(value: str) -> list: + found = [] + for char in value: + category = unicodedata.category(char) + if INVISIBLE.match(char) or category in ("Cf", "Co", "Cs", "Cn"): + found.append(f"U+{ord(char):04X}") + elif category == "So": + found.append(f"U+{ord(char):04X} (symbol or emoji)") + return found + + +def parse_date(value: str) -> Optional[dt.date]: + try: + return dt.date.fromisoformat(value) + except (TypeError, ValueError): + return None + + +def png_chunks(data: bytes) -> list: + chunks = [] + offset = len(PNG_SIGNATURE) + while offset + 8 <= len(data): + (length,) = struct.unpack(">I", data[offset:offset + 4]) + kind = data[offset + 4:offset + 8].decode("latin-1") + chunks.append(kind) + offset += 12 + length + if kind == "IEND": + break + return chunks + + +def check_png(path: Path, size: tuple, max_bytes: int) -> list: + problems = [] + data = path.read_bytes() + if len(data) > max_bytes: + problems.append(f"is {len(data) // 1024} KB, over the {max_bytes // 1024} KB limit") + if not data.startswith(PNG_SIGNATURE): + return problems + ["is not a PNG file"] + if "acTL" in png_chunks(data): + problems.append("is an animated PNG") + from PIL import Image + + try: + with Image.open(path) as image: + image.verify() + with Image.open(path) as image: + image.load() + if image.format != "PNG": + problems.append(f"decodes as {image.format}, not PNG") + if image.size != size: + problems.append(f"is {image.size[0]}x{image.size[1]}, expected {size[0]}x{size[1]}") + if image.mode not in ("RGB", "RGBA", "P", "LA", "L"): + problems.append(f"uses color mode {image.mode}") + except Exception as exc: + problems.append(f"does not decode: {exc}") + return problems + + +def mark_hash(path: Path) -> str: + from PIL import Image + + with Image.open(path) as source: + image = source.convert("RGBA") + alpha = image.getchannel("A").point(lambda v: 255 if v > 200 else 0) + box = alpha.getbbox() or (0, 0) + image.size + image = image.crop(box) + flat = Image.new("RGBA", image.size, (255, 255, 255, 255)) + flat.alpha_composite(image) + pixels = flat.convert("L").resize((17, 16), Image.Resampling.LANCZOS).tobytes() + bits = [pixels[r * 17 + c] > pixels[r * 17 + c + 1] for r in range(16) for c in range(16)] + value = 0 + for bit in bits: + value = (value << 1) | int(bit) + return f"{value:064x}" + + +def hash_distance(a: str, b: str) -> int: + return bin(int(a, 16) ^ int(b, 16)).count("1") + + +def collect_urls(value: Any) -> list: + urls = [] + if isinstance(value, dict): + for item in value.values(): + urls.extend(collect_urls(item)) + elif isinstance(value, list): + for item in value: + urls.extend(collect_urls(item)) + elif isinstance(value, str) and value.startswith(("http://", "https://")): + urls.append(value) + return urls + + +def install_url(install: dict) -> str: + kind = install.get("type") + if kind == "built-in": + return install["docs"] + if kind == "vscode-marketplace": + return "https://marketplace.visualstudio.com/items?itemName=" + install["id"] + if kind == "homebrew": + return f"https://formulae.brew.sh/{install['kind']}/{install['token']}" + return install["url"] + + +def source_url(source: dict) -> Optional[str]: + if source.get("type") != "github": + return None + url = f"https://github.com/{source['repo']}" + if source.get("path"): + url += "/tree/HEAD/" + source["path"] + return url + + +def scan_tree(root: Path, rules: Optional[list] = None) -> list: + hits = [] + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in SCAN_SKIP_DIRS] + for filename in filenames: + path = Path(dirpath) / filename + try: + if path.is_symlink() or path.stat().st_size > SCAN_MAX_FILE_BYTES: + continue + data = path.read_bytes() + except OSError: + continue + if b"\x00" in data[:8192]: + continue + text = data.decode("utf-8", errors="replace") + for rule, pattern in rules or SCAN_RULES: + match = pattern.search(text) + if match: + line = text.count("\n", 0, match.start()) + 1 + hits.append((str(path.relative_to(root)), line, rule, match.group(0))) + return hits + + +def fetch_source(repo: str, path: Optional[str], destination: Path) -> Path: + url = f"https://github.com/{repo}.git" + env = dict(os.environ, GIT_TERMINAL_PROMPT="0", GIT_LFS_SKIP_SMUDGE="1") + base = ["git", "-c", "core.symlinks=false", "-c", "protocol.file.allow=never"] + clone = base + ["clone", "--quiet", "--depth", "1", "--no-tags", "--filter=blob:none"] + if path: + clone += ["--sparse"] + subprocess.run(clone + [url, str(destination)], check=True, env=env, timeout=600) + if path: + subprocess.run(base + ["-C", str(destination), "sparse-checkout", "set", "--no-cone", "/" + path], + check=True, env=env, timeout=600) + return destination / path + return destination + + +class GitHub: + def __init__(self, token: Optional[str] = None, opener: Optional[Callable] = None): + self.token = token if token is not None else os.environ.get("GITHUB_TOKEN", "") + self.opener = opener or urllib.request.urlopen + + def get(self, path: str) -> tuple: + request = urllib.request.Request("https://api.github.com" + path) + request.add_header("Accept", "application/vnd.github+json") + request.add_header("X-GitHub-Api-Version", "2022-11-28") + request.add_header("User-Agent", "tablepro-integrations-validator") + if self.token: + request.add_header("Authorization", "Bearer " + self.token) + try: + with self.opener(request, timeout=30) as response: + body = response.read() + return response.status, json.loads(body) if body else None + except urllib.error.HTTPError as exc: + exc.close() + return exc.code, None + + def repository(self, repo_id: int) -> tuple: + return self.get(f"/repositories/{repo_id}") + + def license(self, repo: str) -> tuple: + return self.get(f"/repos/{repo}/license") + + def user(self, login: str) -> tuple: + return self.get(f"/users/{urllib.parse.quote(login)}") + + def contents(self, repo: str, path: str) -> tuple: + return self.get(f"/repos/{repo}/contents/{urllib.parse.quote(path)}") + + def latest_release(self, repo: str) -> tuple: + return self.get(f"/repos/{repo}/releases/latest") + + def contributors(self, repo: str) -> tuple: + return self.get(f"/repos/{repo}/contributors?per_page=100") + + def public_member(self, org: str, login: str) -> bool: + status, _ = self.get(f"/orgs/{org}/public_members/{urllib.parse.quote(login)}") + return status == 204 + + def issue(self, repo: str, number: int) -> tuple: + return self.get(f"/repos/{repo}/issues/{number}") + + +def http_status(url: str, method: str = "GET", opener: Optional[Callable] = None) -> int: + request = urllib.request.Request(url, method=method) + request.add_header("User-Agent", "Mozilla/5.0 (compatible; tablepro-integrations-validator)") + try: + with (opener or urllib.request.urlopen)(request, timeout=30) as response: + return response.status + except urllib.error.HTTPError as exc: + exc.close() + return exc.code + except (urllib.error.URLError, TimeoutError, OSError): + return 0 diff --git a/scripts/tests/fixtures/integrations/claude-code/icon.png b/scripts/tests/fixtures/integrations/claude-code/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..26c05ed073eace630cbe88fc74f3357329af566c GIT binary patch literal 2122 zcmcgtZERCj7=F&Zw;vmJy(8ErrL-Nyh#R<(7#W6bbqrzr*s3nd#!N28C8EemCb7e$ z9wxfUhm%B>hL|va7>z&JOf(FI?aGEm(*%t(`=FWK5=vO+vLTcWCG)+v`{;~pK%)0Y zd(Znm=Xt-*ea?Hnk}Z~0M=AhI@z$be0D)cwn2q#zJBepRLck8T=(Th4%dqkN2FTk(b7I<>ndMI)Z)S z{SulsdeUSxM=^N6>ymxx9}o4#>GM~e!*&Hn!_WJ<1a3I z=@*6kyvudqvQ-&9!&EEyB^~IIh8{#y0iRqAe;W(`5HN7jBDY8pD3fC7q@WHOROiv> zk+ufo+f)m`Y~_=gaF!rk&Z0g+8AwHax*FMx+=GZ^6I{%xPe&^EA*q$LPC@J&_;ZnS#Q=Cv zmVwwg-S3dp2>BUe9m@e3!&bcMC6C~}!4$U{@Zz&Pj2IYA8OKfhwuhXoc@(V=OBxd` zDRS4 z{rv3KMd>drq)DH z{*`fImkbx1?&y8({zv)?2cxk~2l3F`ea&Hxf)mBuY!(fCkMN{suxPqoW#&&bn)3B6 zuDXkM|Li$T1}-?+=?tqLn~J;pKOMaOh2Sgf*nN~fzI&-@(K?gznb)1^k8_^wj)D2< zV4uUCh4|sQ^pbY+iDxYsDupg7bA#-%KaSSYc{mpkZ%7QvSB`)M{PrN}043wWki&%G z-sL0{B#I|QwR&pi18aD71x8Zz^RaJMd9I<=u5)+05I>-C@?lYR>2>+uLTE}*GoWc2 zNE6?lk-1Ds{fK1Fq|4w|cwwoxYyvW9`w|_O4Ov7}yXLSJ2v|o~m>FGkx2=LK@pl^GT}GFlfW6p$jSBql bpRpa;NBV#Cz0_{X*Mb#qE-5-ysMP%p{`zcm literal 0 HcmV?d00001 diff --git a/scripts/tests/fixtures/integrations/claude-code/integration.json b/scripts/tests/fixtures/integrations/claude-code/integration.json new file mode 100644 index 0000000..686aad8 --- /dev/null +++ b/scripts/tests/fixtures/integrations/claude-code/integration.json @@ -0,0 +1,72 @@ +{ + "$schema": "../../schema/integration.schema.json", + "schemaVersion": 1, + "slug": "claude-code", + "name": "Claude Code", + "summary": "Connect Claude Code to your databases with one claude mcp add command.", + "description": "Run claude mcp add with the path to the tablepro-mcp bridge inside TablePro, and Claude Code gets TablePro's MCP tools: list connections, read schemas, run queries and write data, within what each connection allows external clients to do.\n\nThe Connect a Client sheet in TablePro's settings shows the exact command for your install.", + "tier": "official", + "status": { + "state": "active" + }, + "publisher": { + "name": "TablePro", + "github": "TableProApp", + "githubId": 260450047, + "url": "https://tablepro.app" + }, + "source": { + "type": "github", + "repo": "TableProApp/TablePro", + "repoId": 1117891044, + "path": "TablePro/Core/MCP" + }, + "license": "AGPL-3.0-only", + "install": { + "type": "built-in", + "docs": "https://docs.tablepro.app/integrations/mcp-clients#claude-code" + }, + "host": { + "name": "Claude Code", + "url": "https://claude.com/product/claude-code" + }, + "platforms": [ + "macos" + ], + "categories": [ + "ai-clients" + ], + "surfaces": [ + "mcp" + ], + "disclosures": { + "reads": [ + "connections", + "schema", + "data" + ], + "writes": [ + "data" + ], + "accessNote": "The bundled bridge uses a Read & write token. Each connection's External Clients level and Safe Mode still apply.", + "lowersSafeMode": false, + "network": "internet", + "networkNote": "Claude Code sends query results to Anthropic to answer you.", + "account": false, + "payment": "free" + }, + "links": { + "homepage": "https://docs.tablepro.app/integrations/mcp-clients#claude-code", + "docs": "https://docs.tablepro.app/developers/mcp-tools", + "issues": "https://github.com/TableProApp/TablePro/issues" + }, + "icon": "icon.png", + "keywords": [ + "mcp", + "claude", + "anthropic", + "terminal" + ], + "addedAt": "2026-10-10", + "lastVerifiedAt": "2026-10-10" +} diff --git a/scripts/tests/fixtures/integrations/claude-desktop/icon.png b/scripts/tests/fixtures/integrations/claude-desktop/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6e4ef6b844ab62f370d066c743ba22bde81f9ccd GIT binary patch literal 2123 zcmcgtYfKzf6#nkaYl_l$g~%R2Ve;(4=I|MrB3n zt)X2^n^a>{HxVPz7#kmjL=h4xEGZ97Xs{8{NKMB!-D;o~Qj3KE@ysBy#RAnt=g02c z^PTgZbI-Z+-B-)YN|Kdy1weA?w&I5YfnEj3>*(*d+Tj^MPAx6o5~%&*(rC}?A8NN9 zIKAQEA=UGx=;isUr!gt7=80W<_SKh3m#c?78OJ(k- znT%&U8%2p%J5@!3yMU`M?3}@q8uVYpM#gxbQ4hg3pc2 z5l5`a4SxXfHUy*awc7v{fx8fIrzG$V*!WhFhc{7=S+kq@zN59kr#Hcqg26DOdEpV6 zhjT1^9rOK8IQaBs8mPo)#8{>a;3%)h_jVeU3(q)Fw}qSq?E=#aWkAER2Qi*xG0~#8 zEd^8i$wA_0=UHo>WR?T22(hb$n2KbW0Yun1mVskW9qyp8JabL{T4y^`yX@qRMKVgG zcC|8oV&nBjvk++2%-9arT6u;*!7?q2wQQWwg(4DZmq8jiEl}K)`x4Vi?j7%rc$*t) zDmFLd+&Ogm;@B12;x?^*{7K+KCZhqEEm|v$pI|tO9AMl@`wRXEd6T zQy)c)E4HO`$ccBM4H~_zQ1ud|-EpdWE;9ABzy4z!`8wyZ^YroklRd4OfKwGW8q1Ar zD<(ERsrF0LaQCCDOLHqG9<;lmm&Ejse^Lmv));xn&yS1<3 z4TyImD1$T&P%*47%ACS{7j66`vt%$8gU>BckPMP6rep9~W=ufe>i|gHwqbA&Gki?1 za3G=}v>X1%LCPD;5`AxyK4(NNTKUJ^W3GkG&m^ z+Ae()d{KeBI|C6nLILY~_vOGP5F0d^!UfCEfnMQMgD-2aFqf9^6tG##iq~%l{&M|N`_3y~ zxum_q_13~)bK{Ep+TqX2pFUao>6+%v_D`BDS0&>7506?C4roiOb~V`tLk;C8au{FY zjEE9{-Kp3mWEOBmM)fFO^+Wwlj4;N(aVnC8j|%t#0&a7efFZFE24G{vSf@xbeR5en zzOF_4!|MI38wo44H#xLEt1JxJ@k6c_dJMj9^uKmm(Dqp(8--fLZnJm=OAHF?fp3?= zn#qGp5*FfXp$s^dqW^Q$KgGOtDZIm`4m^SELg+3EXRRGJgdN)2=iu9c{x6{|61~wB zI9z=73e>kDzMUn!Os!1?{AephypC8<52PY&fxd~3Sn#h9)MK=kpl^mR8?8FcO~z3f z`W6#U6Vw610lg=UW{6=c&sPXxhei=|(xeo0<>1d|+LHM9qP9l{I4T=)@ljKW7Lo0b zBbmw!_L;v$wa#Xg1Gpvr`(7lSc?N+55ogyDg2%S%}!?~t`8guX!gt3 z7lq!~->VOgG`sIpf(_qMoUKA5g5HI-0%T2S6Nywwe7X zBsWW|(;!Z9|LQ?bQ@?N7^Xk%2nqQs7nw(o~-}%LfvtL~*8XTdEJ@bGbUOU|m_1!BM z#)_(Tja0Mht!JC&G~B6Ca7sJBKISsbyXPJJxFM(DB_v%F_t!jxsej4Pkt}pZK`#V9 z)iXJeh-U}X=!5~7n?w0PGJQO=dIE!4z^x8)8pR-Xu5uuGJg*zzZmhEMp0N}YG0fuG zNr$=Sz^(O04*ylAdQ9!Md?ZCv^XI#EH3sjU6kY^=%0t2IkcGu}n;}&;zS@Iq2jYz^ z@g592q||LCh&QmrxpBL+LVv|fQ6d<28h!-&%kXVL>v=)z6|_($91HoW{iwf)_%?|b zSyjhkWEVi+h=eS(ek~xJuAR<+??c1QwO+|v6ODIlYCF2Jf@yt?V&WWmza3#0^k=rP z&Sebm^cdTfV(SOw@op{j1vHR(){3;0xQy7%6co~n#swSEYhcDP zz5uZs1`xZMjtJ9!3P7>o`CP^oEDuRIj98KYQG4G?4gTj3zcVbfe-Zb-xbk3^k*utw Ky!cq5+WZfM>0nm? literal 0 HcmV?d00001 diff --git a/scripts/tests/fixtures/integrations/command-line/integration.json b/scripts/tests/fixtures/integrations/command-line/integration.json new file mode 100644 index 0000000..c83dccf --- /dev/null +++ b/scripts/tests/fixtures/integrations/command-line/integration.json @@ -0,0 +1,64 @@ +{ + "$schema": "../../schema/integration.schema.json", + "schemaVersion": 1, + "slug": "command-line", + "name": "Command line", + "summary": "Open database URLs and SQL files in TablePro from Terminal with the tablepro command.", + "description": "Install the tablepro command from TablePro's settings, then pass it a connection URL or a file: tablepro \"postgresql://user@127.0.0.1:5432/app\" opens that database, and tablepro schema.sql opens the file in a query tab.\n\nTablePro reuses a saved connection that matches the URL. Otherwise the session is temporary and nothing is written to disk. Scripts that cannot install the command can run open -b com.TablePro with the same URL.", + "tier": "official", + "status": { + "state": "active" + }, + "publisher": { + "name": "TablePro", + "github": "TableProApp", + "githubId": 260450047, + "url": "https://tablepro.app" + }, + "source": { + "type": "github", + "repo": "TableProApp/TablePro", + "repoId": 1117891044, + "path": "TablePro/Core/Services/Infrastructure/CommandLineToolInstaller.swift" + }, + "license": "AGPL-3.0-only", + "install": { + "type": "built-in", + "docs": "https://docs.tablepro.app/integrations/terminal" + }, + "minTableProVersion": { + "macos": "0.57.0" + }, + "platforms": [ + "macos" + ], + "categories": [ + "command-line" + ], + "surfaces": [ + "cli", + "database-url" + ], + "disclosures": { + "reads": [], + "writes": [], + "lowersSafeMode": false, + "network": "none", + "account": false, + "payment": "free" + }, + "links": { + "homepage": "https://docs.tablepro.app/integrations/terminal", + "docs": "https://docs.tablepro.app/connections/urls", + "issues": "https://github.com/TableProApp/TablePro/issues" + }, + "icon": "icon.png", + "keywords": [ + "terminal", + "shell", + "cli", + "database url" + ], + "addedAt": "2026-10-10", + "lastVerifiedAt": "2026-10-10" +} diff --git a/scripts/tests/fixtures/integrations/cursor/icon.png b/scripts/tests/fixtures/integrations/cursor/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..7be7c085c9e84383339180b6bd96751b3a4e34d1 GIT binary patch literal 2112 zcmcgtZERCj7=F&ZcWv#m_Aa`0Zgabc1S8;@C|MCU%0BECOg9aaMS)#J6Ag18S;)4~ z;9-ASBQoPxmY7)N4+|1fM{-mT{-T%4&iDPA-t!~?F|FqR|MIz4s@TfIGpSHAWN2@&%X!0M;VSI@* zBue}>r(&0oUdR<0)njm+~P6;!(tH(z{ZHNR*_`-yiO4I?E8PCl=Jb$p~7YZ=xd>yekCtFs&u%o8ieuhfZ^oaZrZ7 z#l%wtb%=04?@gf@V#LY|6hhd(F+`j+DFx?q@JAbMN&Gue+a&`WmCd;Lps7TQ(Dp|V zPi6-DWnqT^Okzi8*qAQIVDDEyn(GE zc9zcEGztO#UeLl2)RV`EZPsRHx{GUunc5RY%nfnUpOBbW_+15>oz70&;5*>c?3b@C z3cRtWPahd=%eqGiHnh(pE&s^h&R1t`TQk(#bGgtH&YSM{KE-;eqq+ON55$qM&F)7b zxj9;$0db1^R}XTA`aR2DP?v_%!s;Z})Z8Mw&n=Ff`SMb6Vw5iS>;wAe+Ua(v?+#oT zFRt1#TFt7ro@t%eaHmJXDec1gn9nrtoOjD7O*w@xBJP^JzvdxK|4W7sWTHC^dLekJ zo~eO^@@k=mCk?>d94-LjsS}yiqex@|H@nDb6oc5k(t-Gq{2qX3VWpM#jwhLjVHVF! zIm}fDZmu(Oc&{)u&(!WLKwLC6f4pN?<8a?j;>F;{^C+0@v#{t+Go;GKSLGqwfoL;} zy$8b%DRpNlqD?GzcET>L&|fxFlnREO1|NX_5u2}ip&bsl`Of@%HDV(ct=za2pr^ryD4 z?qv+`^cvfhV#f#M@mX5nvB~XRrbt-6t3QqB3TPtptP^P|aT$>tNhqQhjSDuS*T9Tp zJOh#I1`xTCiV)L&@IkR)eJz&*_}uB|jb!De K{*uE*YTMs*-Cz#@ literal 0 HcmV?d00001 diff --git a/scripts/tests/fixtures/integrations/cursor/integration.json b/scripts/tests/fixtures/integrations/cursor/integration.json new file mode 100644 index 0000000..bea2056 --- /dev/null +++ b/scripts/tests/fixtures/integrations/cursor/integration.json @@ -0,0 +1,72 @@ +{ + "$schema": "../../schema/integration.schema.json", + "schemaVersion": 1, + "slug": "cursor", + "name": "Cursor", + "summary": "Let Cursor's agent read and query your databases through the MCP server in TablePro.", + "description": "Add TablePro to Cursor's MCP config with one entry that points at the tablepro-mcp bridge inside the app. Cursor's agent can then list connections, read schemas, run queries and write data, within what each connection allows external clients to do.\n\nUse the global config in your home folder, or a project config to scope it to one project. The Connect a Client sheet in TablePro's settings shows the steps.", + "tier": "official", + "status": { + "state": "active" + }, + "publisher": { + "name": "TablePro", + "github": "TableProApp", + "githubId": 260450047, + "url": "https://tablepro.app" + }, + "source": { + "type": "github", + "repo": "TableProApp/TablePro", + "repoId": 1117891044, + "path": "TablePro/Core/MCP" + }, + "license": "AGPL-3.0-only", + "install": { + "type": "built-in", + "docs": "https://docs.tablepro.app/integrations/mcp-clients#clients-that-take-the-json-above" + }, + "host": { + "name": "Cursor", + "url": "https://cursor.com" + }, + "platforms": [ + "macos" + ], + "categories": [ + "ai-clients" + ], + "surfaces": [ + "mcp" + ], + "disclosures": { + "reads": [ + "connections", + "schema", + "data" + ], + "writes": [ + "data" + ], + "accessNote": "The bundled bridge uses a Read & write token. Each connection's External Clients level and Safe Mode still apply.", + "lowersSafeMode": false, + "network": "internet", + "networkNote": "Cursor sends query results to the model provider it uses.", + "account": false, + "payment": "free" + }, + "links": { + "homepage": "https://docs.tablepro.app/integrations/mcp-clients#clients-that-take-the-json-above", + "docs": "https://docs.tablepro.app/developers/mcp-tools", + "issues": "https://github.com/TableProApp/TablePro/issues" + }, + "icon": "icon.png", + "keywords": [ + "mcp", + "agent", + "editor", + "ai" + ], + "addedAt": "2026-10-10", + "lastVerifiedAt": "2026-10-10" +} diff --git a/scripts/tests/fixtures/integrations/ddev-manager/icon.png b/scripts/tests/fixtures/integrations/ddev-manager/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6d840013d2cfbbba0b424d789b9f3ecff7da3349 GIT binary patch literal 2111 zcmcgtZ%kWN6#w1ZLf;^ihq4v5(sqks2@vWZpd`TFkjcohtyvZu6D-aAvt@HoM&Tlf4#L4s!_-L-4O=&h{*IRPtS332OtA7J{?>Q3*k_R{I!S&mr+4G>5@CkOi|v zttp3p5RqLh=48IMTtLC#dPLgE3cka+2xcSMLK)^tIrk0I-iYLra9Poxq>GbrOh(cx z^m*KOne;%i6;R=R2~o;~4#~%iA|RKTuR;<>)Z(Iv?oPs9lUnB>rM^)|&NJ<#tXp51 zSc9ooNp1w34H%g-X>)#qZa@y7-*3R97YnL^7*&nH_ok{ameSGoKb`5vj9ID>_AC&J ziYqF|>@qv1Lig!7A^Inn@GVGWx)t!WN<_3kb{lwFm3EPqDF7^6>v;E$i60%agF{Ep zetRLejf5gwx44p}9kqNu_S^F7K32>;kDpJza5M4o#(LL4#RxgNW2ia{ad%K@bw_xb z9NnChL2}8~OFArr;==4K`rv;RrGD@dl9IubtR(aaYp42if&ZD^Z`T?JD*meDOuvhw zc71iA6GbzreM3nP#=XoR7pOWMx!eGisA7V7?oPkMs`fJW5SDOZ~kJ)`!q>4~vCa z|7p>bt<;nQNF1l8Y;<-~Q(mR+E7kjW#&u~J53q#EW*wdN4~BP`MsU#^_Ei-t}C8ffU0c-gcA&XnH}tD_#$U zs!X^nfwQ2OVS`YFY)mbp*=58w8fpcQxb|5S>@<;znd(B>J__%@|E7;u2jrG_PXDsS PqaW71Zd2oMgYUo}(eqc( literal 0 HcmV?d00001 diff --git a/scripts/tests/fixtures/integrations/ddev-manager/integration.json b/scripts/tests/fixtures/integrations/ddev-manager/integration.json new file mode 100644 index 0000000..2c8c6c8 --- /dev/null +++ b/scripts/tests/fixtures/integrations/ddev-manager/integration.json @@ -0,0 +1,63 @@ +{ + "$schema": "../../schema/integration.schema.json", + "schemaVersion": 1, + "slug": "ddev-manager", + "name": "DDEV Manager", + "summary": "Open a DDEV project's database in TablePro from DDEV Manager in VS Code.", + "description": "DDEV Manager is a VS Code extension for starting, stopping and inspecting DDEV projects. Set ddevManager.defaultDatabaseManager to TablePro, and its open database action runs ddev tablepro for the project.\n\nIt needs DDEV 1.25.4 or later, which ships the tablepro command.", + "tier": "community", + "status": { + "state": "active" + }, + "publisher": { + "name": "Biati Digital", + "github": "biati-digital", + "githubId": 1219228, + "url": "https://www.biati.digital" + }, + "source": { + "type": "closed", + "note": "The public repository holds a small part of the source. The published extension is built from code that is not public." + }, + "install": { + "type": "vscode-marketplace", + "id": "biati.ddev-manager", + "openVsx": true + }, + "host": { + "name": "Visual Studio Code", + "url": "https://code.visualstudio.com", + "minVersion": "1.102" + }, + "platforms": [ + "macos" + ], + "categories": [ + "local-development", + "editors" + ], + "surfaces": [ + "database-url" + ], + "disclosures": { + "reads": [], + "writes": [], + "lowersSafeMode": false, + "network": "none", + "account": false, + "payment": "free" + }, + "links": { + "homepage": "https://marketplace.visualstudio.com/items?itemName=biati.ddev-manager", + "issues": "https://github.com/ddev/vscode-ddev-manager/issues" + }, + "icon": "icon.png", + "keywords": [ + "vscode", + "ddev", + "docker", + "php" + ], + "addedAt": "2026-10-10", + "lastVerifiedAt": "2026-10-10" +} diff --git a/scripts/tests/fixtures/integrations/ddev/icon.png b/scripts/tests/fixtures/integrations/ddev/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c1d1e33d37bf175fab86860f00f7d867d60bb5ae GIT binary patch literal 2110 zcmcgtUrGbYQ($VFi`v>_^=GGXmS$HrQogmINa z&`>cBV-w@Pm@&QJ)W}X_Gf+Eqk{D|u(;{;sb)}>kLxQd%6C2aHR|tWXe5mfjz2|;^ ze&2V#?{~iQT2*Dhk#c_u07u2+WlsZCEGcXj@wr|%@CRU1D#}W?*8Om8@N8|9Pc1*& z@|62b`kS^rr7Kw8*x^8C^z<=rQ~L$?<(0zc|9GxlEv6glA0E82hU-W4UFUKT{h8D< zL`O(jBltq6*lGStr8$ZvrD4E!-f-WgBfjf%H= zSvQF$h_6Ory~|ip9z2`Tzm3FwH1d%REh64t8( zEA8;)LVp(iClDDF%}|khFZ6Bj7bEhM@FKi817N6XMdVk?vk4Dpz-h&BOq5<`xiXL4 z(l;S8CG_A4@$bbz7ABZ!a#i9RR1;z!&f9N=aAB%cD5-XV1fR44n(@OwaAM*Wv69&* z(vv9 z^G_))<-KH~=T&L`zW?nW%tucaIGIdK(x*j8UKCSm>)<xeK_+*1lR3PlE9CD>vrTC)gSF0L^d&m($06*kIG2H;J>mzjk5kzX%r@!vm_J%!3c WU#vOY@rGyuP*Gl4c6?*-<-Y*r6lAFY literal 0 HcmV?d00001 diff --git a/scripts/tests/fixtures/integrations/ddev/integration.json b/scripts/tests/fixtures/integrations/ddev/integration.json new file mode 100644 index 0000000..c5c481b --- /dev/null +++ b/scripts/tests/fixtures/integrations/ddev/integration.json @@ -0,0 +1,68 @@ +{ + "$schema": "../../schema/integration.schema.json", + "schemaVersion": 1, + "slug": "ddev", + "name": "DDEV", + "summary": "Open the database of a DDEV project in TablePro with ddev tablepro.", + "description": "DDEV ships a tablepro command on macOS. Run ddev tablepro in a project folder: DDEV starts the project if it is stopped, then opens its MySQL, MariaDB or PostgreSQL database in TablePro as a connection named after the site.\n\nThe command passes DDEV's default database user and password in a connection URL and targets TablePro by bundle ID. Nothing is saved unless you save the connection in TablePro.", + "tier": "community", + "status": { + "state": "active" + }, + "publisher": { + "name": "DDEV", + "github": "ddev", + "githubId": 595986, + "url": "https://ddev.com" + }, + "source": { + "type": "github", + "repo": "ddev/ddev", + "repoId": 80669528, + "path": "pkg/ddevapp/global_dotddev_assets/commands/host/tablepro" + }, + "license": "Apache-2.0", + "install": { + "type": "ships-with-host", + "url": "https://docs.ddev.com/en/stable/users/usage/database-management/#database-guis" + }, + "host": { + "name": "DDEV", + "url": "https://ddev.com", + "minVersion": "1.25.4" + }, + "platforms": [ + "macos" + ], + "categories": [ + "local-development", + "command-line" + ], + "surfaces": [ + "database-url" + ], + "disclosures": { + "reads": [], + "writes": [], + "lowersSafeMode": false, + "network": "none", + "account": false, + "payment": "free" + }, + "links": { + "homepage": "https://ddev.com", + "docs": "https://docs.ddev.com/en/stable/users/usage/database-management/#database-guis", + "issues": "https://github.com/ddev/ddev/issues" + }, + "icon": "icon.png", + "keywords": [ + "docker", + "php", + "wordpress", + "drupal", + "mysql", + "postgresql" + ], + "addedAt": "2026-10-10", + "lastVerifiedAt": "2026-10-10" +} diff --git a/scripts/tests/fixtures/integrations/localwp/icon.png b/scripts/tests/fixtures/integrations/localwp/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9e21267d190e2d9f057778c5a05041341d9803dc GIT binary patch literal 2123 zcmcgtZA=_h6g_WtV5f_~K%tm+b%&y+1_~=pDnzT>76qb#jUl3mk*)dz+Z4ApRcsb` zHBF(?6h9-0KOj~ksZl2(si2gMLNLKqkRqD4%8F*=$5t^k3sRuoH$ZpOP!JQHAG`DJ zJ@>wQ?tOFKQFlpUyluS=Kzwmg!2^IquM(74`ny;?JPjzx#Rc2Ts?T0H_sL5qT`5Pt zax@NVD6+o(VuFm$ANtqDJ~om8 z{U^4MLl3cnLohzIsv;C!qAB3=Ce`V8|IGTsyKDe23Ey^>eo9Eio|4r3+-2{{N>c1d0j zYbckhK=J-^7Vt`*FKwt##>9ijJBDDkWTeZ^x+Ekxc>XbXe}?~YnfJtcvg2US#l*u1 zcvlm z#|SgJRS`XY?rwySkX;7c+tD#+HRik)5kLv|)mU&Klyo~lEul5x8z#4+cT%9Wg=Auz z-n6aI%daw;$F404Gix-28#f4?X<7 zf2O^D{Ov!hr+qcuS7=6;A{iO;ZM(u?seG+(jbsG+`+j`xP;OCtl@dQ6-A=vuq=-wei1VDK=h3Q=;mmCy>eIgE7e9}(Jg;;#=c59+1wG^O(7Q?*Z?r+KY{)3w^N;@+zpRoC9xp{a+M zUJ)rzqe2&_*4wfhYMrzx%0w)*Zk_R(jZsGW#Gx0?cb@l4#vUM)NIJj;M*Sua?$5p# zGEs$LJ8Cbb&3k0mBfr5@YEo*uV^Mitv+~oLh-(c)Pc(CuIS>}9WIk;%X3~(rgr80?^{C=CjQSHQ$Eem(Sy!l6$kp@A7gh0tQ@Zev_SmQTi;bm@`9V!Ui6-v9Z- bM-Q^yPxmJuzklw8*sS=Tl7jblc%J_o1qEz@ literal 0 HcmV?d00001 diff --git a/scripts/tests/fixtures/integrations/localwp/integration.json b/scripts/tests/fixtures/integrations/localwp/integration.json new file mode 100644 index 0000000..dc08075 --- /dev/null +++ b/scripts/tests/fixtures/integrations/localwp/integration.json @@ -0,0 +1,76 @@ +{ + "$schema": "../../schema/integration.schema.json", + "schemaVersion": 1, + "slug": "localwp", + "name": "Local", + "summary": "Open the MySQL database of a Local WordPress site in TablePro from the site's Database tab.", + "description": "A Local add-on that adds an Open TablePro button to a running site's Database tab. TablePro connection URLs cannot point at a MySQL socket, so the add-on opens a short-lived TCP bridge on 127.0.0.1 to the site's socket and hands TablePro a connection URL for it.\n\nInstall it in Local from Add-ons, Installed, Install from Disk, using the .tgz from the latest release. It needs Local 10 or later.", + "tier": "community", + "status": { + "state": "active" + }, + "publisher": { + "name": "Mario Peischl", + "github": "electricarts", + "githubId": 625286, + "url": "https://electricarts.de" + }, + "source": { + "type": "github", + "repo": "electricarts/local-tablepro", + "repoId": 1309032022 + }, + "license": "MIT", + "install": { + "type": "github-release", + "url": "https://github.com/electricarts/local-tablepro/releases/latest" + }, + "host": { + "name": "Local", + "url": "https://localwp.com", + "minVersion": "10.0" + }, + "platforms": [ + "macos" + ], + "categories": [ + "local-development" + ], + "surfaces": [ + "database-url" + ], + "disclosures": { + "reads": [], + "writes": [], + "lowersSafeMode": true, + "safeModeNote": "Opens site databases with Safe Mode set to Silent (safeModeLevel=0), so no statement asks for confirmation.", + "network": "local", + "account": false, + "payment": "free" + }, + "links": { + "homepage": "https://github.com/electricarts/local-tablepro", + "issues": "https://github.com/electricarts/local-tablepro/issues", + "privacy": "https://github.com/electricarts/local-tablepro/blob/main/PRIVACY.md", + "changelog": "https://github.com/electricarts/local-tablepro/blob/main/CHANGELOG.md" + }, + "icon": "icon.png", + "keywords": [ + "wordpress", + "mysql", + "localwp" + ], + "addedAt": "2026-10-10", + "lastVerifiedAt": "2026-10-10", + "compliance": { + "grace": { + "startedAt": "2026-10-10", + "endsAt": "2026-12-09", + "items": [ + "brand-name", + "brand-icon" + ], + "tracking": "https://github.com/electricarts/local-tablepro/issues/10" + } + } +} diff --git a/scripts/tests/fixtures/integrations/raycast/icon.png b/scripts/tests/fixtures/integrations/raycast/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3c1858f818dd340f0e3c0647d602572732fd2907 GIT binary patch literal 102853 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�%nO< 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 b)rS88fp)e@03J$b00000NkvXXu0mjfVl1YZ literal 0 HcmV?d00001 diff --git a/scripts/tests/fixtures/integrations/raycast/integration.json b/scripts/tests/fixtures/integrations/raycast/integration.json new file mode 100644 index 0000000..a7241d8 --- /dev/null +++ b/scripts/tests/fixtures/integrations/raycast/integration.json @@ -0,0 +1,78 @@ +{ + "$schema": "../../schema/integration.schema.json", + "schemaVersion": 1, + "slug": "raycast", + "name": "Raycast", + "summary": "Search connections, tables and query history, and run queries from Raycast.", + "description": "The TablePro extension for Raycast lists your saved connections, opens one in TablePro, searches schemas and tables, shows recent tabs and query history, and runs SQL through the TablePro app.\n\nRun Pair with TablePro once. You choose the token's scope when you pair: Read-only, Read & write, or Full access. The extension's AI tools let Raycast AI list connections, describe tables and run queries with the same token.", + "tier": "official", + "status": { + "state": "active" + }, + "publisher": { + "name": "TablePro", + "github": "TableProApp", + "githubId": 260450047, + "url": "https://tablepro.app" + }, + "source": { + "type": "github", + "repo": "raycast/extensions", + "repoId": 408858330, + "path": "extensions/tablepro" + }, + "license": "MIT", + "install": { + "type": "raycast-store", + "url": "https://www.raycast.com/ngoquocdat/tablepro" + }, + "host": { + "name": "Raycast", + "url": "https://www.raycast.com", + "note": "The AI tools need Raycast AI." + }, + "platforms": [ + "macos" + ], + "categories": [ + "launchers", + "ai-clients" + ], + "surfaces": [ + "url-scheme", + "mcp", + "pairing" + ], + "disclosures": { + "reads": [ + "connections", + "schema", + "data" + ], + "writes": [ + "data", + "schema" + ], + "accessNote": "Writes need a Read & write or Full access token, chosen when you pair.", + "lowersSafeMode": false, + "network": "internet", + "networkNote": "The AI tools send query results to Raycast AI when you use them in a Raycast AI chat.", + "account": false, + "payment": "free" + }, + "links": { + "homepage": "https://www.raycast.com/ngoquocdat/tablepro", + "docs": "https://docs.tablepro.app/integrations/raycast", + "issues": "https://github.com/raycast/extensions/issues", + "changelog": "https://github.com/raycast/extensions/blob/main/extensions/tablepro/CHANGELOG.md" + }, + "icon": "icon.png", + "keywords": [ + "launcher", + "mcp", + "sql", + "raycast ai" + ], + "addedAt": "2026-10-10", + "lastVerifiedAt": "2026-10-10" +} diff --git a/scripts/tests/fixtures/integrations/shortcuts/icon.png b/scripts/tests/fixtures/integrations/shortcuts/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ddc10b4a4e7458214dafb1263e80d18bee84b0db GIT binary patch literal 2118 zcmcgtZA@EL7=F&_*Il7-$0nPO(ALH*5w=#16BOYxSy)MUKQ2*BjrUUEM`*-r{i&pvSd` z{Dss?=$s%WFGBv(z-uY)F$<|2avN}y1)b+flLYxk1FxajYvw5n>=ZaPU}b?bh9*$< z=4onVipy<;=a>mQff2m|gq1&t&KL=l5~E613fj*uU27aYfQfCVS6N9Zuz+u;c6q&_0$+ZmX= zVgIls96C?H)YdOUJChz*IO#L^}rv@D*UfK-jSHj6u57sxUD_ z@}DAd53giToi0LTH}h)&M=|_!26@h{Q4$zoZG#4$iPg6OtR>85_?DS1=$}#0mIRry zO#iwq=&V@cRL@*nE@G=Ji0s{<;8fRgFqK;^Vj-;xPlnVTE%B`YPll2dNuL0q;*&aU z>kq@@*81w;{=&!h+x@INOUq+D;^s^LJ0c3KuMTtmj~W_Be+!NbkH0h>-Id(&3TaDM zV|O&(=*tUP9OB@;5@hja7ZLyKtz5*u+``NGBwWQ71O~by`*KW|2DhfNfOfop=;U2f zQFe>tSK!iVWV5j>OCM$XFr2XjV(Vue#QACjvk ztHK{ru{U{;7bdq9orGmhTOKcIK1ry5T!Yz?1vlY(X~Jddgro2a;t*?M>$L0$pcg7GCX;Hg61y=WVxayymZ zW}>vnk7ld&n%*2|=rmq)Hq)%yd;?OMkU9nv;R(dTz$hXnQi zM+|>K+%DoMWg46!MXx=GGjBpVTBEpqB;aJMCb2Lh;r4>3$SevQ<#Zx_-n;>y5oS_> zLuHJ@3Nf65CYEkjalH^yQF%cLRK_^;L;TeSUL(Ehg}@`Tkm5d6Mp%G|15Aql`$w+n XhVAUYM7^$hMp@0dt*oSDtGMegAwX)x literal 0 HcmV?d00001 diff --git a/scripts/tests/fixtures/integrations/shortcuts/integration.json b/scripts/tests/fixtures/integrations/shortcuts/integration.json new file mode 100644 index 0000000..e64ee23 --- /dev/null +++ b/scripts/tests/fixtures/integrations/shortcuts/integration.json @@ -0,0 +1,69 @@ +{ + "$schema": "../../schema/integration.schema.json", + "schemaVersion": 1, + "slug": "shortcuts", + "name": "Shortcuts", + "summary": "Add rows to a table and open a connection from Shortcuts on iPhone and iPad.", + "description": "TablePro for iPhone and iPad adds three actions to Shortcuts, the Share Sheet and Siri: Add Row to Table, Add Rows to Table and Open Connection. The two insert actions take JSON or CSV and run in the background with the app closed.\n\nThe insert actions need iOS app build 17 or later.", + "tier": "official", + "status": { + "state": "active" + }, + "publisher": { + "name": "TablePro", + "github": "TableProApp", + "githubId": 260450047, + "url": "https://tablepro.app" + }, + "source": { + "type": "github", + "repo": "TableProApp/TablePro", + "repoId": 1117891044, + "path": "TableProMobile/TableProMobile/Intents" + }, + "license": "AGPL-3.0-only", + "install": { + "type": "built-in", + "docs": "https://docs.tablepro.app/integrations/ios-shortcuts" + }, + "host": { + "name": "Shortcuts", + "url": "https://support.apple.com/guide/shortcuts/welcome/ios" + }, + "platforms": [ + "ios", + "ipados" + ], + "categories": [ + "automation" + ], + "surfaces": [ + "shortcuts" + ], + "disclosures": { + "reads": [ + "connections", + "schema" + ], + "writes": [ + "data" + ], + "lowersSafeMode": false, + "network": "none", + "account": false, + "payment": "free" + }, + "links": { + "homepage": "https://docs.tablepro.app/integrations/ios-shortcuts", + "issues": "https://github.com/TableProApp/TablePro/issues" + }, + "icon": "icon.png", + "keywords": [ + "ios", + "siri", + "app intents", + "automation" + ], + "addedAt": "2026-10-10", + "lastVerifiedAt": "2026-10-10" +} diff --git a/scripts/tests/fixtures/integrations/zed/icon.png b/scripts/tests/fixtures/integrations/zed/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..bd30f7122ad744d55023417eda357b6f57a8bc15 GIT binary patch literal 2123 zcmcgtT}&KR6#nkaEIZ5=b_Kh!KldO3Z3$qd3^`ph?M^wF=_u ztx^_KlWJ`0CSoKe#>O<3*doMGSWCsX%{(pGNPW5@BA5>o`-OX-{mlvPT zWPFRWQIvR#8v!BD&3Tln>R|_*JKouOOoufk!G>R2O?5V!0?^Kdl7w)g$D(M z9FZnBygtNQ;SWP=vjHl64TNc_?QYsr<&a=;ZKa#8V2pdP!abo|`b_&Zi*W|Bvv@x~QPTp7|qcmz~ z3*%?A?=+f)K(Aua-C!-xT_jMjOe_w?{Q~s#i3XO(orKVp6fF-ny+)- z4n?PI%jb|2?_wKtdRwLHWk}!jeb;>Gn-{hRPvV&WXOVUOuJ^|=a?mfDHu4oNI^14vY3W}msoTH#v2ZR#BCP_4zQ@kj0y)r z3IhA!eFmhwkt|XBkn}m7*S-t?ua-Wavl|N}_=O1UK~xh+#Su!0G;M^p39-?SYs0pK z=U;hEfx9amAvXd(csmgOWKvNp66un$%xRIPB6u$%=2yEa zs0Z)OflDAZ=rV;1mY)MX;GdZQ?>!2A16^SP9;UlZhQy3tJaE}~6#*GIo<|M)@1MD0 ZM= dict: + return json.loads((FIXTURES / "integrations" / slug / "integration.json").read_text(encoding="utf-8")) + + +def messages(validator, data) -> list: + return [e.message for e in validator.iter_errors(data)] + + +class SchemaTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.validator = schema_validator() + + def test_every_seed_passes(self): + for path in sorted((FIXTURES / "integrations").glob("*/integration.json")): + with self.subTest(entry=path.parent.name): + self.assertEqual(messages(self.validator, json.loads(path.read_text())), []) + + def rejects(self, slug, mutate): + data = copy.deepcopy(seed(slug)) + mutate(data) + self.assertTrue(list(self.validator.iter_errors(data)), "expected the schema to reject this manifest") + + def test_unknown_tier(self): + self.rejects("ddev", lambda d: d.update(tier="verified")) + + def test_extra_property(self): + self.rejects("ddev", lambda d: d.update(stars=10)) + + def test_http_url(self): + self.rejects("ddev", lambda d: d["links"].update(homepage="http://ddev.com")) + + def test_url_with_user_info(self): + self.rejects("ddev", lambda d: d["links"].update(homepage="https://user:pass@ddev.com")) + + def test_ip_literal_url(self): + self.rejects("ddev", lambda d: d["links"].update(homepage="https://192.168.1.10/")) + + def test_javascript_url(self): + self.rejects("ddev", lambda d: d["links"].update(docs="javascript:alert(1)")) + + def test_summary_without_period(self): + self.rejects("ddev", lambda d: d.update(summary="Open the database of a DDEV project in TablePro")) + + def test_summary_with_markup(self): + self.rejects("ddev", lambda d: d.update(summary="Open the database of a DDEV project in TablePro.")) + + def test_name_with_bidi_control(self): + self.rejects("ddev", lambda d: d.update(name="DD\u202eEV")) + + def test_description_with_three_newlines(self): + self.rejects("ddev", lambda d: d.update(description=d["description"].replace("\n\n", "\n\n\n", 1))) + + def test_name_longer_than_32_characters(self): + self.rejects("ddev", lambda d: d.update(name="D" * 33)) + + def test_slug_with_uppercase(self): + self.rejects("ddev", lambda d: d.update(slug="DDEV")) + + def test_schema_version_two(self): + self.rejects("ddev", lambda d: d.update(schemaVersion=2)) + + def test_github_source_without_license(self): + self.rejects("ddev", lambda d: d.pop("license")) + + def test_closed_source_with_license(self): + self.rejects("ddev-manager", lambda d: d.update(license="Apache-2.0")) + + def test_github_source_without_repo_id(self): + self.rejects("ddev", lambda d: d["source"].pop("repoId")) + + def test_source_path_traversal(self): + self.rejects("ddev", lambda d: d["source"].update(path="pkg/../../etc")) + + def test_built_in_on_community_tier(self): + self.rejects("command-line", lambda d: d.update(tier="community")) + + def test_built_in_docs_outside_tablepro(self): + self.rejects("command-line", lambda d: d["install"].update(docs="https://example.com/docs")) + + def test_raycast_install_on_another_host(self): + self.rejects("raycast", lambda d: d["install"].update(url="https://raycast.example.com/a/b")) + + def test_github_release_must_be_latest_page(self): + self.rejects("localwp", lambda d: d["install"].update( + url="https://github.com/electricarts/local-tablepro/releases/download/v2.0.0/x.tgz")) + + def test_install_branch_rejects_foreign_keys(self): + self.rejects("ddev-manager", lambda d: d["install"].update(url="https://example.com")) + + def test_unknown_install_type(self): + self.rejects("ddev", lambda d: d.update(install={"type": "download", "url": "https://ddev.com/x.dmg"})) + + def test_unknown_category(self): + self.rejects("ddev", lambda d: d.update(categories=["integration"])) + + def test_three_categories(self): + self.rejects("ddev", lambda d: d.update(categories=["local-development", "command-line", "other"])) + + def test_unknown_surface(self): + self.rejects("ddev", lambda d: d.update(surfaces=["connections-json"])) + + def test_lowers_safe_mode_without_note(self): + self.rejects("localwp", lambda d: d["disclosures"].pop("safeModeNote")) + + def test_lowers_safe_mode_outside_local_development(self): + self.rejects("localwp", lambda d: d.update(categories=["launchers"])) + + def test_internet_without_note(self): + self.rejects("raycast", lambda d: d["disclosures"].pop("networkNote")) + + def test_paid_without_note(self): + self.rejects("ddev", lambda d: d["disclosures"].update(payment="paid")) + + def test_account_without_privacy_link(self): + self.rejects("ddev", lambda d: d["disclosures"].update(account=True)) + + def test_archived_without_reason(self): + self.rejects("ddev", lambda d: d.update(status={"state": "archived", "since": "2026-10-10"})) + + def test_archived_reason_outside_enum(self): + self.rejects("ddev", lambda d: d.update(status={"state": "archived", "since": "2026-10-10", "reason": "removed"})) + + def test_superseded_without_replacement(self): + self.rejects("ddev", lambda d: d.update(status={"state": "archived", "since": "2026-10-10", "reason": "superseded"})) + + def test_active_with_archive_fields(self): + self.rejects("ddev", lambda d: d.update(status={"state": "active", "since": "2026-10-10"})) + + def test_grace_item_outside_enum(self): + self.rejects("localwp", lambda d: d["compliance"]["grace"].update(items=["stars"])) + + def test_grace_tracking_not_an_issue(self): + self.rejects("localwp", lambda d: d["compliance"]["grace"].update(tracking="https://example.com/issue")) + + def test_bad_date(self): + self.rejects("ddev", lambda d: d.update(addedAt="2026-13-01")) + + def test_icon_other_than_png(self): + self.rejects("ddev", lambda d: d.update(icon="icon.svg")) + + def test_five_screenshots(self): + self.rejects("ddev", lambda d: d.update(screenshots=[{"file": f"screenshot-{i}.png", "alt": "A screenshot of it"} for i in range(1, 6)])) + + def test_wrong_schema_reference(self): + self.rejects("ddev", lambda d: d.update({"$schema": "https://evil.example/schema.json"})) + + def test_publisher_login_with_double_hyphen(self): + self.rejects("ddev", lambda d: d["publisher"].update(github="dd--ev")) + + +class IndexSchemaTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.validator = r.validator_for(REPO, r.INDEX_SCHEMA) + + def image(self, name, size): + return {"file": name, "width": size, "height": size, "sha256": "0" * 64} + + def index(self, icon_file): + entry = {k: v for k, v in seed("ddev").items() if k not in ("$schema", "schemaVersion")} + entry.update( + summarySha256="0" * 64, + closedSource=False, + install=dict(entry["install"], url=r.install_url(entry["install"])), + icon={"512": self.image(icon_file, 512), "128": self.image("ddev-icon-128-0123456789ab.png", 128)}, + screenshots=[], + ) + return {"schemaVersion": 1, "generatedAt": "2026-10-10T00:00:00Z", "commit": "0" * 40, + "contentSha256": "0" * 64, "integrations": [entry], "removed": []} + + def test_flat_content_addressed_image_passes(self): + self.assertEqual(messages(self.validator, self.index("ddev-icon-512-0123456789ab.png")), []) + + def test_image_in_a_folder_is_rejected(self): + self.assertTrue(messages(self.validator, self.index("images/ddev/icon-512.0123456789ab.png"))) + + def test_image_without_content_hash_is_rejected(self): + self.assertTrue(messages(self.validator, self.index("ddev-icon-512.png"))) + + +class EmptyRegistryTests(unittest.TestCase): + def test_no_entries_is_clean(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + for name in ("schema", "curation"): + shutil.copytree(REPO / name, root / name) + (root / "integrations").mkdir() + ctx = validate.run(root, TODAY) + self.assertEqual(ctx.entries, {}) + self.assertEqual([f.message for f in ctx.report.findings], []) + + +class RepoCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + for name in ("schema", "curation"): + shutil.copytree(REPO / name, self.root / name) + shutil.copytree(FIXTURES / "integrations", self.root / "integrations") + + def tearDown(self): + self.tmp.cleanup() + + def manifest(self, slug: str) -> dict: + return json.loads((self.root / "integrations" / slug / "integration.json").read_text()) + + def write(self, slug: str, data: dict) -> None: + folder = self.root / "integrations" / slug + folder.mkdir(parents=True, exist_ok=True) + (folder / "integration.json").write_text(json.dumps(data, indent=2) + "\n") + + def edit(self, slug: str, mutate) -> None: + data = self.manifest(slug) + mutate(data) + self.write(slug, data) + + def run_validator(self, **kwargs): + return validate.run(self.root, TODAY, **kwargs) + + def assertError(self, ctx, fragment: str): + found = [f.message for f in ctx.report.errors] + self.assertTrue(any(fragment in m for m in found), f"no error containing {fragment!r}: {found}") + + +class SemanticTests(RepoCase): + def test_seeds_are_clean(self): + ctx = self.run_validator() + self.assertEqual([f.message for f in ctx.report.errors], []) + + def test_slug_must_match_folder(self): + self.edit("ddev", lambda d: d.update(slug="ddev-cli")) + self.assertError(self.run_validator(), "must equal the folder name") + + def test_community_name_with_tablepro(self): + self.edit("localwp", lambda d: d.update(name="TablePro Launcher")) + self.assertError(self.run_validator(), "name may not contain `tablepro`") + + def test_spaced_and_camel_case_spellings_are_caught(self): + for name in ("Table Pro for Local", "LocalTablePro", "TABLEPRO Local"): + with self.subTest(name=name): + self.assertTrue(r.contains_fragment(name, "tablepro")) + + def test_words_that_merely_contain_the_letters_pass(self): + for name in ("Stable Prompt", "Portable Profiles", "Notable Projects"): + with self.subTest(name=name): + self.assertFalse(r.contains_fragment(name, "tablepro")) + + def test_reserved_slug(self): + shutil.move(str(self.root / "integrations" / "ddev"), str(self.root / "integrations" / "brand")) + self.edit("brand", lambda d: d.update(slug="brand")) + self.assertError(self.run_validator(), "slug `brand` is reserved") + + def test_official_needs_allowlisted_publisher(self): + self.edit("ddev", lambda d: d.update(tier="official")) + self.assertError(self.run_validator(), "official-publishers.json") + + def test_partner_needs_a_curation_row(self): + self.edit("ddev", lambda d: d.update(tier="partner")) + self.assertError(self.run_validator(), "partners.json") + + def test_partner_with_curation_row_passes(self): + self.edit("ddev", lambda d: d.update(tier="partner")) + partners = self.root / "curation" / "partners.json" + data = json.loads(partners.read_text()) + data["partners"].append({"slug": "ddev", "github": "ddev", "githubId": 595986, "domain": "ddev.com", + "method": "github-verified-org", "verifiedAt": "2026-10-10", + "evidence": "https://github.com/ddev"}) + partners.write_text(json.dumps(data)) + self.assertEqual([f.message for f in self.run_validator().report.errors], []) + + def test_removed_slug_cannot_return(self): + removed = self.root / "curation" / "removed.json" + removed.write_text(json.dumps({"$schema": "../schema/curation.schema.json", "removed": [ + {"slug": "ddev", "name": "DDEV", "removedAt": "2026-10-01", "reason": "author-request", + "link": "https://github.com/TableProApp/integrations/issues/1"}]})) + self.assertError(self.run_validator(), "cannot be reused") + + def test_removed_reason_outside_enum(self): + removed = self.root / "curation" / "removed.json" + removed.write_text(json.dumps({"$schema": "../schema/curation.schema.json", "removed": [ + {"slug": "old", "name": "Old", "removedAt": "2026-10-01", "reason": "Remove", + "link": "https://github.com/TableProApp/integrations/issues/1"}]})) + self.assertError(self.run_validator(), "is not one of") + + def test_duplicate_name(self): + self.edit("ddev-manager", lambda d: d.update(name="D.D.E.V")) + self.assertError(self.run_validator(), "name duplicates the entry") + + def test_similar_name_warns(self): + self.edit("ddev-manager", lambda d: d.update(name="DDEW")) + ctx = self.run_validator() + self.assertTrue(any("close to the entry `ddev`" in f.message for f in ctx.report.warnings)) + + def test_duplicate_install_target(self): + self.edit("ddev-manager", lambda d: d.update(install=self.manifest("ddev")["install"])) + self.assertError(self.run_validator(), "install target duplicates") + + def test_future_added_date(self): + self.edit("ddev", lambda d: d.update(addedAt="2026-12-01", lastVerifiedAt="2026-12-01")) + self.assertError(self.run_validator(), "addedAt is in the future") + + def test_verified_before_added(self): + self.edit("ddev", lambda d: d.update(addedAt="2026-10-01", lastVerifiedAt="2026-09-01")) + self.assertError(self.run_validator(), "lastVerifiedAt is before addedAt") + + def test_grace_longer_than_policy(self): + self.edit("localwp", lambda d: d["compliance"]["grace"].update(endsAt="2026-12-31")) + self.assertError(self.run_validator(), "the policy allows 60") + + def test_grace_ending_before_start(self): + self.edit("localwp", lambda d: d["compliance"]["grace"].update(endsAt="2026-10-01")) + self.assertError(self.run_validator(), "must be after startedAt") + + def test_min_version_for_unlisted_platform(self): + self.edit("command-line", lambda d: d.update(minTableProVersion={"ios": "1.0"})) + self.assertError(self.run_validator(), "ios is not in platforms") + + def test_release_install_from_another_repo(self): + self.edit("localwp", lambda d: d["install"].update(url="https://github.com/evil/local-tablepro/releases/latest")) + self.assertError(self.run_validator(), "not the source repository") + + def test_superseded_replacement_must_exist(self): + self.edit("ddev-manager", lambda d: d.update(status={"state": "archived", "since": "2026-10-10", + "reason": "superseded", "replacement": "nope"})) + self.assertError(self.run_validator(), "is not an entry") + + def test_shortener_url(self): + self.edit("ddev", lambda d: d["links"].update(docs="https://bit.ly/abc")) + self.assertError(self.run_validator(), "URL shortener") + + def test_emoji_in_name(self): + self.edit("ddev", lambda d: d.update(name="DDEV \U0001F680")) + self.assertError(self.run_validator(), "symbol or emoji") + + def test_non_nfc_text(self): + self.edit("localwp", lambda d: d["publisher"].update(name="Rene\u0301 Peischl")) + self.assertError(self.run_validator(), "NFC") + + def test_extra_file_in_entry_folder(self): + (self.root / "integrations" / "ddev" / "README.md").write_text("hi") + self.assertError(self.run_validator(), "belong in an entry folder") + + def test_svg_icon_is_rejected(self): + (self.root / "integrations" / "ddev" / "icon.svg").write_text("") + self.assertError(self.run_validator(), "belong in an entry folder") + + def test_icon_wrong_size(self): + from PIL import Image + Image.new("RGBA", (256, 256), (1, 2, 3, 255)).save(self.root / "integrations" / "ddev" / "icon.png") + self.assertError(self.run_validator(), "expected 512x512") + + def test_icon_that_is_not_a_png(self): + (self.root / "integrations" / "ddev" / "icon.png").write_bytes(b"GIF89a" + b"\x00" * 64) + self.assertError(self.run_validator(), "is not a PNG") + + def test_animated_png_icon(self): + from PIL import Image + frames = [Image.new("RGBA", (512, 512), (i * 80, 0, 0, 255)) for i in range(2)] + path = self.root / "integrations" / "ddev" / "icon.png" + frames[0].save(path, save_all=True, append_images=frames[1:], format="PNG") + self.assertError(self.run_validator(), "animated PNG") + + def test_third_party_icon_that_is_the_tablepro_mark(self): + shutil.copy(TABLEPRO_ICON, self.root / "integrations" / "localwp" / "icon.png") + self.assertError(self.run_validator(), "looks like the TablePro mark") + + def test_icon_hash_failure_is_an_error(self): + from unittest import mock + with mock.patch.object(r, "mark_hash", side_effect=OSError("broken")): + self.assertError(self.run_validator(), "could not compare the icon") + + def test_official_entry_may_use_the_mark(self): + ctx = self.run_validator() + self.assertFalse(any("TablePro mark" in f.message for f in ctx.report.findings)) + + def test_listed_screenshot_missing(self): + self.edit("ddev", lambda d: d.update(screenshots=[{"file": "screenshot-1.png", "alt": "DDEV opening TablePro"}])) + self.assertError(self.run_validator(), "listed in integration.json but missing") + + def test_unlisted_screenshot(self): + from PIL import Image + Image.new("RGB", (1600, 1000)).save(self.root / "integrations" / "ddev" / "screenshot-1.png") + self.assertError(self.run_validator(), "not listed in integration.json") + + def test_screenshot_wrong_size(self): + from PIL import Image + Image.new("RGB", (2000, 1250)).save(self.root / "integrations" / "ddev" / "screenshot-1.png") + self.edit("ddev", lambda d: d.update(screenshots=[{"file": "screenshot-1.png", "alt": "DDEV opening TablePro"}])) + self.assertError(self.run_validator(), "expected 1600x1000") + + def test_invalid_json(self): + (self.root / "integrations" / "ddev" / "integration.json").write_text("{ nope") + self.assertError(self.run_validator(), "invalid JSON") + + def test_curation_file_missing(self): + (self.root / "curation" / "reserved.json").unlink() + self.assertError(self.run_validator(), "file is missing") + + +class ChangeScopeTests(RepoCase): + def setUp(self): + super().setUp() + subprocess.run(["git", "init", "-q", "-b", "main", str(self.root)], check=True) + self.commit("base") + self.git("checkout", "-q", "-b", "pr") + + def git(self, *args): + subprocess.run(["git", "-C", str(self.root), *args], check=True) + + def commit(self, message="change"): + self.git("add", "-A") + self.git("-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false", + "commit", "-q", "-m", message) + + def run_pr(self, maintainer=False): + return self.run_validator(base="main", author="someone", maintainer=maintainer) + + def test_added_at_is_immutable(self): + self.edit("ddev", lambda d: d.update(addedAt="2026-10-09")) + self.commit() + self.assertError(self.run_pr(maintainer=True), "addedAt never changes") + + def test_contributor_cannot_change_tier(self): + self.edit("ddev", lambda d: d.update(tier="official")) + self.commit() + self.assertError(self.run_pr(), "tier changed") + + def test_contributor_cannot_change_repo_id(self): + self.edit("localwp", lambda d: d["source"].update(repoId=1)) + self.commit() + self.assertError(self.run_pr(), "source.repoId changed") + + def test_contributor_touches_one_entry(self): + self.edit("ddev", lambda d: d.update(keywords=["docker"])) + self.edit("localwp", lambda d: d.update(keywords=["wordpress"])) + self.commit() + self.assertError(self.run_pr(), "one entry folder per pull request") + + def test_contributor_cannot_touch_curation(self): + (self.root / "curation" / "partners.json").write_text( + json.dumps({"$schema": "../schema/curation.schema.json", "partners": []}, indent=4)) + self.commit() + self.assertError(self.run_pr(), "only maintainers change files outside") + + def test_rename_is_refused(self): + shutil.move(str(self.root / "integrations" / "ddev"), str(self.root / "integrations" / "ddev-cli")) + self.edit("ddev-cli", lambda d: d.update(slug="ddev-cli")) + self.commit() + self.assertError(self.run_pr(maintainer=True), "cannot be renamed") + + def test_deletion_needs_a_tombstone(self): + shutil.rmtree(self.root / "integrations" / "ddev-manager") + self.commit() + self.assertError(self.run_pr(maintainer=True), "without a tombstone") + + def test_maintainer_deletion_with_tombstone_passes(self): + shutil.rmtree(self.root / "integrations" / "ddev-manager") + removed = self.root / "curation" / "removed.json" + removed.write_text(json.dumps({"$schema": "../schema/curation.schema.json", "removed": [ + {"slug": "ddev-manager", "name": "DDEV Manager", "removedAt": "2026-10-10", "reason": "author-request", + "link": "https://github.com/TableProApp/integrations/issues/2"}]})) + self.commit() + self.assertEqual([f.message for f in self.run_pr(maintainer=True).report.errors], []) + + def test_contributor_update_of_own_entry_passes(self): + self.edit("localwp", lambda d: d.update(keywords=["wordpress", "mysql"])) + self.commit() + self.assertEqual([f.message for f in self.run_pr().report.errors], []) + + +class FakeResponse: + def __init__(self, status, body): + self.status = status + self._body = json.dumps(body).encode() if body is not None else b"" + + def read(self): + return self._body + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +class OnlineTests(RepoCase): + def github(self, routes): + import urllib.error + + def opener(request, timeout=30): + path = request.full_url.replace("https://api.github.com", "") + if path in routes: + status, body = routes[path] + if status >= 400: + raise urllib.error.HTTPError(request.full_url, status, "x", {}, None) + return FakeResponse(status, body) + raise urllib.error.HTTPError(request.full_url, 404, "x", {}, None) + return r.GitHub(token="", opener=opener) + + def local_routes(self, **overrides): + routes = { + "/users/electricarts": (200, {"id": 625286}), + "/repositories/1309032022": (200, {"full_name": "electricarts/local-tablepro", "archived": False, "private": False, "fork": False}), + "/repos/electricarts/local-tablepro/license": (200, {"license": {"spdx_id": "MIT"}}), + "/repos/electricarts/local-tablepro/issues/10": (200, {"number": 10}), + } + routes.update(overrides) + return routes + + def check(self, routes): + report = r.Report() + ctx = validate.Context(self.root, TODAY, r.load_curation(self.root, report), r.load_entries(self.root), + report, None, "", True) + entry = ctx.entries["localwp"] + manifest = entry.manifest + publisher = manifest["publisher"] + gh = self.github(routes) + status, user = gh.user(publisher["github"]) + if status != 200 or user.get("id") != publisher["githubId"]: + report.error(entry.manifest_path, "publisher.githubId does not match") + validate.check_github_source(ctx, gh, entry, manifest["source"], manifest) + return ctx + + def test_matching_repository_passes(self): + self.assertEqual([f.message for f in self.check(self.local_routes()).report.errors], []) + + def test_repojacked_repository_id(self): + ctx = self.check(self.local_routes(**{"/repositories/1309032022": (200, {"full_name": "attacker/local-tablepro"})})) + self.assertError(ctx, "is attacker/local-tablepro") + + def test_archived_repository(self): + ctx = self.check(self.local_routes(**{"/repositories/1309032022": (200, {"full_name": "electricarts/local-tablepro", "archived": True})})) + self.assertError(ctx, "archived on GitHub") + + def test_missing_license(self): + ctx = self.check(self.local_routes(**{"/repos/electricarts/local-tablepro/license": (404, None)})) + self.assertError(ctx, "no LICENSE file") + + def test_license_mismatch(self): + ctx = self.check(self.local_routes(**{"/repos/electricarts/local-tablepro/license": (200, {"license": {"spdx_id": "GPL-3.0"}})})) + self.assertError(ctx, "GitHub reports GPL-3.0") + + def test_renamed_publisher_account(self): + ctx = self.check(self.local_routes(**{"/users/electricarts": (200, {"id": 1})})) + self.assertError(ctx, "publisher.githubId does not match") + + def test_ownership_for_a_stranger_needs_consent(self): + report = r.Report() + ctx = validate.Context(self.root, TODAY, r.load_curation(self.root, report), r.load_entries(self.root), + report, None, "stranger", False) + gh = self.github({"/repos/electricarts/local-tablepro/contributors?per_page=100": (200, [ + {"login": "electricarts", "contributions": 90}, {"login": "stranger", "contributions": 3}])}) + entry = ctx.entries["localwp"] + validate.check_ownership(ctx, gh, entry, entry.manifest["source"], entry.manifest["publisher"]) + self.assertEqual(ctx.needs_consent, ["localwp"]) + + def test_major_contributor_does_not_need_consent(self): + report = r.Report() + ctx = validate.Context(self.root, TODAY, r.load_curation(self.root, report), r.load_entries(self.root), + report, None, "helper", False) + gh = self.github({"/repos/electricarts/local-tablepro/contributors?per_page=100": (200, [ + {"login": "electricarts", "contributions": 90}, {"login": "helper", "contributions": 31}])}) + entry = ctx.entries["localwp"] + validate.check_ownership(ctx, gh, entry, entry.manifest["source"], entry.manifest["publisher"]) + self.assertEqual(ctx.needs_consent, []) + + +class ScanTests(unittest.TestCase): + def scan(self, files): + with tempfile.TemporaryDirectory() as tmp: + for name, text in files.items(): + path = Path(tmp) / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + return [hit[2] for hit in r.scan_tree(Path(tmp))] + + def test_private_files_are_found(self): + hits = self.scan({ + "a.ts": 'const APP = "Library/Application Support/TablePro";', + "b.ts": 'join(dir, "mcp-handshake.json")', + "c.py": 'Path.home() / "Library/Preferences/com.TablePro.plist"', + "d.py": 'KEY = "com.TablePro.connections"', + "e.sh": "security find-generic-password -s com.TablePro -w", + "f.sh": "defaults read com.TablePro", + }) + self.assertEqual(sorted(hits), sorted(["app-support-dir", "handshake-file", "prefs-plist", "prefs-keys", "keychain", "defaults-domain"])) + + def test_public_surfaces_are_not_flagged(self): + hits = self.scan({ + "a.sh": 'open -b com.TablePro "mysql://db:db@127.0.0.1:3306/db"', + "b.ts": 'open("tablepro://connect/" + id)', + "node_modules/x/c.js": 'const p = "Application Support/TablePro"', + }) + self.assertEqual(hits, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/validate.py b/scripts/validate.py new file mode 100644 index 0000000..42a2932 --- /dev/null +++ b/scripts/validate.py @@ -0,0 +1,536 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import datetime as dt +import ipaddress +import json +import os +import subprocess +import sys +import tempfile +import unicodedata +import urllib.parse +from pathlib import Path +from typing import Optional + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import registry as r # noqa: E402 + +MAINTAINER_ASSOCIATIONS = {"OWNER", "MEMBER", "COLLABORATOR"} + + +class Context: + def __init__(self, root: Path, today: dt.date, curation: dict, entries: dict, report: r.Report, + base: Optional[str], author: str, maintainer: bool): + self.root = root + self.today = today + self.curation = curation + self.entries = entries + self.report = report + self.base = base + self.author = author + self.maintainer = maintainer + self.base_entries: dict = {} + self.changed_paths: list = [] + self.needs_consent: list = [] + + def curated(self, name: str, key: str) -> list: + return (self.curation.get(name) or {}).get(key, []) + + +def git(root: Path, *args: str) -> str: + return subprocess.run(["git", "-C", str(root), *args], check=True, capture_output=True, text=True).stdout + + +def load_base(ctx: Context) -> None: + if not ctx.base: + return + ctx.changed_paths = [line.split("\t") for line in git(ctx.root, "diff", "--name-status", "--no-renames", + f"{ctx.base}...HEAD").splitlines() if line] + listing = git(ctx.root, "ls-tree", "-r", "--name-only", ctx.base, "--", "integrations") + for path in listing.splitlines(): + parts = path.split("/") + if len(parts) == 3 and parts[2] == "integration.json": + try: + ctx.base_entries[parts[1]] = json.loads(git(ctx.root, "show", f"{ctx.base}:{path}")) + except (subprocess.CalledProcessError, json.JSONDecodeError): + ctx.base_entries[parts[1]] = None + + +def check_url(ctx: Context, file: str, url: str) -> None: + parsed = urllib.parse.urlsplit(url) + host = (parsed.hostname or "").lower() + if parsed.scheme != "https": + ctx.report.error(file, f"{url}: use https") + if parsed.username or parsed.password: + ctx.report.error(file, f"{url}: remove the user name or password from the URL") + try: + ipaddress.ip_address(host) + ctx.report.error(file, f"{url}: use a host name, not an IP address") + except ValueError: + pass + if host in r.SHORTENER_HOSTS: + ctx.report.error(file, f"{url}: link to the destination, not a URL shortener") + + +def check_text(ctx: Context, entry: r.Entry, manifest: dict) -> None: + file = entry.manifest_path + fields = [("name", manifest.get("name")), ("summary", manifest.get("summary")), + ("description", manifest.get("description")), ("publisher.name", (manifest.get("publisher") or {}).get("name"))] + for label, value in fields: + if not isinstance(value, str): + continue + if unicodedata.normalize("NFC", value) != value: + ctx.report.error(file, f"{label}: normalize the text to NFC") + bad = r.unsafe_characters(value.replace("\n", "")) + if bad: + ctx.report.error(file, f"{label}: remove {', '.join(sorted(set(bad)))}", r.line_of(entry.text, [label.split('.')[-1]])) + + +def check_naming(ctx: Context, entry: r.Entry, manifest: dict) -> None: + file = entry.manifest_path + if manifest.get("tier") == "official": + return + reserved = ctx.curation.get("reserved.json") or {} + name = manifest.get("name", "") + if entry.slug in reserved.get("slugs", []): + ctx.report.error(file, f"slug `{entry.slug}` is reserved") + reserved_names = {r.normalized_name(n) for n in reserved.get("names", [])} + if r.normalized_name(name) in reserved_names: + ctx.report.error(file, f"name `{name}` is reserved", r.line_of(entry.text, ["name"])) + for fragment in reserved.get("nameFragments", []): + if r.contains_fragment(entry.slug, fragment): + ctx.report.error(file, f"slug may not contain `{fragment}`") + if r.contains_fragment(name, fragment): + ctx.report.error(file, f"name may not contain `{fragment}`: the directory already says it works with TablePro", + r.line_of(entry.text, ["name"])) + + +def check_tier(ctx: Context, entry: r.Entry, manifest: dict) -> None: + file = entry.manifest_path + tier = manifest.get("tier") + publisher = manifest.get("publisher") or {} + if tier == "official": + allowed = {(p["github"].lower(), p["githubId"]) for p in ctx.curated("official-publishers.json", "publishers")} + if (publisher.get("github", "").lower(), publisher.get("githubId")) not in allowed: + ctx.report.error(file, "tier official needs a publisher listed in curation/official-publishers.json", + r.line_of(entry.text, ["tier"])) + if tier == "partner": + rows = [p for p in ctx.curated("partners.json", "partners") if p["slug"] == entry.slug] + if not rows: + ctx.report.error(file, "tier partner needs a row in curation/partners.json", r.line_of(entry.text, ["tier"])) + elif rows[0]["githubId"] != publisher.get("githubId"): + ctx.report.error(file, "publisher does not match the verified partner in curation/partners.json") + + +def check_dates(ctx: Context, entry: r.Entry, manifest: dict) -> None: + file = entry.manifest_path + added = r.parse_date(manifest.get("addedAt")) + verified = r.parse_date(manifest.get("lastVerifiedAt")) + if added and added > ctx.today: + ctx.report.error(file, "addedAt is in the future", r.line_of(entry.text, ["addedAt"])) + if verified and verified > ctx.today: + ctx.report.error(file, "lastVerifiedAt is in the future", r.line_of(entry.text, ["lastVerifiedAt"])) + if added and verified and verified < added: + ctx.report.error(file, "lastVerifiedAt is before addedAt", r.line_of(entry.text, ["lastVerifiedAt"])) + status = manifest.get("status") or {} + if status.get("state") == "archived": + since = r.parse_date(status.get("since")) + if since and added and since < added: + ctx.report.error(file, "status.since is before addedAt") + replacement = status.get("replacement") + if replacement and replacement not in ctx.entries: + ctx.report.error(file, f"status.replacement `{replacement}` is not an entry") + grace = (manifest.get("compliance") or {}).get("grace") + if grace: + started = r.parse_date(grace.get("startedAt")) + ends = r.parse_date(grace.get("endsAt")) + if started and ends: + if ends <= started: + ctx.report.error(file, "compliance.grace.endsAt must be after startedAt") + elif (ends - started).days > r.GRACE_MAX_DAYS: + ctx.report.error(file, f"compliance.grace is {(ends - started).days} days; the policy allows {r.GRACE_MAX_DAYS}") + if ends < ctx.today: + ctx.report.warning(file, f"compliance.grace ended on {ends.isoformat()}: fix the items or archive the entry") + + +def check_consistency(ctx: Context, entry: r.Entry, manifest: dict) -> None: + file = entry.manifest_path + if manifest.get("slug") != entry.slug: + ctx.report.error(file, f"slug `{manifest.get('slug')}` must equal the folder name `{entry.slug}`", + r.line_of(entry.text, ["slug"])) + platforms = set(manifest.get("platforms") or []) + for key in (manifest.get("minTableProVersion") or {}): + if key not in platforms: + ctx.report.error(file, f"minTableProVersion.{key} is set but {key} is not in platforms") + source = manifest.get("source") or {} + install = manifest.get("install") or {} + if install.get("type") == "github-release" and source.get("type") == "github": + repo = "/".join(install["url"].split("/")[3:5]) + if repo.lower() != source.get("repo", "").lower(): + ctx.report.error(file, f"install.url points at {repo}, not the source repository {source.get('repo')}") + if install.get("type") == "github-release" and source.get("type") != "github": + ctx.report.error(file, "a github-release install needs a github source") + if install.get("type") == "page": + ctx.report.warning(file, "install type page needs a maintainer to approve the destination") + for url in r.collect_urls(manifest): + check_url(ctx, file, url) + + +def check_files(ctx: Context, entry: r.Entry, manifest: dict) -> None: + prefix = f"integrations/{entry.slug}/" + for path in sorted(entry.directory.iterdir()): + if path.is_symlink() or not path.is_file() or not r.ENTRY_FILE.match(path.name): + ctx.report.error(prefix + path.name, "only integration.json, icon.png and screenshot-1.png to screenshot-4.png belong in an entry folder") + icon = entry.directory / "icon.png" + if icon.is_file() and not icon.is_symlink(): + problems = r.check_png(icon, r.ICON_SIZE, r.ICON_MAX_BYTES) + for problem in problems: + ctx.report.error(prefix + "icon.png", "icon " + problem) + if not problems: + check_protected_mark(ctx, entry, manifest, icon) + else: + ctx.report.error(prefix + "icon.png", "icon.png is missing") + listed = [s.get("file") for s in manifest.get("screenshots") or []] + if len(set(listed)) != len(listed): + ctx.report.error(entry.manifest_path, "a screenshot is listed twice") + expected = [f"screenshot-{i}.png" for i in range(1, len(listed) + 1)] + if sorted(listed) != expected: + ctx.report.error(entry.manifest_path, "number screenshots from screenshot-1.png without gaps") + present = sorted(p.name for p in entry.directory.glob("screenshot-*.png")) + for name in sorted(set(present) - set(listed)): + ctx.report.error(prefix + name, "this screenshot is not listed in integration.json") + for name in listed: + path = entry.directory / name + if not path.is_file(): + ctx.report.error(prefix + name, "listed in integration.json but missing") + continue + for problem in r.check_png(path, r.SHOT_SIZE, r.SHOT_MAX_BYTES): + ctx.report.error(prefix + name, "screenshot " + problem) + + +def check_protected_mark(ctx: Context, entry: r.Entry, manifest: dict, icon) -> None: + if manifest.get("tier") == "official": + return + marks = ctx.curation.get("protected-marks.json") or {} + try: + digest = r.mark_hash(icon) + except Exception as exc: + ctx.report.error(f"integrations/{entry.slug}/icon.png", f"could not compare the icon with the TablePro marks: {exc}") + return + limit = marks.get("maxDistance", 0) + for mark in marks.get("marks", []): + distance = r.hash_distance(digest, mark["hash"]) + if distance <= limit: + ctx.report.error(f"integrations/{entry.slug}/icon.png", + f"icon looks like the TablePro mark `{mark['id']}` (distance {distance}). Use your own icon; a small TablePro badge in a corner is fine") + return + + +def check_cross(ctx: Context) -> None: + seen_names: dict = {} + seen_sources: dict = {} + seen_installs: dict = {} + removed = {row["slug"]: row for row in ctx.curated("removed.json", "removed")} + for slug, entry in ctx.entries.items(): + manifest = entry.manifest + if not isinstance(manifest, dict): + continue + file = entry.manifest_path + if slug in removed: + ctx.report.error(file, f"slug `{slug}` was removed on {removed[slug]['removedAt']} ({removed[slug]['reason']}) and cannot be reused") + key = r.normalized_name(manifest.get("name", "")) + if key in seen_names: + ctx.report.error(file, f"name duplicates the entry `{seen_names[key]}`") + seen_names.setdefault(key, slug) + source = manifest.get("source") or {} + built_in = (manifest.get("install") or {}).get("type") == "built-in" + if source.get("type") == "github" and not built_in: + source_key = (source.get("repoId"), source.get("path", "")) + if source_key in seen_sources: + ctx.report.error(file, f"source duplicates the entry `{seen_sources[source_key]}`") + seen_sources.setdefault(source_key, slug) + install = manifest.get("install") or {} + if install.get("type") and install.get("type") != "built-in": + target = r.install_url(install).lower() + if target in seen_installs: + ctx.report.error(file, f"install target duplicates the entry `{seen_installs[target]}`") + seen_installs.setdefault(target, slug) + changed = changed_slugs(ctx) + names = {slug: r.normalized_name((e.manifest or {}).get("name", "")) for slug, e in ctx.entries.items() + if isinstance(e.manifest, dict)} + for slug in changed: + for other, other_name in names.items(): + if other == slug or slug not in names: + continue + if 0 < r.levenshtein(names[slug], other_name) <= r.SIMILAR_NAME_DISTANCE: + ctx.report.warning(ctx.entries[slug].manifest_path, f"name is close to the entry `{other}`") + + +def changed_slugs(ctx: Context) -> list: + if not ctx.base: + return list(ctx.entries) + slugs = set() + for parts in ctx.changed_paths: + segments = parts[-1].split("/") + if len(segments) >= 2 and segments[0] == "integrations": + slugs.add(segments[1]) + return sorted(slugs) + + +def check_change_scope(ctx: Context) -> None: + if not ctx.base: + return + touched_entries = set() + other_paths = [] + for parts in ctx.changed_paths: + path = parts[-1] + segments = path.split("/") + if segments[0] == "integrations" and len(segments) >= 3: + touched_entries.add(segments[1]) + else: + other_paths.append(path) + removed_rows = {row["slug"] for row in ctx.curated("removed.json", "removed")} + for slug in sorted(touched_entries): + before = ctx.base_entries.get(slug) + entry = ctx.entries.get(slug) + file = f"integrations/{slug}/integration.json" + if entry is None: + if slug not in removed_rows: + ctx.report.error(file, "an entry was deleted without a tombstone in curation/removed.json; archive it instead, or add the tombstone") + if not ctx.maintainer: + ctx.report.error(file, "only maintainers remove entries; open a removal request issue") + continue + if before is None or not isinstance(entry.manifest, dict): + continue + after = entry.manifest + if before.get("addedAt") != after.get("addedAt"): + ctx.report.error(file, "addedAt never changes after the first merge", r.line_of(entry.text, ["addedAt"])) + for label, old, new in ( + ("source.repoId", (before.get("source") or {}).get("repoId"), (after.get("source") or {}).get("repoId")), + ("publisher.githubId", (before.get("publisher") or {}).get("githubId"), (after.get("publisher") or {}).get("githubId")), + ("tier", before.get("tier"), after.get("tier")), + ("compliance", before.get("compliance"), after.get("compliance")), + ("lastVerifiedAt", before.get("lastVerifiedAt"), after.get("lastVerifiedAt")), + ): + if old != new and not ctx.maintainer: + ctx.report.error(file, f"{label} changed; only a maintainer changes it") + added = [s for s in touched_entries if s in ctx.entries and s not in ctx.base_entries] + deleted = [s for s in touched_entries if s not in ctx.entries and s in ctx.base_entries] + if added and deleted: + ctx.report.error("integrations/", f"a slug cannot be renamed ({', '.join(deleted)} to {', '.join(added)}); slugs are permanent") + if not ctx.maintainer: + if len(touched_entries) > 1: + ctx.report.error("integrations/", "change one entry folder per pull request") + for path in other_paths: + ctx.report.error(path, "only maintainers change files outside integrations//") + + +def check_online(ctx: Context, gh: r.GitHub, slugs: list) -> None: + for slug in slugs: + entry = ctx.entries.get(slug) + if entry is None or not isinstance(entry.manifest, dict): + continue + manifest = entry.manifest + file = entry.manifest_path + publisher = manifest.get("publisher") or {} + status, user = gh.user(publisher.get("github", "")) + if status != 200: + ctx.report.error(file, f"GitHub account {publisher.get('github')} not found") + elif user.get("id") != publisher.get("githubId"): + ctx.report.error(file, f"publisher.githubId {publisher.get('githubId')} does not match {publisher.get('github')} ({user.get('id')})") + source = manifest.get("source") or {} + if source.get("type") == "github": + check_github_source(ctx, gh, entry, source, manifest) + check_ownership(ctx, gh, entry, source, publisher) + grace = (manifest.get("compliance") or {}).get("grace") + if grace: + parts = grace["tracking"].split("/") + status, issue = gh.issue(f"{parts[3]}/{parts[4]}", int(parts[6])) + if status != 200: + ctx.report.error(file, "compliance.grace.tracking issue not found") + check_install_target(ctx, entry, manifest) + + +def check_github_source(ctx: Context, gh: r.GitHub, entry: r.Entry, source: dict, manifest: dict) -> None: + file = entry.manifest_path + status, repo = gh.repository(source["repoId"]) + if status != 200: + ctx.report.error(file, f"repository id {source['repoId']} not found") + return + if repo["full_name"].lower() != source["repo"].lower(): + ctx.report.error(file, f"repository id {source['repoId']} is {repo['full_name']}, not {source['repo']}") + if repo.get("archived"): + ctx.report.error(file, f"{repo['full_name']} is archived on GitHub") + if repo.get("private"): + ctx.report.error(file, f"{repo['full_name']} is private") + if repo.get("fork") and entry.slug not in ctx.base_entries: + ctx.report.warning(file, f"{repo['full_name']} is a fork; the policy needs the original author's approval") + if source.get("path"): + status, _ = gh.contents(repo["full_name"], source["path"]) + if status != 200: + ctx.report.error(file, f"source.path {source['path']} not found in {repo['full_name']}") + return + status, license_info = gh.license(repo["full_name"]) + declared = manifest.get("license", "") + if status != 200: + ctx.report.error(file, f"{repo['full_name']} has no LICENSE file GitHub can find") + return + detected = ((license_info or {}).get("license") or {}).get("spdx_id") or "NOASSERTION" + if detected == "NOASSERTION": + ctx.report.warning(file, f"GitHub cannot identify the license of {repo['full_name']}; a maintainer checks it against {declared}") + elif detected not in (declared, declared.replace("-only", ""), declared.replace("-or-later", "")): + ctx.report.error(file, f"license is {declared} here but GitHub reports {detected}") + + +def check_ownership(ctx: Context, gh: r.GitHub, entry: r.Entry, source: dict, publisher: dict) -> None: + if ctx.maintainer or not ctx.author or entry.slug in ctx.base_entries: + return + author = ctx.author.lower() + if author == publisher.get("github", "").lower(): + return + if source.get("type") == "github": + owner = source["repo"].split("/")[0] + if author == owner.lower() or gh.public_member(owner, ctx.author): + return + status, contributors = gh.contributors(source["repo"]) + if status == 200 and contributors: + top = contributors[0].get("contributions", 0) + mine = next((c.get("contributions", 0) for c in contributors if c.get("login", "").lower() == author), 0) + if top and mine * 3 >= top: + return + ctx.needs_consent.append(entry.slug) + ctx.report.warning(entry.manifest_path, f"@{ctx.author} is not the publisher, the repository owner or a major contributor; the PR needs the owner's consent in a comment") + + +def check_install_target(ctx: Context, entry: r.Entry, manifest: dict) -> None: + install = manifest.get("install") or {} + kind = install.get("type") + url = None + if kind == "homebrew": + url = f"https://formulae.brew.sh/api/{install['kind']}/{install['token']}.json" + elif kind == "vscode-marketplace": + publisher, name = install["id"].split(".", 1) + if install.get("openVsx"): + status = r.http_status(f"https://open-vsx.org/api/{publisher}/{name}") + if status != 200: + ctx.report.error(entry.manifest_path, f"Open VSX has no {install['id']} (HTTP {status})") + url = r.install_url(install) + elif kind in ("raycast-store", "alfred-gallery", "app-store", "github-release"): + url = install["url"] + if url: + status = r.http_status(url) + if status != 200: + ctx.report.error(entry.manifest_path, f"install target {url} answered HTTP {status}") + + +def scan_sources(ctx: Context, slugs: list) -> None: + allow = {(a["slug"], a["file"], a["rule"]) for a in ctx.curated("scan-allowlist.json", "allow")} + for slug in slugs: + entry = ctx.entries.get(slug) + if entry is None or not isinstance(entry.manifest, dict): + continue + source = entry.manifest.get("source") or {} + if source.get("type") != "github" or entry.manifest.get("install", {}).get("type") == "built-in": + continue + with tempfile.TemporaryDirectory() as tmp: + try: + tree = r.fetch_source(source["repo"], source.get("path"), Path(tmp) / "src") + except Exception as exc: + ctx.report.warning(entry.manifest_path, f"could not fetch the source to scan it: {exc}") + continue + for file, line, rule, text in r.scan_tree(tree): + if (slug, file, rule) in allow: + continue + ctx.report.error(entry.manifest_path, + f"{source['repo']}/{source.get('path', '')} {file}:{line} reads TablePro's private storage ({rule}: `{text}`). Use a documented interface") + + +def validate_entries(ctx: Context, validator) -> None: + for slug, entry in ctx.entries.items(): + file = entry.manifest_path + if not r.SLUG.match(slug): + ctx.report.error(f"integrations/{slug}/", "folder name must be lowercase words joined by hyphens") + if entry.load_error: + ctx.report.error(file, entry.load_error) + continue + if r.schema_errors(validator, entry.manifest, entry.text, file, ctx.report): + check_files(ctx, entry, entry.manifest if isinstance(entry.manifest, dict) else {}) + continue + manifest = entry.manifest + check_consistency(ctx, entry, manifest) + check_text(ctx, entry, manifest) + check_naming(ctx, entry, manifest) + check_tier(ctx, entry, manifest) + check_dates(ctx, entry, manifest) + check_files(ctx, entry, manifest) + + +def run(root: Path, today: dt.date, base: Optional[str] = None, author: str = "", maintainer: bool = True, + online: bool = False, scan: bool = False, gh: Optional[r.GitHub] = None) -> Context: + report = r.Report() + r.check_schema_files(root, report) + curation = r.load_curation(root, report) + entries = r.load_entries(root) + ctx = Context(root, today, curation, entries, report, base, author, maintainer) + load_base(ctx) + validator = r.validator_for(root, r.INTEGRATION_SCHEMA) + validate_entries(ctx, validator) + check_cross(ctx) + check_change_scope(ctx) + if online: + check_online(ctx, gh or r.GitHub(), changed_slugs(ctx)) + if scan: + scan_sources(ctx, changed_slugs(ctx)) + return ctx + + +def emit(ctx: Context, annotations: bool) -> None: + for finding in ctx.report.findings: + if annotations: + message = finding.message.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + line = f",line={finding.line}" if finding.line else "" + print(f"::{finding.level} file={finding.file}{line}::{message}") + else: + where = f"{finding.file}:{finding.line}" if finding.line else finding.file + print(f"{finding.level}: {where}: {finding.message}") + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a", encoding="utf-8") as handle: + handle.write(f"### Registry validation\n\n{len(ctx.report.errors)} errors, {len(ctx.report.warnings)} warnings\n\n") + for finding in ctx.report.findings: + handle.write(f"- **{finding.level}** `{finding.file}`: {finding.message}\n") + + +def main(argv: Optional[list] = None) -> int: + parser = argparse.ArgumentParser(description="Validate the TablePro integrations registry.") + parser.add_argument("--root", default=str(Path(__file__).resolve().parent.parent)) + parser.add_argument("--base", help="git ref of the target branch; enables change-scope checks") + parser.add_argument("--author", default="", help="pull request author login") + parser.add_argument("--author-association", default="OWNER", help="GitHub author_association of the PR author") + parser.add_argument("--online", action="store_true", help="check GitHub, licenses, ownership and install targets") + parser.add_argument("--scan-sources", action="store_true", help="clone changed sources and scan for private-file access") + parser.add_argument("--urls-out", help="write every URL of the changed entries to this file") + parser.add_argument("--today", help="override today's date (YYYY-MM-DD)") + parser.add_argument("--annotations", action="store_true", help="print GitHub Actions annotations") + args = parser.parse_args(argv) + today = dt.date.fromisoformat(args.today) if args.today else dt.datetime.now(dt.timezone.utc).date() + ctx = run(Path(args.root), today, args.base, args.author, + args.author_association.upper() in MAINTAINER_ASSOCIATIONS, args.online, args.scan_sources) + if args.urls_out: + urls = set() + for slug in changed_slugs(ctx): + entry = ctx.entries.get(slug) + if entry and isinstance(entry.manifest, dict): + urls.update(r.collect_urls(entry.manifest)) + Path(args.urls_out).parent.mkdir(parents=True, exist_ok=True) + Path(args.urls_out).write_text("".join(u + "\n" for u in sorted(urls)), encoding="utf-8") + emit(ctx, args.annotations) + print(f"{len(ctx.entries)} entries, {len(ctx.report.errors)} errors, {len(ctx.report.warnings)} warnings") + return 1 if ctx.report.errors else 0 + + +if __name__ == "__main__": + sys.exit(main())