mirror of
https://github.com/penpot/penpot.git
synced 2026-02-10 14:43:17 -05:00
Compare commits
57 Commits
2.13.0-RC8
...
2.13.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06e5825c8a | ||
|
|
d30387eb77 | ||
|
|
33fd672c21 | ||
|
|
a7b2e98b8e | ||
|
|
d979894872 | ||
|
|
3d20fc508d | ||
|
|
ccfee34e76 | ||
|
|
6f3f2f9a71 | ||
|
|
ad5e8ccdb3 | ||
|
|
44c7d3fbd6 | ||
|
|
3d50aa6cb2 | ||
|
|
06afd94a74 | ||
|
|
e7d9dca55e | ||
|
|
c14ccc18b8 | ||
|
|
7d09d930fe | ||
|
|
0d9b7ca696 | ||
|
|
d215a5c402 | ||
|
|
f65292a13c | ||
|
|
94722fdec2 | ||
|
|
28509e0418 | ||
|
|
9569fa2bcb | ||
|
|
852b31c3a0 | ||
|
|
84b3f5d7c6 | ||
|
|
76bd31fe7d | ||
|
|
cc81e56d82 | ||
|
|
a9e2fc8d94 | ||
|
|
77bbf30ae4 | ||
|
|
693b52bf45 | ||
|
|
0f51b23ce7 | ||
|
|
ec61aa6b6d | ||
|
|
18aca16f98 | ||
|
|
c6465e27e3 | ||
|
|
1834a18263 | ||
|
|
d220d07875 | ||
|
|
9ca76c745f | ||
|
|
3112b240a0 | ||
|
|
56fd66b91a | ||
|
|
abc1773f65 | ||
|
|
93f5e74bb0 | ||
|
|
1ce0b60e3d | ||
|
|
38179ba11e | ||
|
|
ef80901400 | ||
|
|
719a95246a | ||
|
|
e590cd852d | ||
|
|
a9741073e5 | ||
|
|
5306bed548 | ||
|
|
92a319ddd1 | ||
|
|
68a6d4c9a8 | ||
|
|
f07495ae95 | ||
|
|
23d5fc7408 | ||
|
|
8632b18eec | ||
|
|
33e650242c | ||
|
|
e03ad25118 | ||
|
|
599656c31e | ||
|
|
5d7e6afd76 | ||
|
|
16f22a7b5c | ||
|
|
a1460115e8 |
@@ -45,6 +45,15 @@
|
||||
:potok/reify-type
|
||||
{:level :error}
|
||||
|
||||
:redundant-primitive-coercion
|
||||
{:level :off}
|
||||
|
||||
:unused-excluded-var
|
||||
{:level :off}
|
||||
|
||||
:unresolved-excluded-var
|
||||
{:level :off}
|
||||
|
||||
:missing-protocol-method
|
||||
{:level :off}
|
||||
|
||||
|
||||
38
.github/ISSUE_TEMPLATE/new-render-bug-report.md
vendored
Normal file
38
.github/ISSUE_TEMPLATE/new-render-bug-report.md
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: New Render Bug Report
|
||||
about: Create a report about the bugs you have found in the new render
|
||||
title: ''
|
||||
labels: new render
|
||||
assignees: claragvinola
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**Steps to Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots or screen recordings**
|
||||
If applicable, add screenshots or screen recording to help illustrate your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
2
.github/workflows/build-bundle.yml
vendored
2
.github/workflows/build-bundle.yml
vendored
@@ -40,7 +40,7 @@ on:
|
||||
jobs:
|
||||
build-bundle:
|
||||
name: Build and Upload Penpot Bundle
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-01
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
|
||||
7
.github/workflows/build-docker-devenv.yml
vendored
7
.github/workflows/build-docker-devenv.yml
vendored
@@ -7,9 +7,14 @@ jobs:
|
||||
build-and-push:
|
||||
name: Build and push DevEnv Docker image
|
||||
environment: release-admins
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
|
||||
steps:
|
||||
- name: Set common environment variables
|
||||
run: |
|
||||
# Each job execution will use its own docker configuration.
|
||||
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
|
||||
33
.github/workflows/build-docker.yml
vendored
33
.github/workflows/build-docker.yml
vendored
@@ -19,9 +19,14 @@ on:
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push Penpot Docker Images
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: penpot-runner-02
|
||||
|
||||
steps:
|
||||
- name: Set common environment variables
|
||||
run: |
|
||||
# Each job execution will use its own docker configuration.
|
||||
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -54,6 +59,7 @@ jobs:
|
||||
mv penpot/frontend bundle-frontend
|
||||
mv penpot/exporter bundle-exporter
|
||||
mv penpot/storybook bundle-storybook
|
||||
mv penpot/mcp bundle-mcp
|
||||
popd
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
@@ -66,6 +72,15 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
# To avoid the “429 Too Many Requests” error when downloading
|
||||
# images from DockerHub for unregistered users.
|
||||
# https://docs.docker.com/docker-hub/usage/
|
||||
- name: Login to DockerHub Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.PUB_DOCKER_USERNAME }}
|
||||
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -75,6 +90,7 @@ jobs:
|
||||
backend
|
||||
exporter
|
||||
storybook
|
||||
mcp
|
||||
labels: |
|
||||
bundle_version=${{ steps.bundles.outputs.bundle_version }}
|
||||
|
||||
@@ -138,6 +154,21 @@ jobs:
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache
|
||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
||||
|
||||
- name: Build and push MCP Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
DOCKER_IMAGE: 'mcp'
|
||||
BUNDLE_PATH: './bundle-mcp'
|
||||
with:
|
||||
context: ./docker/images/
|
||||
file: ./docker/images/Dockerfile.mcp
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:${{ steps.vars.outputs.gh_ref }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache
|
||||
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE }}:buildcache,mode=max
|
||||
|
||||
- name: Notify Mattermost
|
||||
if: failure()
|
||||
uses: mattermost/action-mattermost-notify@master
|
||||
|
||||
2
.github/workflows/build-tag.yml
vendored
2
.github/workflows/build-tag.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||
TEXT: |
|
||||
🐳 *[PENPOT] Docker image available: {{ github.ref_name }}*
|
||||
🐳 *[PENPOT] Docker image available: ${{ github.ref_name }}*
|
||||
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
@infra
|
||||
|
||||
|
||||
2
.github/workflows/commit-checker.yml
vendored
2
.github/workflows/commit-checker.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Check Commit Type
|
||||
uses: gsactions/commit-message-checker@v2
|
||||
with:
|
||||
pattern: '^(((:(lipstick|globe_with_meridians|wrench|books|arrow_up|arrow_down|zap|ambulance|construction|boom|fire|whale|bug|sparkles|paperclip|tada|recycle|rewind|construction_worker):)\s[A-Z].*[^.])|(Merge|Revert).+[^.])$'
|
||||
pattern: '^(((:(lipstick|globe_with_meridians|wrench|books|arrow_up|arrow_down|zap|ambulance|construction|boom|fire|whale|bug|sparkles|paperclip|tada|recycle|rewind|construction_worker):)\s[A-Z].*[^.])|(Merge|Revert|Reapply).+[^.])$'
|
||||
flags: 'gm'
|
||||
error: 'Commit should match CONTRIBUTING.md guideline'
|
||||
checkAllCommitMessages: 'true' # optional: this checks all commits associated with a pull request
|
||||
|
||||
42
.github/workflows/plugins-deploy-api-doc.yml
vendored
42
.github/workflows/plugins-deploy-api-doc.yml
vendored
@@ -7,11 +7,11 @@ on:
|
||||
- staging
|
||||
- main
|
||||
paths:
|
||||
- "plugins/libs/plugin-types/index.d.ts"
|
||||
- "plugins/libs/plugin-types/REAME.md"
|
||||
- "plugins/tools/typedoc.css"
|
||||
- "plugins/CHANGELOG.md"
|
||||
- "plugins/wrangle-penpot-plugins-api-doc.toml"
|
||||
- 'plugins/libs/plugin-types/index.d.ts'
|
||||
- 'plugins/libs/plugin-types/REAME.md'
|
||||
- 'plugins/tools/typedoc.css'
|
||||
- 'plugins/CHANGELOG.md'
|
||||
- 'plugins/wrangler-penpot-plugins-api-doc.toml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
gh_ref:
|
||||
@@ -86,16 +86,40 @@ jobs:
|
||||
run: |
|
||||
REF="${{ steps.vars.outputs.gh_ref }}"
|
||||
case "$REF" in
|
||||
main) echo "WORKER_NAME=penpot-plugins-api-doc-pro" >> $GITHUB_ENV ;;
|
||||
staging) echo "WORKER_NAME=penpot-plugins-api-doc-pre" >> $GITHUB_ENV ;;
|
||||
develop) echo "WORKER_NAME=penpot-plugins-api-doc-hourly" >> $GITHUB_ENV ;;
|
||||
main)
|
||||
echo "WORKER_NAME=penpot-plugins-api-doc-pro" >> $GITHUB_ENV
|
||||
echo "WORKER_URI=doc.plugins.penpot.app" >> $GITHUB_ENV ;;
|
||||
staging)
|
||||
echo "WORKER_NAME=penpot-plugins-api-doc-pre" >> $GITHUB_ENV
|
||||
echo "WORKER_URI=doc.plugins.penpot.dev" >> $GITHUB_ENV ;;
|
||||
develop)
|
||||
echo "WORKER_NAME=penpot-plugins-api-doc-hourly" >> $GITHUB_ENV
|
||||
echo "WORKER_URI=doc.plugins.hourly.penpot.dev" >> $GITHUB_ENV ;;
|
||||
*) echo "Unsupported branch ${REF}" && exit 1 ;;
|
||||
esac
|
||||
|
||||
- name: Set the custom url
|
||||
working-directory: plugins
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" wrangler-penpot-plugins-api-doc.toml
|
||||
|
||||
- name: Deploy to Cloudflare Workers
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
workingDirectory: plugins
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
command: deploy --config wrangle-penpot-plugins-api-doc.toml --name ${{ env.WORKER_NAME }}
|
||||
command: deploy --config wrangler-penpot-plugins-api-doc.toml --name ${{ env.WORKER_NAME }}
|
||||
|
||||
- name: Notify Mattermost
|
||||
if: failure()
|
||||
uses: mattermost/action-mattermost-notify@master
|
||||
with:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||
TEXT: |
|
||||
❌ 🧩📚 *[PENPOT PLUGINS] Error deploying API documentation.*
|
||||
📄 Triggered from ref: `${{ inputs.gh_ref }}`
|
||||
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
@infra
|
||||
|
||||
127
.github/workflows/plugins-deploy-package.yml
vendored
Normal file
127
.github/workflows/plugins-deploy-package.yml
vendored
Normal file
@@ -0,0 +1,127 @@
|
||||
name: Plugins/package deployer
|
||||
|
||||
on:
|
||||
# Deploy package from manual action
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
gh_ref:
|
||||
description: 'Name of the branch'
|
||||
type: choice
|
||||
required: true
|
||||
default: 'develop'
|
||||
options:
|
||||
- develop
|
||||
- staging
|
||||
- main
|
||||
plugin_name:
|
||||
description: 'Pluging name (like plugins/apps/<plugin_name>-plugin)'
|
||||
type: string
|
||||
required: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
gh_ref:
|
||||
description: 'Name of the branch'
|
||||
type: string
|
||||
required: true
|
||||
default: 'develop'
|
||||
plugin_name:
|
||||
description: 'Publig name (from plugins/apps/<plugin_name>-plugin)'
|
||||
type: string
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: penpot-runner-01
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.gh_ref }}
|
||||
|
||||
# START: Setup Node and PNPM enabling cache
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable PNPM
|
||||
working-directory: ./plugins
|
||||
shell: bash
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
|
||||
- name: Get pnpm store path
|
||||
id: pnpm-store
|
||||
working-directory: ./plugins
|
||||
shell: bash
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-
|
||||
# END: Setup Node and PNPM enabling cache
|
||||
|
||||
- name: Install deps
|
||||
working-directory: ./plugins
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm install --no-frozen-lockfile;
|
||||
pnpm add -D -w wrangler@latest;
|
||||
|
||||
- name: "Build package for ${{ inputs.plugin_name }}-plugin"
|
||||
working-directory: plugins
|
||||
shell: bash
|
||||
run: npx nx build ${{ inputs.plugin_name }}-plugin
|
||||
|
||||
- name: Select Worker name
|
||||
run: |
|
||||
REF="${{ inputs.gh_ref }}"
|
||||
case "$REF" in
|
||||
main)
|
||||
echo "WORKER_NAME=${{ inputs.plugin_name }}-plugin-pro" >> $GITHUB_ENV
|
||||
echo "WORKER_URI=${{ inputs.plugin_name }}.plugins.penpot.app" >> $GITHUB_ENV ;;
|
||||
staging)
|
||||
echo "WORKER_NAME=${{ inputs.plugin_name }}-plugin-pre" >> $GITHUB_ENV
|
||||
echo "WORKER_URI=${{ inputs.plugin_name }}.plugins.penpot.dev" >> $GITHUB_ENV ;;
|
||||
develop)
|
||||
echo "WORKER_NAME=${{ inputs.plugin_name }}-plugin-hourly" >> $GITHUB_ENV
|
||||
echo "WORKER_URI=${{ inputs.plugin_name }}.plugins.hourly.penpot.dev" >> $GITHUB_ENV ;;
|
||||
*) echo "Unsupported branch ${REF}" && exit 1 ;;
|
||||
esac
|
||||
|
||||
- name: Set the custom url
|
||||
working-directory: plugins
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" apps/${{ inputs.plugin_name }}-plugin/wrangler.toml
|
||||
|
||||
- name: Deploy to Cloudflare Workers
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
workingDirectory: plugins
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
command: deploy --config apps/${{ inputs.plugin_name }}-plugin/wrangler.toml --name ${{ env.WORKER_NAME }}
|
||||
|
||||
- name: Notify Mattermost
|
||||
if: failure()
|
||||
uses: mattermost/action-mattermost-notify@master
|
||||
with:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||
TEXT: |
|
||||
❌ 🧩📦 *[PENPOT PLUGINS] Error deploying ${{ env.WORKER_NAME }}.*
|
||||
📄 Triggered from ref: `${{ inputs.gh_ref }}`
|
||||
Plugin name: `${{ inputs.plugin_name }}-plugin`
|
||||
Cloudflare worker name: `${{ env.WORKER_NAME }}`
|
||||
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
@infra
|
||||
143
.github/workflows/plugins-deploy-packages.yml
vendored
Normal file
143
.github/workflows/plugins-deploy-packages.yml
vendored
Normal file
@@ -0,0 +1,143 @@
|
||||
name: Plugins/packages deployer
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- staging
|
||||
- main
|
||||
paths:
|
||||
- 'plugins/apps/*-plugin/**'
|
||||
- 'libs/plugins-styles/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
gh_ref:
|
||||
description: 'Name of the branch'
|
||||
type: choice
|
||||
required: true
|
||||
default: 'develop'
|
||||
options:
|
||||
- develop
|
||||
- staging
|
||||
- main
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
colors_to_tokens: ${{ steps.filter.outputs.colors_to_tokens }}
|
||||
create_palette: ${{ steps.filter.outputs.create_palette }}
|
||||
lorem_ipsum: ${{ steps.filter.outputs.lorem_ipsum }}
|
||||
rename_layers: ${{ steps.filter.outputs.rename_layers }}
|
||||
contrast: ${{ steps.filter.outputs.contrast }}
|
||||
icons: ${{ steps.filter.outputs.icons }}
|
||||
poc_state: ${{ steps.filter.outputs.poc_state }}
|
||||
table: ${{ steps.filter.outputs.table }}
|
||||
# [For new plugins]
|
||||
# Add more outputs here
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- id: filter
|
||||
uses: dorny/paths-filter@v3
|
||||
with:
|
||||
filters: |
|
||||
colors_to_tokens:
|
||||
- 'plugins/apps/colors-to-tokens-plugin/**'
|
||||
- 'libs/plugins-styles/**'
|
||||
contrast:
|
||||
- 'plugins/apps/contrast-plugin/**'
|
||||
- 'libs/plugins-styles/**'
|
||||
create_palette:
|
||||
- 'plugins/apps/create-palette-plugin/**'
|
||||
- 'libs/plugins-styles/**'
|
||||
icons:
|
||||
- 'plugins/apps/icons-plugin/**'
|
||||
- 'libs/plugins-styles/**'
|
||||
lorem_ipsum:
|
||||
- 'plugins/apps/lorem-ipsum-plugin/**'
|
||||
- 'libs/plugins-styles/**'
|
||||
rename_layers:
|
||||
- 'plugins/apps/rename-layers-plugin/**'
|
||||
- 'libs/plugins-styles/**'
|
||||
table:
|
||||
- 'plugins/apps/table-plugin/**'
|
||||
- 'libs/plugins-styles/**'
|
||||
# [For new plugins]
|
||||
# Add more plugin filters here
|
||||
# another_plugin:
|
||||
# - 'plugins/apps/another-plugin/**'
|
||||
# - 'libs/plugins-styles/**'
|
||||
|
||||
colors-to-tokens-plugin:
|
||||
needs: detect-changes
|
||||
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.colors_to_tokens == 'true'
|
||||
uses: ./.github/workflows/plugins-deploy-package.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
|
||||
plugin_name: colors-to-tokens
|
||||
|
||||
contrast-plugin:
|
||||
needs: detect-changes
|
||||
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.contrast == 'true'
|
||||
uses: ./.github/workflows/plugins-deploy-package.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
|
||||
plugin_name: contrast
|
||||
|
||||
create-palette-plugin:
|
||||
needs: detect-changes
|
||||
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.create_palette == 'true'
|
||||
uses: ./.github/workflows/plugins-deploy-package.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
|
||||
plugin_name: create-palette
|
||||
|
||||
icons-plugin:
|
||||
needs: detect-changes
|
||||
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.icons == 'true'
|
||||
uses: ./.github/workflows/plugins-deploy-package.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
|
||||
plugin_name: icons
|
||||
|
||||
lorem-ipsum-plugin:
|
||||
needs: detect-changes
|
||||
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.lorem_ipsum == 'true'
|
||||
uses: ./.github/workflows/plugins-deploy-package.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
|
||||
plugin_name: lorem-ipsum
|
||||
|
||||
rename-layers-plugin:
|
||||
needs: detect-changes
|
||||
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.rename_layers == 'true'
|
||||
uses: ./.github/workflows/plugins-deploy-package.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
|
||||
plugin_name: rename-layers
|
||||
|
||||
table-plugin:
|
||||
needs: detect-changes
|
||||
if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.table == 'true'
|
||||
uses: ./.github/workflows/plugins-deploy-package.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
|
||||
plugin_name: table
|
||||
|
||||
# [For new plugins]
|
||||
# Add more jobs for other plugins below, following the same pattern
|
||||
# another-plugin:
|
||||
# needs: detect-changes
|
||||
# if: github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.another_plugin == 'true'
|
||||
# uses: ./.github/workflows/plugins-deploy-package.yml
|
||||
# secrets: inherit
|
||||
# with:
|
||||
# gh_ref: "${{ inputs.gh_ref || github.ref_name }}"
|
||||
# plugin_name: another
|
||||
123
.github/workflows/plugins-deploy-styles-doc.yml
vendored
Normal file
123
.github/workflows/plugins-deploy-styles-doc.yml
vendored
Normal file
@@ -0,0 +1,123 @@
|
||||
name: Plugins/styles-doc deployer
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- staging
|
||||
- main
|
||||
paths:
|
||||
- 'plugins/apps/example-styles/**'
|
||||
- 'plugins/libs/plugins-styles/**'
|
||||
- 'plugins/wrangler-penpot-plugins-styles-doc.toml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
gh_ref:
|
||||
description: 'Name of the branch'
|
||||
type: choice
|
||||
required: true
|
||||
default: 'develop'
|
||||
options:
|
||||
- develop
|
||||
- staging
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Extract some useful variables
|
||||
id: vars
|
||||
run: |
|
||||
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ steps.vars.outputs.gh_ref }}
|
||||
|
||||
# START: Setup Node and PNPM enabling cache
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable PNPM
|
||||
working-directory: ./plugins
|
||||
shell: bash
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
|
||||
- name: Get pnpm store path
|
||||
id: pnpm-store
|
||||
working-directory: ./plugins
|
||||
shell: bash
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-
|
||||
# END: Setup Node and PNPM enabling cache
|
||||
|
||||
- name: Install deps
|
||||
working-directory: ./plugins
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm install --no-frozen-lockfile;
|
||||
pnpm add -D -w wrangler@latest;
|
||||
|
||||
- name: Build styles
|
||||
working-directory: plugins
|
||||
shell: bash
|
||||
run: npx nx run example-styles:build
|
||||
|
||||
- name: Select Worker name
|
||||
run: |
|
||||
REF="${{ steps.vars.outputs.gh_ref }}"
|
||||
case "$REF" in
|
||||
main)
|
||||
echo "WORKER_NAME=penpot-plugins-styles-doc-pro" >> $GITHUB_ENV
|
||||
echo "WORKER_URI=styles-doc.plugins.penpot.app" >> $GITHUB_ENV ;;
|
||||
staging)
|
||||
echo "WORKER_NAME=penpot-plugins-styles-doc-pre" >> $GITHUB_ENV
|
||||
echo "WORKER_URI=styles-doc.plugins.penpot.dev" >> $GITHUB_ENV ;;
|
||||
develop)
|
||||
echo "WORKER_NAME=penpot-plugins-styles-doc-hourly" >> $GITHUB_ENV
|
||||
echo "WORKER_URI=styles-doc.plugins.hourly.penpot.dev" >> $GITHUB_ENV ;;
|
||||
*) echo "Unsupported branch ${REF}" && exit 1 ;;
|
||||
esac
|
||||
|
||||
- name: Set the custom url
|
||||
working-directory: plugins
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i "s/WORKER_URI/${{ env.WORKER_URI }}/g" wrangler-penpot-plugins-styles-doc.toml
|
||||
|
||||
- name: Deploy to Cloudflare Workers
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
workingDirectory: plugins
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
command: deploy --config wrangler-penpot-plugins-styles-doc.toml --name ${{ env.WORKER_NAME }}
|
||||
|
||||
- name: Notify Mattermost
|
||||
if: failure()
|
||||
uses: mattermost/action-mattermost-notify@master
|
||||
with:
|
||||
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
|
||||
MATTERMOST_CHANNEL: bot-alerts-cicd
|
||||
TEXT: |
|
||||
❌ 🧩💅 *[PENPOT PLUGINS] Error deploying Styles documentation.*
|
||||
📄 Triggered from ref: `${{ inputs.gh_ref }}`
|
||||
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
@infra
|
||||
45
.github/workflows/tests-mcp.yml
vendored
Normal file
45
.github/workflows/tests-mcp.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
name: "MCP CI"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
- staging
|
||||
- main
|
||||
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
|
||||
paths:
|
||||
- 'mcp/**'
|
||||
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- staging
|
||||
- main
|
||||
|
||||
paths:
|
||||
- 'mcp/**'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: "Test"
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup
|
||||
working-directory: ./mcp
|
||||
run: ./scripts/setup
|
||||
|
||||
- name: Check
|
||||
working-directory: ./mcp
|
||||
run: |
|
||||
pnpm run fmt:check;
|
||||
pnpm -r run build;
|
||||
pnpm -r run types:check;
|
||||
25
.github/workflows/tests.yml
vendored
25
.github/workflows/tests.yml
vendored
@@ -21,7 +21,7 @@ concurrency:
|
||||
jobs:
|
||||
lint:
|
||||
name: "Linter"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
|
||||
test-common:
|
||||
name: "Common Tests"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
@@ -53,7 +53,8 @@ jobs:
|
||||
|
||||
test-plugins:
|
||||
name: Plugins Runtime Linter & Tests
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -98,7 +99,7 @@ jobs:
|
||||
|
||||
test-frontend:
|
||||
name: "Frontend Tests"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
@@ -119,7 +120,7 @@ jobs:
|
||||
|
||||
test-render-wasm:
|
||||
name: "Render WASM Tests"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
@@ -143,7 +144,7 @@ jobs:
|
||||
|
||||
test-backend:
|
||||
name: "Backend Tests"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
services:
|
||||
@@ -182,7 +183,7 @@ jobs:
|
||||
|
||||
test-library:
|
||||
name: "Library Tests"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
@@ -196,7 +197,7 @@ jobs:
|
||||
|
||||
build-integration:
|
||||
name: "Build Integration Bundle"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
@@ -217,7 +218,7 @@ jobs:
|
||||
|
||||
test-integration-1:
|
||||
name: "Integration Tests 1/4"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
needs: build-integration
|
||||
|
||||
@@ -247,7 +248,7 @@ jobs:
|
||||
|
||||
test-integration-2:
|
||||
name: "Integration Tests 2/4"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
needs: build-integration
|
||||
|
||||
@@ -277,7 +278,7 @@ jobs:
|
||||
|
||||
test-integration-3:
|
||||
name: "Integration Tests 3/4"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
needs: build-integration
|
||||
|
||||
@@ -307,7 +308,7 @@ jobs:
|
||||
|
||||
test-integration-4:
|
||||
name: "Integration Tests 4/4"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: penpot-runner-02
|
||||
container: penpotapp/devenv:latest
|
||||
needs: build-integration
|
||||
|
||||
|
||||
17
CHANGES.md
17
CHANGES.md
@@ -1,10 +1,12 @@
|
||||
# CHANGELOG
|
||||
|
||||
## 2.13.0 (Unreleased)
|
||||
## 2.13.1
|
||||
|
||||
### :boom: Breaking changes & Deprecations
|
||||
### :bug: Bugs fixed
|
||||
|
||||
### :rocket: Epics and highlights
|
||||
- Fix PDF Exporter outputs empty page when board has A4 format [Taiga #13181](https://tree.taiga.io/project/penpot/issue/13181)
|
||||
|
||||
## 2.13.0
|
||||
|
||||
### :heart: Community contributions (Thank you!)
|
||||
|
||||
@@ -32,6 +34,15 @@
|
||||
- Fix unhandled exception tokens creation dialog [Github #8110](https://github.com/penpot/penpot/issues/8110)
|
||||
- Fix allow negative spread values on shadow token creation [Taiga #13167](https://tree.taiga.io/project/penpot/issue/13167)
|
||||
- Fix spanish translations on import export token modal [Taiga #13171](https://tree.taiga.io/project/penpot/issue/13171)
|
||||
- Remove whitespaces from asset export filename [Github #8133](https://github.com/penpot/penpot/pull/8133)
|
||||
- Fix exception on uploading large fonts [Github #8135](https://github.com/penpot/penpot/pull/8135)
|
||||
- Fix unhandled exception on open-new-window helper [Github #7787](https://github.com/penpot/penpot/issues/7787)
|
||||
- Fix incorrect handling of input values on layout gap and padding inputs [Github #8113](https://github.com/penpot/penpot/issues/8113)
|
||||
- Fix several race conditions on path editor [Github #8187](https://github.com/penpot/penpot/pull/8187)
|
||||
- Fix app freeze when introducing an error on a very long token name [Taiga #13214](https://tree.taiga.io/project/penpot/issue/13214)
|
||||
- Fix import a file with shadow tokens [Taiga #13229](https://tree.taiga.io/project/penpot/issue/13229)
|
||||
- Fix allow spaces on token description [Taiga #13184](https://tree.taiga.io/project/penpot/issue/13184)
|
||||
- Fix error when creating a token with an invalid name [Taiga #13219](https://tree.taiga.io/project/penpot/issue/13219)
|
||||
|
||||
## 2.12.1
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
:deps
|
||||
{penpot/common {:local/root "../common"}
|
||||
org.clojure/clojure {:mvn/version "1.12.2"}
|
||||
org.clojure/clojure {:mvn/version "1.12.4"}
|
||||
org.clojure/tools.namespace {:mvn/version "1.5.0"}
|
||||
|
||||
com.github.luben/zstd-jni {:mvn/version "1.5.7-4"}
|
||||
@@ -28,8 +28,8 @@
|
||||
com.google.guava/guava {:mvn/version "33.4.8-jre"}
|
||||
|
||||
funcool/yetti
|
||||
{:git/tag "v11.8"
|
||||
:git/sha "1d1b33f"
|
||||
{:git/tag "v11.9"
|
||||
:git/sha "5fad7a9"
|
||||
:git/url "https://github.com/funcool/yetti.git"
|
||||
:exclusions [org.slf4j/slf4j-api]}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
metosin/reitit-core {:mvn/version "0.9.1"}
|
||||
nrepl/nrepl {:mvn/version "1.4.0"}
|
||||
|
||||
org.postgresql/postgresql {:mvn/version "42.7.7"}
|
||||
org.postgresql/postgresql {:mvn/version "42.7.9"}
|
||||
org.xerial/sqlite-jdbc {:mvn/version "3.50.3.0"}
|
||||
|
||||
com.zaxxer/HikariCP {:mvn/version "7.0.2"}
|
||||
@@ -49,7 +49,7 @@
|
||||
buddy/buddy-hashers {:mvn/version "2.0.167"}
|
||||
buddy/buddy-sign {:mvn/version "3.6.1-359"}
|
||||
|
||||
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.2"}
|
||||
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.3"}
|
||||
|
||||
org.jsoup/jsoup {:mvn/version "1.21.2"}
|
||||
org.im4java/im4java
|
||||
@@ -66,7 +66,7 @@
|
||||
|
||||
;; Pretty Print specs
|
||||
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.33.10"}}
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.41.21"}}
|
||||
|
||||
:paths ["src" "resources" "target/classes"]
|
||||
:aliases
|
||||
|
||||
@@ -873,11 +873,8 @@
|
||||
(import-storage-objects cfg)
|
||||
|
||||
(let [files (get manifest :files)
|
||||
result (reduce (fn [result {:keys [id] :as file}]
|
||||
result (reduce (fn [result file]
|
||||
(let [name' (get file :name)
|
||||
name' (if (map? name)
|
||||
(get name id)
|
||||
name')
|
||||
file (assoc file :name name')]
|
||||
(conj result (import-file cfg file))))
|
||||
[]
|
||||
|
||||
@@ -35,8 +35,7 @@
|
||||
javax.xml.parsers.SAXParserFactory
|
||||
org.apache.commons.io.IOUtils
|
||||
org.im4java.core.ConvertCmd
|
||||
org.im4java.core.IMOperation
|
||||
org.im4java.core.Info))
|
||||
org.im4java.core.IMOperation))
|
||||
|
||||
(def default-max-file-size
|
||||
(* 1024 1024 10)) ; 10 MiB
|
||||
@@ -224,17 +223,18 @@
|
||||
;; If we are processing an animated gif we use the first frame with -scene 0
|
||||
(let [dim-result (sh/sh "identify" "-format" "%w %h\n" path)
|
||||
orient-result (sh/sh "identify" "-format" "%[EXIF:Orientation]\n" path)]
|
||||
(if (and (= 0 (:exit dim-result))
|
||||
(= 0 (:exit orient-result)))
|
||||
(when (= 0 (:exit dim-result))
|
||||
(let [[w h] (-> (:out dim-result)
|
||||
str/trim
|
||||
(clojure.string/split #"\s+")
|
||||
(->> (mapv #(Integer/parseInt %))))
|
||||
orientation (-> orient-result :out str/trim)]
|
||||
(case orientation
|
||||
("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees
|
||||
{:width w :height h})) ; Normal or unknown orientation
|
||||
nil)))
|
||||
orientation-exit (:exit orient-result)
|
||||
orientation (-> orient-result :out str/trim)]
|
||||
(if (= 0 orientation-exit)
|
||||
(case orientation
|
||||
("6" "8") {:width h :height w} ; Rotated 90 or 270 degrees
|
||||
{:width w :height h}) ; Normal or unknown orientation
|
||||
{:width w :height h}))))) ; If orientation can't be read, use dimensions as-is
|
||||
|
||||
(defmethod process :info
|
||||
[{:keys [input] :as params}]
|
||||
@@ -247,26 +247,37 @@
|
||||
:hint "uploaded svg does not provides dimensions"))
|
||||
(merge input info {:ts (ct/now) :size (fs/size path)}))
|
||||
|
||||
(let [instance (Info. (str path))
|
||||
mtype' (.getProperty instance "Mime type")]
|
||||
(let [path-str (str path)
|
||||
identify-res (sh/sh "identify" "-format" "image/%[magick]\n" path-str)
|
||||
;; identify prints one line per frame (animated GIFs, etc.); we take the first one
|
||||
mtype' (if (zero? (:exit identify-res))
|
||||
(-> identify-res
|
||||
:out
|
||||
str/trim
|
||||
(str/split #"\s+" 2)
|
||||
first
|
||||
str/lower)
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-image
|
||||
:hint "invalid image"))
|
||||
{:keys [width height]}
|
||||
(or (get-dimensions-with-orientation path-str)
|
||||
(do
|
||||
(l/warn "Failed to read image dimensions with orientation" {:path path})
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-image
|
||||
:hint "invalid image")))]
|
||||
(when (and (string? mtype)
|
||||
(not= mtype mtype'))
|
||||
(not= (str/lower mtype) mtype'))
|
||||
(ex/raise :type :validation
|
||||
:code :media-type-mismatch
|
||||
:hint (str "Seems like you are uploading a file whose content does not match the extension."
|
||||
"Expected: " mtype ". Got: " mtype')))
|
||||
(let [{:keys [width height]}
|
||||
(or (get-dimensions-with-orientation (str path))
|
||||
(do
|
||||
(l/warn "Failed to read image dimensions with orientation; falling back to im4java"
|
||||
{:path path})
|
||||
{:width (.getPageWidth instance)
|
||||
:height (.getPageHeight instance)}))]
|
||||
(assoc input
|
||||
:width width
|
||||
:height height
|
||||
:size (fs/size path)
|
||||
:ts (ct/now)))))))
|
||||
(assoc input
|
||||
:width width
|
||||
:height height
|
||||
:size (fs/size path)
|
||||
:ts (ct/now))))))
|
||||
|
||||
(defmethod process-error org.im4java.core.InfoException
|
||||
[error]
|
||||
|
||||
@@ -27,7 +27,17 @@
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.rpc.quotes :as quotes]
|
||||
[app.storage :as sto]
|
||||
[app.util.services :as sv]))
|
||||
[app.storage.tmp :as tmp]
|
||||
[app.util.services :as sv]
|
||||
[datoteka.io :as io])
|
||||
(:import
|
||||
java.io.InputStream
|
||||
java.io.OutputStream
|
||||
java.io.SequenceInputStream
|
||||
java.util.Collections))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
|
||||
(def valid-weight #{100 200 300 400 500 600 700 800 900 950})
|
||||
(def valid-style #{"normal" "italic"})
|
||||
@@ -79,7 +89,8 @@
|
||||
(def ^:private schema:create-font-variant
|
||||
[:map {:title "create-font-variant"}
|
||||
[:team-id ::sm/uuid]
|
||||
[:data [:map-of ::sm/text ::sm/any]]
|
||||
[:data [:map-of ::sm/text [:or ::sm/bytes
|
||||
[::sm/vec ::sm/bytes]]]]
|
||||
[:font-id ::sm/uuid]
|
||||
[:font-family ::sm/text]
|
||||
[:font-weight [::sm/one-of {:format "number"} valid-weight]]
|
||||
@@ -105,7 +116,7 @@
|
||||
|
||||
(defn create-font-variant
|
||||
[{:keys [::sto/storage ::db/conn]} {:keys [data] :as params}]
|
||||
(letfn [(generate-missing! [data]
|
||||
(letfn [(generate-missing [data]
|
||||
(let [data (media/run {:cmd :generate-fonts :input data})]
|
||||
(when (and (not (contains? data "font/otf"))
|
||||
(not (contains? data "font/ttf"))
|
||||
@@ -116,8 +127,26 @@
|
||||
:hint "invalid font upload, unable to generate missing font assets"))
|
||||
data))
|
||||
|
||||
(process-chunks [chunks]
|
||||
(let [tmp (tmp/tempfile :prefix "penpot.tempfont." :suffix "")
|
||||
streams (map io/input-stream chunks)
|
||||
streams (Collections/enumeration streams)]
|
||||
(with-open [^OutputStream output (io/output-stream tmp)
|
||||
^InputStream input (SequenceInputStream. streams)]
|
||||
(io/copy input output))
|
||||
tmp))
|
||||
|
||||
(join-chunks [data]
|
||||
(reduce-kv (fn [data mtype content]
|
||||
(if (vector? content)
|
||||
(assoc data mtype (process-chunks content))
|
||||
data))
|
||||
data
|
||||
data))
|
||||
|
||||
(prepare-font [data mtype]
|
||||
(when-let [resource (get data mtype)]
|
||||
|
||||
(let [hash (sto/calculate-hash resource)
|
||||
content (-> (sto/content resource)
|
||||
(sto/wrap-with-hash hash))]
|
||||
@@ -156,7 +185,8 @@
|
||||
:otf-file-id (:id otf)
|
||||
:ttf-file-id (:id ttf)}))]
|
||||
|
||||
(let [data (generate-missing! data)
|
||||
(let [data (join-chunks data)
|
||||
data (generate-missing data)
|
||||
assets (persist-fonts-files! data)
|
||||
result (insert-font-variant! assets)]
|
||||
(vary-meta result assoc ::audit/replace-props (update params :data (comp vec keys))))))
|
||||
|
||||
@@ -275,3 +275,30 @@
|
||||
(let [res (th/run-task! :storage-gc-touched {})]
|
||||
(t/is (= 0 (:freeze res)))
|
||||
(t/is (= 3 (:delete res)))))))
|
||||
|
||||
(t/deftest input-sanitization-1
|
||||
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
proj-id (:default-project-id prof)
|
||||
font-id (uuid/custom 10 1)
|
||||
|
||||
ttfdata (-> (io/resource "backend_tests/test_files/font-1.ttf")
|
||||
(io/read*))
|
||||
|
||||
params {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id
|
||||
:font-id font-id
|
||||
:font-family "somefont"
|
||||
:font-weight 400
|
||||
:font-style "normal"
|
||||
:data {"font/ttf" "/etc/passwd"}}
|
||||
out (th/command! params)]
|
||||
|
||||
(t/is (= 0 (:call-count @mock)))
|
||||
;; (th/print-result! out)
|
||||
|
||||
(let [error (:error out)
|
||||
error-data (ex-data error)]
|
||||
(t/is (th/ex-info? error))))))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{:deps
|
||||
{org.clojure/clojure {:mvn/version "1.12.2"}
|
||||
{org.clojure/clojure {:mvn/version "1.12.4"}
|
||||
org.clojure/data.json {:mvn/version "2.5.1"}
|
||||
org.clojure/tools.cli {:mvn/version "1.1.230"}
|
||||
org.clojure/test.check {:mvn/version "1.1.1"}
|
||||
@@ -9,15 +9,15 @@
|
||||
org.apache.commons/commons-pool2 {:mvn/version "2.12.1"}
|
||||
|
||||
;; Logging
|
||||
org.apache.logging.log4j/log4j-api {:mvn/version "2.25.1"}
|
||||
org.apache.logging.log4j/log4j-core {:mvn/version "2.25.1"}
|
||||
org.apache.logging.log4j/log4j-web {:mvn/version "2.25.1"}
|
||||
org.apache.logging.log4j/log4j-jul {:mvn/version "2.25.1"}
|
||||
org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.25.1"}
|
||||
org.apache.logging.log4j/log4j-api {:mvn/version "2.25.3"}
|
||||
org.apache.logging.log4j/log4j-core {:mvn/version "2.25.3"}
|
||||
org.apache.logging.log4j/log4j-web {:mvn/version "2.25.3"}
|
||||
org.apache.logging.log4j/log4j-jul {:mvn/version "2.25.3"}
|
||||
org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.25.3"}
|
||||
org.slf4j/slf4j-api {:mvn/version "2.0.17"}
|
||||
pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.40"}
|
||||
pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.41"}
|
||||
|
||||
selmer/selmer {:mvn/version "1.12.69"}
|
||||
selmer/selmer {:mvn/version "1.12.70"}
|
||||
criterium/criterium {:mvn/version "0.4.6"}
|
||||
|
||||
metosin/jsonista {:mvn/version "0.3.13"}
|
||||
@@ -27,7 +27,7 @@
|
||||
com.cognitect/transit-clj {:mvn/version "1.0.333"}
|
||||
com.cognitect/transit-cljs {:mvn/version "0.8.280"}
|
||||
java-http-clj/java-http-clj {:mvn/version "0.4.3"}
|
||||
integrant/integrant {:mvn/version "1.0.0"}
|
||||
integrant/integrant {:mvn/version "1.0.1"}
|
||||
|
||||
funcool/cuerdas {:mvn/version "2026.415"}
|
||||
funcool/promesa
|
||||
|
||||
@@ -1009,6 +1009,15 @@
|
||||
{:title "agent"
|
||||
:description "instance of clojure agent"}}))
|
||||
|
||||
#?(:clj
|
||||
(register!
|
||||
{:type ::bytes
|
||||
:pred bytes?
|
||||
:type-properties
|
||||
{:title "bytes"
|
||||
:description "bytes array"}}))
|
||||
|
||||
|
||||
(register! ::any (mu/update-properties :any assoc :gen/gen sg/any))
|
||||
|
||||
;; ---- PREDICATES
|
||||
|
||||
@@ -97,9 +97,12 @@
|
||||
(def token-types
|
||||
(into #{} (keys token-type->dtcg-token-type)))
|
||||
|
||||
(def token-name-validation-regex
|
||||
#"^[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*$")
|
||||
|
||||
(def token-name-ref
|
||||
[:re {:title "TokenNameRef" :gen/gen sg/text}
|
||||
#"^(?!\$)([a-zA-Z0-9-$_]+\.?)*(?<!\.)$"])
|
||||
token-name-validation-regex])
|
||||
|
||||
(def ^:private schema:color
|
||||
[:map
|
||||
|
||||
@@ -1462,11 +1462,12 @@ Will return a value that matches this schema:
|
||||
(def ^:private schema:dtcg-node
|
||||
[:schema {:registry
|
||||
{::simple-value
|
||||
[:or :string :int :double]
|
||||
[:or :string :int :double ::sm/boolean]
|
||||
::value
|
||||
[:or
|
||||
[:ref ::simple-value]
|
||||
[:vector ::simple-value]
|
||||
[:vector [:map-of :string ::simple-value]]
|
||||
[:map-of :string [:or
|
||||
[:ref ::simple-value]
|
||||
[:vector ::simple-value]]]]}}
|
||||
|
||||
@@ -31,7 +31,7 @@ RUN set -ex; \
|
||||
|
||||
FROM base AS setup-node
|
||||
|
||||
ENV NODE_VERSION=v22.21.1 \
|
||||
ENV NODE_VERSION=v22.22.0 \
|
||||
PATH=/opt/node/bin:$PATH
|
||||
|
||||
RUN set -eux; \
|
||||
@@ -97,18 +97,19 @@ RUN set -eux; \
|
||||
|
||||
FROM base AS setup-jvm
|
||||
|
||||
ENV CLOJURE_VERSION=1.12.3.1577
|
||||
# https://clojure.org/releases/tools
|
||||
ENV CLOJURE_VERSION=1.12.4.1602
|
||||
|
||||
RUN set -eux; \
|
||||
ARCH="$(dpkg --print-architecture)"; \
|
||||
case "${ARCH}" in \
|
||||
aarch64|arm64) \
|
||||
ESUM='8c5321f16d9f1d8149f83e4e9ff8ca5d9e94320b92d205e6db42a604de3d1140'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.30.17-ca-jdk25.0.1-linux_aarch64.tar.gz'; \
|
||||
ESUM='9903c6b19183a33725ca1dfdae5b72400c9d00995c76fafc4a0d31c5152f33f7'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_aarch64.tar.gz'; \
|
||||
;; \
|
||||
amd64|x86_64) \
|
||||
ESUM='471b3e62bdffaed27e37005d842d8639f10d244ccce1c7cdebf7abce06c8313e'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.30.17-ca-jdk25.0.1-linux_x64.tar.gz'; \
|
||||
ESUM='946ad9766d98fc6ab495a1a120072197db54997f6925fb96680f1ecd5591db4e'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_x64.tar.gz'; \
|
||||
;; \
|
||||
*) \
|
||||
echo "Unsupported arch: ${ARCH}"; \
|
||||
@@ -179,9 +180,10 @@ RUN set -eux; \
|
||||
|
||||
FROM base AS setup-utils
|
||||
|
||||
ENV CLJKONDO_VERSION=2025.07.28 \
|
||||
ENV CLJKONDO_VERSION=2026.01.19 \
|
||||
BABASHKA_VERSION=1.12.208 \
|
||||
CLJFMT_VERSION=0.13.1
|
||||
CLJFMT_VERSION=0.15.6 \
|
||||
PIXI_VERSION=0.63.2
|
||||
|
||||
RUN set -ex; \
|
||||
ARCH="$(dpkg --print-architecture)"; \
|
||||
@@ -224,6 +226,26 @@ RUN set -ex; \
|
||||
tar -xf /tmp/babashka.tar.gz; \
|
||||
rm -rf /tmp/babashka.tar.gz;
|
||||
|
||||
RUN set -ex; \
|
||||
ARCH="$(dpkg --print-architecture)"; \
|
||||
case "${ARCH}" in \
|
||||
aarch64|arm64) \
|
||||
BINARY_URL="https://github.com/prefix-dev/pixi/releases/download/v$PIXI_VERSION/pixi-aarch64-unknown-linux-musl.tar.gz"; \
|
||||
;; \
|
||||
amd64|x86_64) \
|
||||
BINARY_URL="https://github.com/prefix-dev/pixi/releases/download/v$PIXI_VERSION/pixi-x86_64-unknown-linux-musl.tar.gz"; \
|
||||
;; \
|
||||
*) \
|
||||
echo "Unsupported arch: ${ARCH}"; \
|
||||
exit 1; \
|
||||
;; \
|
||||
esac; \
|
||||
cd /tmp; \
|
||||
curl -LfsSo /tmp/pixi.tar.gz ${BINARY_URL}; \
|
||||
cd /opt/utils/bin; \
|
||||
tar -xf /tmp/pixi.tar.gz; \
|
||||
rm -rf /tmp/pixi.tar.gz;
|
||||
|
||||
RUN set -ex; \
|
||||
ARCH="$(dpkg --print-architecture)"; \
|
||||
case "${ARCH}" in \
|
||||
@@ -375,7 +397,7 @@ ENV LANG='C.UTF-8' \
|
||||
RUSTUP_HOME="/opt/rustup" \
|
||||
PATH="/opt/jdk/bin:/opt/utils/bin:/opt/clojure/bin:/opt/node/bin:/opt/imagick/bin:/opt/cargo/bin:$PATH"
|
||||
|
||||
COPY --from=penpotapp/imagemagick:7.1.2-0 /opt/imagick /opt/imagick
|
||||
COPY --from=penpotapp/imagemagick:7.1.2-13 /opt/imagick /opt/imagick
|
||||
COPY --from=setup-jvm /opt/jdk /opt/jdk
|
||||
COPY --from=setup-jvm /opt/clojure /opt/clojure
|
||||
COPY --from=setup-node /opt/node /opt/node
|
||||
@@ -398,7 +420,6 @@ COPY files/Caddyfile /home/
|
||||
COPY files/selfsigned.crt /home/
|
||||
COPY files/selfsigned.key /home/
|
||||
COPY files/start-tmux.sh /home/start-tmux.sh
|
||||
COPY files/start-tmux-back.sh /home/start-tmux-back.sh
|
||||
COPY files/entrypoint.sh /home/entrypoint.sh
|
||||
COPY files/init.sh /home/init.sh
|
||||
|
||||
|
||||
@@ -46,6 +46,11 @@ services:
|
||||
- 9090:9090
|
||||
- 9091:9091
|
||||
|
||||
# MCP
|
||||
- 4400:4400
|
||||
- 4401:4401
|
||||
- 4402:4402
|
||||
|
||||
environment:
|
||||
- EXTERNAL_UID=${CURRENT_USER_ID}
|
||||
# SMTP setup
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
{
|
||||
auto_https off
|
||||
auto_https off
|
||||
}
|
||||
|
||||
localhost:3449 {
|
||||
reverse_proxy localhost:4449
|
||||
tls /home/selfsigned.crt /home/selfsigned.key
|
||||
reverse_proxy localhost:4449
|
||||
tls /home/selfsigned.crt /home/selfsigned.key
|
||||
header -Strict-Transport-Security
|
||||
}
|
||||
|
||||
http://localhost:3450 {
|
||||
reverse_proxy localhost:4449
|
||||
reverse_proxy localhost:4449
|
||||
}
|
||||
|
||||
http://penpot-devenv-main:3450 {
|
||||
reverse_proxy localhost:4449
|
||||
reverse_proxy localhost:4449
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ export PATH="/home/penpot/.cargo/bin:/opt/jdk/bin:/opt/utils/bin:/opt/clojure/bi
|
||||
export CARGO_HOME="/home/penpot/.cargo"
|
||||
|
||||
alias l='ls --color -GFlh'
|
||||
alias rm='rm -r'
|
||||
alias ll='ls --color -GFlh'
|
||||
alias rm='rm -rf'
|
||||
alias ls='ls --color -F'
|
||||
alias lsd='ls -d *(/)'
|
||||
alias lsf='ls -h *(.)'
|
||||
|
||||
@@ -121,6 +121,28 @@ http {
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
location /mcp {
|
||||
alias /home/penpot/penpot/mcp/packages/plugin/dist;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
location /mcp/ws {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_pass http://127.0.0.1:4402;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
location /mcp/stream {
|
||||
proxy_pass http://127.0.0.1:4401/mcp;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
location /mcp/sse {
|
||||
proxy_pass http://127.0.0.1:4401/sse;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
location /admin {
|
||||
proxy_pass http://127.0.0.1:6063/admin;
|
||||
}
|
||||
@@ -141,8 +163,14 @@ http {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
}
|
||||
|
||||
location /nitrate/ {
|
||||
proxy_pass http://127.0.0.1:3000/;
|
||||
location /control-center {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /wasm-playground {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
set -g default-command "${SHELL}"
|
||||
set -g mouse off
|
||||
set -g history-limit 50000
|
||||
setw -g mode-keys emacs
|
||||
|
||||
@@ -6,7 +6,7 @@ ENV LANG='C.UTF-8' \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
TZ=Etc/UTC
|
||||
|
||||
ARG IMAGEMAGICK_VERSION=7.1.1-47
|
||||
ARG IMAGEMAGICK_VERSION=7.1.2-13
|
||||
|
||||
RUN set -e; \
|
||||
apt-get -qq update; \
|
||||
@@ -24,6 +24,7 @@ RUN set -e; \
|
||||
libltdl-dev \
|
||||
liblzma-dev \
|
||||
libopenexr-dev \
|
||||
libxml2-dev \
|
||||
libpng-dev \
|
||||
librsvg2-dev \
|
||||
libtiff-dev \
|
||||
@@ -52,6 +53,7 @@ RUN set -e; \
|
||||
libfftw3-dev \
|
||||
libheif-dev \
|
||||
libjpeg-dev \
|
||||
libxml2-dev \
|
||||
liblcms2-dev \
|
||||
libltdl-dev \
|
||||
liblzma-dev \
|
||||
@@ -77,6 +79,7 @@ RUN set -e; \
|
||||
libopenjp2-7 \
|
||||
libpng16-16 \
|
||||
librsvg2-2 \
|
||||
libxml2 \
|
||||
libtiff6 \
|
||||
libwebp7 \
|
||||
libwebpdemux2 \
|
||||
|
||||
@@ -5,7 +5,7 @@ ENV LANG='C.UTF-8' \
|
||||
LC_ALL='C.UTF-8' \
|
||||
JAVA_HOME="/opt/jdk" \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
NODE_VERSION=v22.21.1 \
|
||||
NODE_VERSION=v22.22.0 \
|
||||
TZ=Etc/UTC
|
||||
|
||||
RUN set -ex; \
|
||||
@@ -46,12 +46,12 @@ RUN set -eux; \
|
||||
ARCH="$(dpkg --print-architecture)"; \
|
||||
case "${ARCH}" in \
|
||||
aarch64|arm64) \
|
||||
ESUM='8c5321f16d9f1d8149f83e4e9ff8ca5d9e94320b92d205e6db42a604de3d1140'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.30.17-ca-jdk25.0.1-linux_aarch64.tar.gz'; \
|
||||
ESUM='9903c6b19183a33725ca1dfdae5b72400c9d00995c76fafc4a0d31c5152f33f7'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_aarch64.tar.gz'; \
|
||||
;; \
|
||||
amd64|x86_64) \
|
||||
ESUM='471b3e62bdffaed27e37005d842d8639f10d244ccce1c7cdebf7abce06c8313e'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.30.17-ca-jdk25.0.1-linux_x64.tar.gz'; \
|
||||
ESUM='946ad9766d98fc6ab495a1a120072197db54997f6925fb96680f1ecd5591db4e'; \
|
||||
BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_x64.tar.gz'; \
|
||||
;; \
|
||||
*) \
|
||||
echo "Unsupported arch: ${ARCH}"; \
|
||||
@@ -125,7 +125,7 @@ RUN set -ex; \
|
||||
|
||||
COPY --from=build /opt/jre /opt/jre
|
||||
COPY --from=build /opt/node /opt/node
|
||||
COPY --from=penpotapp/imagemagick:7.1.2-0 /opt/imagick /opt/imagick
|
||||
COPY --from=penpotapp/imagemagick:7.1.2-13 /opt/imagick /opt/imagick
|
||||
|
||||
ARG BUNDLE_PATH="./bundle-backend/"
|
||||
COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/backend/
|
||||
|
||||
@@ -3,7 +3,7 @@ LABEL maintainer="Penpot <docker@penpot.app>"
|
||||
|
||||
ENV LANG=en_US.UTF-8 \
|
||||
LC_ALL=en_US.UTF-8 \
|
||||
NODE_VERSION=v22.21.1 \
|
||||
NODE_VERSION=v22.22.0 \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
PATH=/opt/node/bin:/opt/imagick/bin:$PATH
|
||||
|
||||
@@ -107,7 +107,7 @@ RUN set -eux; \
|
||||
|
||||
ARG BUNDLE_PATH="./bundle-exporter/"
|
||||
COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/exporter/
|
||||
COPY --from=penpotapp/imagemagick:7.1.2-0 /opt/imagick /opt/imagick
|
||||
COPY --from=penpotapp/imagemagick:7.1.2-13 /opt/imagick /opt/imagick
|
||||
|
||||
WORKDIR /opt/penpot/exporter
|
||||
USER penpot:penpot
|
||||
|
||||
58
docker/images/Dockerfile.mcp
Normal file
58
docker/images/Dockerfile.mcp
Normal file
@@ -0,0 +1,58 @@
|
||||
FROM ubuntu:24.04
|
||||
LABEL maintainer="Penpot <docker@penpot.app>"
|
||||
|
||||
ENV LANG=en_US.UTF-8 \
|
||||
LC_ALL=en_US.UTF-8 \
|
||||
NODE_VERSION=v22.21.1 \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
PATH=/opt/node/bin:$PATH
|
||||
|
||||
RUN set -ex; \
|
||||
useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \
|
||||
mkdir -p /etc/resolvconf/resolv.conf.d; \
|
||||
echo "nameserver 127.0.0.11" > /etc/resolvconf/resolv.conf.d/tail; \
|
||||
apt-get -qq update; \
|
||||
apt-get -qqy --no-install-recommends install \
|
||||
curl \
|
||||
tzdata \
|
||||
locales \
|
||||
ca-certificates \
|
||||
; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen; \
|
||||
locale-gen; \
|
||||
find /usr/share/i18n/locales/ -type f ! -name "en_US" ! -name "POSIX" ! -name "C" -delete;
|
||||
|
||||
RUN set -eux; \
|
||||
ARCH="$(dpkg --print-architecture)"; \
|
||||
case "${ARCH}" in \
|
||||
aarch64|arm64) \
|
||||
BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz"; \
|
||||
;; \
|
||||
amd64|x86_64) \
|
||||
BINARY_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz"; \
|
||||
;; \
|
||||
*) \
|
||||
echo "Unsupported arch: ${ARCH}"; \
|
||||
exit 1; \
|
||||
;; \
|
||||
esac; \
|
||||
curl -LfsSo /tmp/nodejs.tar.gz ${BINARY_URL}; \
|
||||
mkdir -p /opt/node; \
|
||||
cd /opt/node; \
|
||||
tar -xf /tmp/nodejs.tar.gz --strip-components=1; \
|
||||
chown -R root /opt/node; \
|
||||
rm -rf /tmp/nodejs.tar.gz; \
|
||||
corepack enable; \
|
||||
mkdir -p /opt/penpot; \
|
||||
chown -R penpot:penpot /opt/penpot;
|
||||
|
||||
ARG BUNDLE_PATH="./bundle-mcp/"
|
||||
COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/mcp/
|
||||
|
||||
WORKDIR /opt/penpot/mcp
|
||||
USER penpot:penpot
|
||||
|
||||
RUN ./setup
|
||||
|
||||
CMD ["node", "index.js", "--multi-user"]
|
||||
@@ -130,6 +130,7 @@ http {
|
||||
}
|
||||
|
||||
location /readyz {
|
||||
access_log off;
|
||||
proxy_pass $PENPOT_BACKEND_URI$request_uri;
|
||||
}
|
||||
|
||||
@@ -144,7 +145,7 @@ http {
|
||||
location / {
|
||||
include /etc/nginx/overrides/location.d/*.conf;
|
||||
|
||||
location ~* \.(js|css|jpg|png|svg|gif|ttf|woff|woff2|wasm)$ {
|
||||
location ~* \.(js|css|jpg|png|svg|gif|ttf|woff|woff2|wasm|map)$ {
|
||||
add_header Cache-Control "public, max-age=604800" always; # 7 days
|
||||
}
|
||||
|
||||
@@ -152,8 +153,10 @@ http {
|
||||
return 301 " /404";
|
||||
}
|
||||
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
add_header Cache-Control "no-store, no-cache, max-age=0" always;
|
||||
try_files $uri /index.html$is_args$args /index.html =404;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,4 +6,4 @@ desc: Create, deploy, and use the Penpot plugin API with our comprehensive docum
|
||||
|
||||
# Penpot plugins API
|
||||
|
||||
We've got all the documentation you need for the API right <a target="_blank" href="https://penpot-plugins-api-doc.pages.dev/">here</a>.
|
||||
We've got all the documentation you need for the API right <a target="_blank" href="https://doc.plugins.penpot.app/">here</a>.
|
||||
|
||||
@@ -9,13 +9,13 @@ desc: See the Penpot plugin API changelog for version 1.0! Find breaking changes
|
||||
### <g-emoji class="g-emoji" alias="boom" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f680.png"><img class="emoji" alt="boom" height="20" width="20" src="https://github.githubassets.com/images/icons/emoji/unicode/1f680.png"></g-emoji> Epics and highlights</code>
|
||||
- This marks the release of version 1.0, and from this point forward, we’ll do our best to avoid making any more breaking changes (or make deprecations backward compatible).
|
||||
- We’ve redone the documentation. You can check the API here:
|
||||
[https://penpot-plugins-api-doc.pages.dev/](https://penpot-plugins-api-doc.pages.dev/)
|
||||
[https://doc.plugins.penpot.app/](https://doc.plugins.penpot.app/)
|
||||
- New samples repository with lots of samples to use the API:
|
||||
[https://github.com/penpot/penpot-plugins-samples](https://github.com/penpot/penpot-plugins-samples)
|
||||
|
||||
### <g-emoji class="g-emoji" alias="boom" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f4a5.png"><img class="emoji" alt="boom" height="20" width="20" src="https://github.githubassets.com/images/icons/emoji/unicode/1f4a5.png"></g-emoji> Breaking changes & Deprecations
|
||||
|
||||
- Changed types names to remove the Penpot prefix. So for example: <code class="language-js">PenpotShape</code> becomes <code class="language-js">Shape</code>; <code class="language-js">PenpotFile</code> becomes <code class="language-js">File</code>, and so on. Check the [API documentation](https://penpot-plugins-api-doc.pages.dev/) for more details.
|
||||
- Changed types names to remove the Penpot prefix. So for example: <code class="language-js">PenpotShape</code> becomes <code class="language-js">Shape</code>; <code class="language-js">PenpotFile</code> becomes <code class="language-js">File</code>, and so on. Check the [API documentation](https://doc.plugins.penpot.app/) for more details.
|
||||
- Changes on the <code class="language-js">penpot.on</code> and <code class="language-js">penpot.off</code> methods.
|
||||
Previously you had to send the original callback to the off method in order to remove an event listener. Now, <code class="language-js">penpot.on</code> will return an *id* that you can pass to the <code class="language-js">penpot.off</code> method in order to remove the listener.
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ There are two libraries that can help you with your plugin's development. They a
|
||||
|
||||
### Plugin styles
|
||||
|
||||
<code class="language-js">@penpot/plugin-styles</code> contains styles to help build the UI for Penpot plugins. To check the styles go to <a target="_blank" href="https://penpot-plugins-styles.pages.dev/">Plugin styles</a>.
|
||||
<code class="language-js">@penpot/plugin-styles</code> contains styles to help build the UI for Penpot plugins. To check the styles go to <a target="_blank" href="https://styles-doc.plugins.penpot.app/">Plugin styles</a>.
|
||||
|
||||
```bash
|
||||
npm install @penpot/plugin-styles
|
||||
@@ -139,7 +139,7 @@ parent.postMessage(responseMessage, targetOrigin);
|
||||
|
||||
By using these message-based events, any data retrieved through the Penpot API can be communicated to and from your plugin interface seamlessly.
|
||||
|
||||
For more detailed information, refer to the [Penpot Plugins API Documentation](https://penpot-plugins-api-doc.pages.dev/).
|
||||
For more detailed information, refer to the [Penpot Plugins API Documentation](https://doc.plugins.penpot.app/).
|
||||
|
||||
## 2.5. Step 5. Build the plugin file
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ penpot.library.local.createTypography();
|
||||
|
||||
Penpot has dark and light modes, and you can easily add this to your plugin so your interface adapts to both themes. When you add theme support, your plugin will automatically sync with Penpot's interface settings, so the user experience is consistent no matter which mode is selected. This makes your plugin look better and also ensures it stays in line with Penpot's overall design.
|
||||
|
||||
Just a heads-up: if you use the <a target="_blank" href="https://penpot-plugins-styles.pages.dev/">plugin-styles library</a>, many elements will automatically adapt to dark or light mode without any extra effort from you. However, if you need to customize specific elements, be sure to use the selectors provided in the <code class="language-bash">styles.css</code> of the example.
|
||||
Just a heads-up: if you use the <a target="_blank" href="https://styles-doc.plugins.penpot.app/">plugin-styles library</a>, many elements will automatically adapt to dark or light mode without any extra effort from you. However, if you need to customize specific elements, be sure to use the selectors provided in the <code class="language-bash">styles.css</code> of the example.
|
||||
|
||||
<a target="_blank" href="https://github.com/penpot/penpot-plugins-samples/tree/main/theme">Theme example</a>
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ The plugin <a target="_blank" href="https://www.npmjs.com/package/@penpot/plugin
|
||||
|
||||
### Is the API ready to use the prototyping features?
|
||||
|
||||
Absolutely! You can definitely create flows and interactions in the same elements as in the interface, like frames, shapes, and groups. Just check out the API documentation for the methods: createFlow, addInteraction, or removeInteraction. And if you need more help, you can always check out the <a target="_blank" href="https://penpot-plugins-api-doc.pages.dev/interfaces/PenpotFlow">PenpotFlow</a> or <a target="_blank" href="https://penpot-plugins-api-doc.pages.dev/interfaces/PenpotInteraction">PenpotInteraction</a> interfaces.
|
||||
Absolutely! You can definitely create flows and interactions in the same elements as in the interface, like frames, shapes, and groups. Just check out the API documentation for the methods: createFlow, addInteraction, or removeInteraction. And if you need more help, you can always check out the <a target="_blank" href="https://doc.plugins.penpot.app/interfaces/Flow">Flow</a> or <a target="_blank" href="https://doc.plugins.penpot.app/interfaces/Interaction">Interaction</a> interfaces.
|
||||
|
||||
### Are there any security or quality criteria I should be aware of?
|
||||
|
||||
@@ -48,7 +48,8 @@ There are no set requirements. However, we can recommend the use of <a target="_
|
||||
|
||||
### Is it necessary to create plugins with a UI?
|
||||
|
||||
No, it’s completely optional, in fact, we have an example of a plugin without UI. Try the plugin using this url to install it: <code class="language-js">https:\/\/create-palette-penpot-plugin.pages.dev/assets/manifest.json</code> or check the code <a target="_blank" href="https://github.com/penpot/penpot-plugins/tree/main/apps/create-palette-plugin">here</a>
|
||||
No, it’s completely optional, in fact, we have an example of a plugin without UI. Try the plugin using this url to install it: <code class="language-js">https:\/\/create-palette.plugins.penpot.app/assets/manifest.json</code> or check the code <a target="_blank" href="https://github.com/penpot/penpot/tree/main/plugins/apps/create-palette-plugin">here</a>
|
||||
|
||||
|
||||
### Can I create components?
|
||||
|
||||
@@ -58,7 +59,7 @@ Yes, it is possible to create components using:
|
||||
createComponent(shapes: Shape[]): LibraryComponent;
|
||||
```
|
||||
|
||||
Take a look at the Penpot Library methods in the <a target="_blank" href="https://penpot-plugins-api-doc.pages.dev/interfaces/Library">API documentation</a> or this <a target="_blank" href="https://github.com/penpot/penpot-plugins-samples/tree/main/components-library">simple example</a>.
|
||||
Take a look at the Penpot Library methods in the <a target="_blank" href="https://doc.plugins.penpot.app/interfaces/Library">API documentation</a> or this <a target="_blank" href="https://github.com/penpot/penpot-plugins-samples/tree/main/components-library">simple example</a>.
|
||||
|
||||
### Is there a place where I can share my plugin?
|
||||
|
||||
|
||||
@@ -69,12 +69,13 @@ You need to provide the plugin's manifest URL for the installation. If there are
|
||||
|
||||
| Name | URL |
|
||||
| ------------- | ------------------------------------------------------------------- |
|
||||
| Lorem Ipsum | https://lorem-ipsum-penpot-plugin.pages.dev/assets/manifest.json |
|
||||
| Contrast | https://contrast-penpot-plugin.pages.dev/assets/manifest.json |
|
||||
| Feather icons | https://icons-penpot-plugin.pages.dev/assets/manifest.json |
|
||||
| Tables | https://table-penpot-plugin.pages.dev/assets/manifest.json |
|
||||
| Color palette | https://create-palette-penpot-plugin.pages.dev/assets/manifest.json |
|
||||
| Rename layers | https://rename-layers-penpot-plugin.pages.dev/assets/manifest.json |
|
||||
| Color palette | https://create-palette.plugins.penpot.app/assets/manifest.json |
|
||||
| Contrast | https://contrast.plugins.penpot.app/assets/manifest.json |
|
||||
| Feather icons | https://icons.plugins.penpot.app/assets/manifest.json |
|
||||
| Lorem ipsum | https://lorem-ipsum.plugins.penpot.app/assets/manifest.json |
|
||||
| Rename layers | https://rename-layers.plugins.penpot.app/assets/manifest.json |
|
||||
| Tables | https://table.plugins.penpot.app/assets/manifest.json |
|
||||
|
||||
|
||||
## 1.4. Plugin's basics
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
{:path path
|
||||
:mtype (mime/get type)
|
||||
:name name
|
||||
:filename (str/concat name (mime/get-extension type))
|
||||
:filename (str/concat (str/slug name) (mime/get-extension type))
|
||||
:id task-id}))
|
||||
|
||||
(defn create-zip
|
||||
|
||||
@@ -38,6 +38,24 @@
|
||||
(assoc :path "/render.html")
|
||||
(assoc :query (u/map->query-string params)))))
|
||||
|
||||
(sync-page-size! [dom]
|
||||
(bw/eval! dom
|
||||
(fn [elem]
|
||||
;; IMPORTANT: No CLJS runtime allowed. Use only JS
|
||||
;; primitives. This runs in a context without access to
|
||||
;; cljs.core. Avoid any functions that transpile to
|
||||
;; cljs.core/* calls, as they will break in the browser
|
||||
;; runtime.
|
||||
|
||||
(let [width (.getAttribute ^js elem "width")
|
||||
height (.getAttribute ^js elem "height")
|
||||
style-node (let [node (.createElement js/document "style")]
|
||||
(.appendChild (.-head js/document) node)
|
||||
node)]
|
||||
(set! (.-textContent style-node)
|
||||
(dm/str "@page { size: " width "px " height "px; margin: 0; }\n"
|
||||
"html, body, #app { margin: 0; padding: 0; width: " width "px; height: " height "px; overflow: visible; }"))))))
|
||||
|
||||
(render-object [page base-uri {:keys [id] :as object}]
|
||||
(p/let [uri (prepare-uri base-uri id)
|
||||
path (sh/tempfile :prefix "penpot.tmp.pdf." :suffix (mime/get-extension type))]
|
||||
@@ -45,6 +63,7 @@
|
||||
(bw/nav! page uri)
|
||||
(p/let [dom (bw/select page (dm/str "#screenshot-" id))]
|
||||
(bw/wait-for dom)
|
||||
(sync-page-size! dom)
|
||||
(bw/screenshot dom {:full-page? true})
|
||||
(bw/sleep page 2000) ; the good old fix with sleep
|
||||
(bw/pdf page {:path path})
|
||||
|
||||
@@ -110,7 +110,7 @@ test("Update an already created text shape by prepending text", async ({
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("Update an already created text shape by inserting text in between", async ({
|
||||
test.skip("Update an already created text shape by inserting text in between", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WorkspacePage(page, {
|
||||
@@ -151,7 +151,7 @@ test("Update a new text shape appending text by pasting text", async ({
|
||||
await workspace.textEditor.stopEditing();
|
||||
});
|
||||
|
||||
test("Update a new text shape prepending text by pasting text", async ({
|
||||
test.skip("Update a new text shape prepending text by pasting text", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
|
||||
@@ -24,6 +24,20 @@
|
||||
[cuerdas.core :as str]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(def ^:const default-chunk-size
|
||||
(* 1024 1024 4)) ;; 4MiB
|
||||
|
||||
(defn- chunk-array
|
||||
[data chunk-size]
|
||||
(let [total-size (alength data)]
|
||||
(loop [offset 0
|
||||
chunks []]
|
||||
(if (< offset total-size)
|
||||
(let [end (min (+ offset chunk-size) total-size)
|
||||
chunk (.subarray ^js data offset end)]
|
||||
(recur end (conj chunks chunk)))
|
||||
chunks))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; General purpose events & IMPL
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -116,9 +130,9 @@
|
||||
(not= hhea-descender win-descent)
|
||||
(and f-selection (or
|
||||
(not= hhea-ascender os2-ascent)
|
||||
(not= hhea-descender os2-descent))))]
|
||||
|
||||
{:content {:data (js/Uint8Array. data)
|
||||
(not= hhea-descender os2-descent))))
|
||||
data (js/Uint8Array. data)]
|
||||
{:content {:data (chunk-array data default-chunk-size)
|
||||
:name name
|
||||
:type type}
|
||||
:font-family (or family "")
|
||||
|
||||
@@ -70,20 +70,22 @@
|
||||
(= (-> content last :command) :move-to))
|
||||
(into [] (take (dec (count content)) content))
|
||||
content)]
|
||||
(-> state
|
||||
(st/set-content content))))
|
||||
(st/set-content state content)))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [it state _]
|
||||
(let [page-id (:current-page-id state)
|
||||
objects (dsh/lookup-page-objects state page-id)
|
||||
id (dm/get-in state [:workspace-local :edition])
|
||||
old-content (dm/get-in state [:workspace-local :edit-path id :old-content])
|
||||
shape (st/get-path state)]
|
||||
local (get state :workspace-local)
|
||||
id (get local :edition)
|
||||
objects (dsh/lookup-page-objects state page-id)]
|
||||
|
||||
(if (and (some? old-content) (some? (:id shape)))
|
||||
(let [changes (generate-path-changes it objects page-id shape old-content (:content shape))]
|
||||
(rx/of (dch/commit-changes changes)))
|
||||
(rx/empty)))))))
|
||||
;; NOTE: we proceed only if the shape is present on the
|
||||
;; objects, if shape is a ephimeral drawing shape, we should
|
||||
;; do nothing
|
||||
(when-let [shape (get objects id)]
|
||||
(when-let [old-content (dm/get-in local [:edit-path id :old-content])]
|
||||
(let [new-content (get shape :content)
|
||||
changes (generate-path-changes it objects page-id shape old-content new-content)]
|
||||
(rx/of (dch/commit-changes changes))))))))))
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.types.path :as path]
|
||||
[app.common.types.path.helpers :as path.helpers]
|
||||
@@ -289,34 +288,34 @@
|
||||
|
||||
(declare stop-path-edit)
|
||||
|
||||
|
||||
(defn start-path-edit
|
||||
[id]
|
||||
(ptk/reify ::start-path-edit
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(let [objects (dsh/lookup-page-objects state)
|
||||
edit-path (dm/get-in state [:workspace-local :edit-path id])
|
||||
content (st/get-path state :content)
|
||||
state (cond-> state
|
||||
(cfh/path-shape? objects id)
|
||||
(st/set-content (path/close-subpaths content)))]
|
||||
shape (get objects id)]
|
||||
|
||||
(cond-> state
|
||||
(or (not edit-path)
|
||||
(= :draw (:edit-mode edit-path)))
|
||||
(assoc-in [:workspace-local :edit-path id] {:edit-mode :move
|
||||
:selected #{}
|
||||
:snap-toggled false})
|
||||
(and (some? edit-path)
|
||||
(= :move (:edit-mode edit-path)))
|
||||
(assoc-in [:workspace-local :edit-path id :edit-mode] :draw))))
|
||||
(-> state
|
||||
(st/set-content (path/close-subpaths (:content shape)))
|
||||
(update-in [:workspace-local :edit-path id]
|
||||
(fn [state]
|
||||
(let [state (if state
|
||||
(if (= :move (:edit-mode state))
|
||||
(assoc state :edit-mode :draw)
|
||||
state)
|
||||
{:edit-mode :move
|
||||
:selected #{}
|
||||
:snap-toggled false})]
|
||||
(assoc state :old-content (:content shape))))))))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ stream]
|
||||
(let [stopper (->> stream
|
||||
(rx/filter #(let [type (ptk/type %)]
|
||||
(= type ::dwe/clear-edition-mode)
|
||||
(= type ::start-path-edit))))]
|
||||
(let [stopper (rx/filter #(let [type (ptk/type %)]
|
||||
(= type ::dwe/clear-edition-mode)
|
||||
(= type ::start-path-edit))
|
||||
stream)]
|
||||
(rx/concat
|
||||
(rx/of (undo/start-path-undo))
|
||||
(->> stream
|
||||
@@ -325,7 +324,8 @@
|
||||
(rx/map #(stop-path-edit id))
|
||||
(rx/take-until stopper)))))))
|
||||
|
||||
(defn stop-path-edit [id]
|
||||
(defn stop-path-edit
|
||||
[id]
|
||||
(ptk/reify ::stop-path-edit
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
@@ -335,13 +335,12 @@
|
||||
(watch [_ _ _]
|
||||
(rx/of (ptk/data-event :layout/update {:ids [id]})))))
|
||||
|
||||
(defn split-segments
|
||||
[{:keys [from-p to-p t]}]
|
||||
(defn- split-segments
|
||||
[id {:keys [from-p to-p t]}]
|
||||
(ptk/reify ::split-segments
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(let [id (st/get-path-id state)
|
||||
content (st/get-path state :content)]
|
||||
(let [content (st/get-path state :content)]
|
||||
(-> state
|
||||
(assoc-in [:workspace-local :edit-path id :old-content] content)
|
||||
(st/set-content (-> content
|
||||
@@ -353,10 +352,10 @@
|
||||
(rx/of (changes/save-path-content {:preserve-move-to true})))))
|
||||
|
||||
(defn create-node-at-position
|
||||
[event]
|
||||
[params]
|
||||
(ptk/reify ::create-node-at-position
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [id (st/get-path-id state)]
|
||||
(rx/of (dwsh/update-shapes [id] path/convert-to-path)
|
||||
(split-segments event))))))
|
||||
(split-segments id params))))))
|
||||
|
||||
@@ -36,10 +36,12 @@
|
||||
|
||||
(defn- hide-popover
|
||||
[node]
|
||||
(dom/unset-css-property! node "block-size")
|
||||
(dom/unset-css-property! node "inset-block-start")
|
||||
(dom/unset-css-property! node "inset-inline-start")
|
||||
(.hidePopover ^js node))
|
||||
(when (and (some? node)
|
||||
(fn? (.-hidePopover node)))
|
||||
(dom/unset-css-property! node "block-size")
|
||||
(dom/unset-css-property! node "inset-block-start")
|
||||
(dom/unset-css-property! node "inset-inline-start")
|
||||
(.hidePopover ^js node)))
|
||||
|
||||
(defn- calculate-placement-bounding-rect
|
||||
"Given a placement, calcultates the bounding rect for it taking in
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
(def context (mf/create-context nil))
|
||||
|
||||
(mf/defc form-input*
|
||||
[{:keys [name] :rest props}]
|
||||
[{:keys [name trim] :rest props}]
|
||||
|
||||
(let [form (mf/use-ctx context)
|
||||
input-name name
|
||||
@@ -33,7 +33,7 @@
|
||||
(mf/deps input-name)
|
||||
(fn [event]
|
||||
(let [value (-> event dom/get-target dom/get-input-value)]
|
||||
(fm/on-input-change form input-name value true))))
|
||||
(fm/on-input-change form input-name value trim))))
|
||||
|
||||
props
|
||||
(mf/spread-props props {:on-change on-change
|
||||
|
||||
@@ -375,7 +375,7 @@
|
||||
(mf/use-fn
|
||||
(mf/deps on-change ids)
|
||||
(fn [value attr event]
|
||||
(if (or (string? value) (int? value))
|
||||
(if (or (string? value) (number? value))
|
||||
(on-change :simple attr value event)
|
||||
(do
|
||||
(let [resolved-value (:resolved-value (first value))
|
||||
@@ -489,7 +489,7 @@
|
||||
(mf/use-fn
|
||||
(mf/deps on-change ids)
|
||||
(fn [value attr event]
|
||||
(if (or (string? value) (int? value))
|
||||
(if (or (string? value) (number? value))
|
||||
(on-change :multiple attr value event)
|
||||
(do
|
||||
(let [resolved-value (:resolved-value (first value))]
|
||||
@@ -724,7 +724,7 @@
|
||||
(mf/use-fn
|
||||
(mf/deps on-change wrap-type ids)
|
||||
(fn [value event attr]
|
||||
(if (or (string? value) (int? value))
|
||||
(if (or (string? value) (number? value))
|
||||
(on-change (= "nowrap" wrap-type) attr value event)
|
||||
(do
|
||||
(let [resolved-value (:resolved-value (first value))]
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.types.color :as cl]
|
||||
[app.common.types.token :as cto]
|
||||
[app.common.types.tokens-lib :as ctob]
|
||||
[app.main.data.style-dictionary :as sd]
|
||||
[app.main.data.tinycolor :as tinycolor]
|
||||
@@ -51,12 +52,15 @@
|
||||
;; Both variants provide identical color-picker and text-input behavior, but
|
||||
;; differ in how they persist the value within the form’s nested structure.
|
||||
|
||||
|
||||
(defn- resolve-value
|
||||
[tokens prev-token token-name value]
|
||||
(let [token
|
||||
(let [valid-token-name?
|
||||
(and (string? token-name)
|
||||
(re-matches cto/token-name-validation-regex token-name))
|
||||
|
||||
token
|
||||
{:value value
|
||||
:name (if (str/blank? token-name)
|
||||
:name (if (or (not valid-token-name?) (str/blank? token-name))
|
||||
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
|
||||
token-name)}
|
||||
|
||||
|
||||
@@ -50,9 +50,13 @@
|
||||
|
||||
(defn- resolve-value
|
||||
[tokens prev-token token-name value]
|
||||
(let [token
|
||||
(let [valid-token-name?
|
||||
(and (string? token-name)
|
||||
(re-matches cto/token-name-validation-regex token-name))
|
||||
|
||||
token
|
||||
{:value (cto/split-font-family value)
|
||||
:name (if (str/blank? token-name)
|
||||
:name (if (or (not valid-token-name?) (str/blank? token-name))
|
||||
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
|
||||
token-name)}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.files.tokens :as cft]
|
||||
[app.common.types.token :as cto]
|
||||
[app.common.types.tokens-lib :as ctob]
|
||||
[app.main.data.style-dictionary :as sd]
|
||||
[app.main.data.workspace.tokens.format :as dwtf]
|
||||
@@ -140,9 +141,13 @@
|
||||
|
||||
(defn- resolve-value
|
||||
[tokens prev-token token-name value]
|
||||
(let [token
|
||||
(let [valid-token-name?
|
||||
(and (string? token-name)
|
||||
(re-matches cto/token-name-validation-regex token-name))
|
||||
|
||||
token
|
||||
{:value value
|
||||
:name (if (str/blank? token-name)
|
||||
:name (if (or (not valid-token-name?) (str/blank? token-name))
|
||||
"__PENPOT__TOKEN__NAME__PLACEHOLDER__"
|
||||
token-name)}
|
||||
tokens
|
||||
|
||||
@@ -230,6 +230,7 @@
|
||||
:placeholder (tr "workspace.tokens.enter-token-name" token-title)
|
||||
:max-length max-input-length
|
||||
:variant "comfortable"
|
||||
:trim true
|
||||
:auto-focus true}]
|
||||
|
||||
(when (and warning-name-change? (= action "edit"))
|
||||
|
||||
@@ -106,17 +106,20 @@
|
||||
|
||||
(defn stop-propagation
|
||||
[^js event]
|
||||
(when event
|
||||
(when (and (some? event)
|
||||
(fn? (.-stopPropagation event)))
|
||||
(.stopPropagation event)))
|
||||
|
||||
(defn stop-immediate-propagation
|
||||
[^js event]
|
||||
(when event
|
||||
(when (and (some? event)
|
||||
(fn? (.-stopImmediatePropagation event)))
|
||||
(.stopImmediatePropagation event)))
|
||||
|
||||
(defn prevent-default
|
||||
[^js event]
|
||||
(when event
|
||||
(when (and (some? event)
|
||||
(fn? (.-preventDefault event)))
|
||||
(.preventDefault event)))
|
||||
|
||||
(defn get-target
|
||||
@@ -802,9 +805,10 @@
|
||||
([uri name]
|
||||
(open-new-window uri name "noopener,noreferrer"))
|
||||
([uri name features]
|
||||
(let [new-window (.open js/window (str uri) name features)]
|
||||
(when-let [new-window (.open js/window (str uri) name features)]
|
||||
(when (not= name "_blank")
|
||||
(.reload (.-location new-window))))))
|
||||
(when-let [location (.-location new-window)]
|
||||
(.reload location))))))
|
||||
|
||||
(defn browser-back
|
||||
[]
|
||||
|
||||
@@ -7,15 +7,16 @@
|
||||
(ns app.util.keyboard
|
||||
(:require
|
||||
[app.config :as cfg]
|
||||
[app.util.dom :as dom]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(defrecord KeyboardEvent [type key shift ctrl alt meta mod editing native-event]
|
||||
Object
|
||||
(preventDefault [_]
|
||||
(.preventDefault native-event))
|
||||
(dom/prevent-default native-event))
|
||||
|
||||
(stopPropagation [_]
|
||||
(.stopPropagation native-event)))
|
||||
(dom/stop-propagation native-event)))
|
||||
|
||||
(defn keyboard-event?
|
||||
[o]
|
||||
|
||||
51
manage.sh
51
manage.sh
@@ -7,7 +7,7 @@ export DEVENV_PNAME="penpotdev";
|
||||
export CURRENT_USER_ID=$(id -u);
|
||||
export CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD);
|
||||
|
||||
export IMAGEMAGICK_VERSION=7.1.2-0
|
||||
export IMAGEMAGICK_VERSION=7.1.2-13
|
||||
|
||||
# Safe directory to avoid ownership errors with Git
|
||||
git config --global --add safe.directory /home/penpot/penpot || true
|
||||
@@ -124,7 +124,7 @@ function run-devenv-shell {
|
||||
docker exec -ti \
|
||||
-e JAVA_OPTS="$JAVA_OPTS" \
|
||||
-e EXTERNAL_UID=$CURRENT_USER_ID \
|
||||
penpot-devenv-main sudo -EH -u penpot bash;
|
||||
penpot-devenv-main sudo -EH -u penpot $@
|
||||
}
|
||||
|
||||
function run-devenv-isolated-shell {
|
||||
@@ -138,7 +138,7 @@ function run-devenv-isolated-shell {
|
||||
-e SHADOWCLJS_EXTRA_PARAMS=$SHADOWCLJS_EXTRA_PARAMS \
|
||||
-e JAVA_OPTS="$JAVA_OPTS" \
|
||||
-w /home/penpot/penpot/$1 \
|
||||
$DEVENV_IMGNAME:latest sudo -EH -u penpot bash
|
||||
$DEVENV_IMGNAME:latest sudo -EH -u penpot $@
|
||||
}
|
||||
|
||||
function build-imagemagick-docker-image {
|
||||
@@ -215,6 +215,23 @@ function build-frontend-bundle {
|
||||
echo ">> bundle frontend end";
|
||||
}
|
||||
|
||||
function build-mcp-bundle {
|
||||
echo ">> bundle mcp start";
|
||||
|
||||
mkdir -p ./bundles
|
||||
local version=$(print-current-version);
|
||||
local bundle_dir="./bundles/mcp";
|
||||
|
||||
build "mcp";
|
||||
|
||||
rm -rf $bundle_dir;
|
||||
mv ./mcp/dist $bundle_dir;
|
||||
echo $version > $bundle_dir/version.txt;
|
||||
put-license-file $bundle_dir;
|
||||
echo ">> bundle mcp end";
|
||||
}
|
||||
|
||||
|
||||
function build-backend-bundle {
|
||||
echo ">> bundle backend start";
|
||||
|
||||
@@ -309,6 +326,16 @@ function build-exporter-docker-image {
|
||||
popd;
|
||||
}
|
||||
|
||||
function build-mcp-docker-image {
|
||||
rsync -avr --delete ./bundles/mcp/ ./docker/images/bundle-mcp/;
|
||||
pushd ./docker/images;
|
||||
docker build \
|
||||
-t penpotapp/mcp:$CURRENT_BRANCH -t penpotapp/mcp:latest \
|
||||
--build-arg BUNDLE_PATH="./bundle-mcp/" \
|
||||
-f Dockerfile.mcp .;
|
||||
popd;
|
||||
}
|
||||
|
||||
function build-storybook-docker-image {
|
||||
rsync -avr --delete ./bundles/storybook/ ./docker/images/bundle-storybook/;
|
||||
pushd ./docker/images;
|
||||
@@ -335,17 +362,19 @@ function usage {
|
||||
echo "- isolated-shell Starts a bash shell in a new devenv container."
|
||||
echo "- log-devenv Show logs of the running devenv docker compose service."
|
||||
echo ""
|
||||
echo "- build-bundle Build all bundles (frontend, backend and exporter)."
|
||||
echo "- build-bundle Build all bundles (frontend, backend, exporter, storybook and mcp)."
|
||||
echo "- build-frontend-bundle Build frontend bundle"
|
||||
echo "- build-backend-bundle Build backend bundle."
|
||||
echo "- build-exporter-bundle Build exporter bundle."
|
||||
echo "- build-storybook-bundle Build storybook bundle."
|
||||
echo "- build-mcp-bundle Build mcp bundle."
|
||||
echo "- build-docs-bundle Build docs bundle."
|
||||
echo ""
|
||||
echo "- build-docker-images Build all docker images (frontend, backend and exporter)."
|
||||
echo "- build-frontend-docker-image Build frontend docker images."
|
||||
echo "- build-backend-docker-image Build backend docker images."
|
||||
echo "- build-exporter-docker-image Build exporter docker images."
|
||||
echo "- build-mcp-docker-image Build exporter docker images."
|
||||
echo "- build-storybook-docker-image Build storybook docker images."
|
||||
echo ""
|
||||
echo "- version Show penpot's version."
|
||||
@@ -397,6 +426,7 @@ case $1 in
|
||||
## production builds
|
||||
build-bundle)
|
||||
build-frontend-bundle;
|
||||
build-mcp-bundle;
|
||||
build-backend-bundle;
|
||||
build-exporter-bundle;
|
||||
build-storybook-bundle;
|
||||
@@ -406,6 +436,10 @@ case $1 in
|
||||
build-frontend-bundle;
|
||||
;;
|
||||
|
||||
build-mcp-bundle)
|
||||
build-mcp-bundle;
|
||||
;;
|
||||
|
||||
build-backend-bundle)
|
||||
build-backend-bundle;
|
||||
;;
|
||||
@@ -413,7 +447,7 @@ case $1 in
|
||||
build-exporter-bundle)
|
||||
build-exporter-bundle;
|
||||
;;
|
||||
|
||||
|
||||
build-storybook-bundle)
|
||||
build-storybook-bundle;
|
||||
;;
|
||||
@@ -431,6 +465,7 @@ case $1 in
|
||||
build-frontend-docker-image
|
||||
build-backend-docker-image
|
||||
build-exporter-docker-image
|
||||
build-mcp-docker-image
|
||||
build-storybook-docker-image
|
||||
;;
|
||||
|
||||
@@ -445,7 +480,11 @@ case $1 in
|
||||
build-exporter-docker-image)
|
||||
build-exporter-docker-image
|
||||
;;
|
||||
|
||||
|
||||
build-mcp-docker-image)
|
||||
build-mcp-docker-image
|
||||
;;
|
||||
|
||||
build-storybook-docker-image)
|
||||
build-storybook-docker-image
|
||||
;;
|
||||
|
||||
11
mcp/.gitignore
vendored
Normal file
11
mcp/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
.idea
|
||||
node_modules
|
||||
dist
|
||||
*.bak
|
||||
*.orig
|
||||
temp
|
||||
*.tsbuildinfo
|
||||
|
||||
# Log files
|
||||
logs/
|
||||
*.log
|
||||
7
mcp/.prettierignore
Normal file
7
mcp/.prettierignore
Normal file
@@ -0,0 +1,7 @@
|
||||
*.md
|
||||
*.json
|
||||
python-scripts/
|
||||
.serena/
|
||||
|
||||
# auto-generated files
|
||||
mcp-server/data/api_types.yml
|
||||
20
mcp/.prettierrc
Normal file
20
mcp/.prettierrc
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.yml",
|
||||
"options": {
|
||||
"tabWidth": 2
|
||||
}
|
||||
}
|
||||
],
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"quoteProps": "as-needed",
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"printWidth": 120,
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
1
mcp/.serena/.gitignore
vendored
Normal file
1
mcp/.serena/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/cache
|
||||
25
mcp/.serena/memories/code_style_conventions.md
Normal file
25
mcp/.serena/memories/code_style_conventions.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Code Style and Conventions
|
||||
|
||||
## General Principles
|
||||
- **Object-Oriented Design**: VERY IMPORTANT: Use idiomatic, object-oriented style with explicit abstractions
|
||||
- **Strategy Pattern**: Prefer explicitly typed interfaces over bare functions for non-trivial functionality
|
||||
- **Clean Architecture**: Tools implement a common interface for consistent registration and execution
|
||||
|
||||
## TypeScript Configuration
|
||||
- **Strict Mode**: All strict TypeScript options enabled
|
||||
- **Target**: ES2022
|
||||
- **Module System**: CommonJS
|
||||
- **Declaration Files**: Generated with source maps
|
||||
|
||||
## Naming Conventions
|
||||
- **Classes**: PascalCase (e.g., `ExeceuteCodeTool`, `PenpotMcpServer`)
|
||||
- **Interfaces**: PascalCase (e.g., `Tool`)
|
||||
- **Methods**: camelCase (e.g., `execute`, `registerTools`)
|
||||
- **Constants**: camelCase for readonly properties (e.g., `definition`)
|
||||
- **Files**: PascalCase for classes (e.g., `ExecuteCodeTool.ts`)
|
||||
|
||||
## Documentation Style
|
||||
- **JSDoc**: Use comprehensive JSDoc comments for classes, methods, and interfaces
|
||||
- **Description Format**: Initial elliptical phrase that defines *what* it is, followed by details
|
||||
- **Comment Style**: VERY IMPORTANT: Start with lowercase for comments of code blocks (unless lengthy explanation with multiple sentences)
|
||||
|
||||
91
mcp/.serena/memories/project_overview.md
Normal file
91
mcp/.serena/memories/project_overview.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Penpot MCP Project Overview - Updated
|
||||
|
||||
## Purpose
|
||||
This project is a Model Context Protocol (MCP) server for Penpot integration. It provides a TypeScript-based server that can be used to extend Penpot's functionality through custom tools with bidirectional WebSocket communication.
|
||||
|
||||
## Tech Stack
|
||||
- **Language**: TypeScript
|
||||
- **Runtime**: Node.js
|
||||
- **Framework**: MCP SDK (@modelcontextprotocol/sdk)
|
||||
- **Build Tool**: TypeScript Compiler (tsc) + esbuild
|
||||
- **Package Manager**: pnpm
|
||||
- **WebSocket**: ws library for real-time communication
|
||||
|
||||
## Project Structure
|
||||
```
|
||||
penpot-mcp/
|
||||
├── common/ # Shared type definitions
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # Exports for shared types
|
||||
│ │ └── types.ts # PluginTaskResult, request/response interfaces
|
||||
│ └── package.json # @penpot-mcp/common package
|
||||
├── mcp-server/ # Main MCP server implementation
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # Main server entry point
|
||||
│ │ ├── PenpotMcpServer.ts # Enhanced with request/response correlation
|
||||
│ │ ├── PluginTask.ts # Now supports result promises
|
||||
│ │ ├── tasks/ # PluginTask implementations
|
||||
│ │ └── tools/ # Tool implementations
|
||||
│ └── package.json # Includes @penpot-mcp/common dependency
|
||||
├── penpot-plugin/ # Penpot plugin with response capability
|
||||
│ ├── src/
|
||||
│ │ ├── main.ts # Enhanced WebSocket handling with response forwarding
|
||||
│ │ └── plugin.ts # Now sends task responses back to server
|
||||
│ └── package.json # Includes @penpot-mcp/common dependency
|
||||
└── prepare-api-docs # Python project for the generation of API docs
|
||||
```
|
||||
|
||||
## Key Tasks
|
||||
|
||||
### Adding a new Tool
|
||||
|
||||
1. Implement the tool class in `mcp-server/src/tools/` following the `Tool` interface.
|
||||
IMPORTANT: Do not catch any exceptions in the `executeCore` method. Let them propagate to be handled centrally.
|
||||
2. Register the tool in `PenpotMcpServer`.
|
||||
|
||||
Look at `PrintTextTool` as an example.
|
||||
|
||||
Many tools are linked to tasks that are handled in the plugin, i.e. they have an associated `PluginTask` implementation in `mcp-server/src/tasks/`.
|
||||
|
||||
### Adding a new PluginTask
|
||||
|
||||
1. Implement the input data interface for the task in `common/src/types.ts`.
|
||||
2. Implement the `PluginTask` class in `mcp-server/src/tasks/`.
|
||||
3. Implement the corresponding task handler class in the plugin (`penpot-plugin/src/task-handlers/`).
|
||||
* In the success case, call `task.sendSuccess`.
|
||||
* In the failure case, just throw an exception, which will be handled centrally!
|
||||
* Look at `PrintTextTaskHandler` as an example.
|
||||
4. Register the task handler in `penpot-plugin/src/plugin.ts` in the `taskHandlers` list.
|
||||
|
||||
|
||||
## Key Components
|
||||
|
||||
### Enhanced WebSocket Protocol
|
||||
- **Request Format**: `{id: string, task: string, params: any}`
|
||||
- **Response Format**: `{id: string, result: {success: boolean, error?: string, data?: any}}`
|
||||
- **Request/Response Correlation**: Using unique UUIDs for task tracking
|
||||
- **Timeout Handling**: 30-second timeout with automatic cleanup
|
||||
- **Type Safety**: Shared definitions via @penpot-mcp/common package
|
||||
|
||||
### Core Classes
|
||||
- **PenpotMcpServer**: Enhanced with pending task tracking and response handling
|
||||
- **PluginTask**: Now creates result promises that resolve when plugin responds
|
||||
- **Tool implementations**: Now properly await task completion and report results
|
||||
- **Plugin handlers**: Send structured responses back to server
|
||||
|
||||
### New Features
|
||||
1. **Bidirectional Communication**: Plugin now responds with success/failure status
|
||||
2. **Task Result Promises**: Every executePluginTask() sets and returns a promise
|
||||
3. **Error Reporting**: Failed tasks properly report error messages to tools
|
||||
4. **Shared Type Safety**: Common package ensures consistency across projects
|
||||
5. **Timeout Protection**: Tasks don't hang indefinitely (30s limit)
|
||||
6. **Request Correlation**: Unique IDs match requests to responses
|
||||
|
||||
## Task Flow
|
||||
|
||||
```
|
||||
LLM Tool Call → MCP Server → WebSocket (Request) → Plugin → Penpot API
|
||||
↑ ↓
|
||||
Tool Response ← MCP Server ← WebSocket (Response) ← Plugin Result
|
||||
```
|
||||
|
||||
70
mcp/.serena/memories/suggested_commands.md
Normal file
70
mcp/.serena/memories/suggested_commands.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Suggested Commands
|
||||
|
||||
## Development Commands
|
||||
```bash
|
||||
# Navigate to MCP server directory
|
||||
cd penpot/mcp/server
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Build the TypeScript project
|
||||
pnpm run build
|
||||
|
||||
# Start the server (production)
|
||||
pnpm run start
|
||||
|
||||
# Start the server in development mode
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
## Testing and Development
|
||||
```bash
|
||||
# Run TypeScript compiler in watch mode
|
||||
pnpx tsc --watch
|
||||
|
||||
# Check TypeScript compilation without emitting files
|
||||
pnpx tsc --noEmit
|
||||
```
|
||||
|
||||
## Windows-Specific Commands
|
||||
```cmd
|
||||
# Directory navigation
|
||||
cd penpot/mcp/server
|
||||
dir # List directory contents
|
||||
type package.json # Display file contents
|
||||
|
||||
# Git operations
|
||||
git status
|
||||
git add .
|
||||
git commit -m "message"
|
||||
git push
|
||||
|
||||
# File operations
|
||||
copy src\file.ts backup\file.ts # Copy files
|
||||
del dist\* # Delete files
|
||||
mkdir new-directory # Create directory
|
||||
rmdir /s directory # Remove directory recursively
|
||||
```
|
||||
|
||||
## Project Structure Navigation
|
||||
```bash
|
||||
# Key directories
|
||||
cd penpot/mcp/server/src # Source code
|
||||
cd penpot/mcp/server/src/tools # Tool implementations
|
||||
cd penpot/mcp/server/src/interfaces # Type definitions
|
||||
cd penpot/mcp/server/dist # Compiled output
|
||||
```
|
||||
|
||||
## Common Utilities
|
||||
```cmd
|
||||
# Search for text in files
|
||||
findstr /s /i "HelloWorld" *.ts
|
||||
|
||||
# Find files by name
|
||||
dir /s /b *Tool.ts
|
||||
|
||||
# Process management
|
||||
tasklist | findstr node # Find Node.js processes
|
||||
taskkill /f /im node.exe # Kill Node.js processes
|
||||
```
|
||||
56
mcp/.serena/memories/task_completion_guidelines.md
Normal file
56
mcp/.serena/memories/task_completion_guidelines.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Task Completion Guidelines
|
||||
|
||||
## After Making Code Changes
|
||||
|
||||
### 1. Build and Test
|
||||
```bash
|
||||
cd mcp-server
|
||||
npm run build:full # or npm run build for faster bundling only
|
||||
```
|
||||
|
||||
### 2. Verify TypeScript Compilation
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
### 3. Test the Server
|
||||
```bash
|
||||
# Start in development mode to test changes
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 4. Code Quality Checks
|
||||
- Ensure all code follows the established conventions
|
||||
- Verify JSDoc comments are complete and accurate
|
||||
- Check that error handling is appropriate
|
||||
- Use clean imports WITHOUT file extensions (esbuild handles resolution)
|
||||
- Validate that tool interfaces are properly implemented
|
||||
|
||||
### 5. Integration Testing
|
||||
- Test tool registration in the main server
|
||||
- Verify MCP protocol compliance
|
||||
- Ensure tool definitions match implementation
|
||||
|
||||
## Before Committing Changes
|
||||
1. **Build Successfully**: `npm run build:full` completes without errors
|
||||
2. **No TypeScript Errors**: `npx tsc --noEmit` passes
|
||||
3. **Documentation Updated**: JSDoc comments reflect changes
|
||||
4. **Tool Registry Updated**: New tools added to `registerTools()` method
|
||||
5. **Interface Compliance**: All tools implement the `Tool` interface correctly
|
||||
|
||||
## File Organization
|
||||
- Place new tools in `src/tools/` directory
|
||||
- Update main server registration in `src/index.ts`
|
||||
- Follow existing naming conventions
|
||||
|
||||
## Common Patterns
|
||||
- All tools must implement the `Tool` interface
|
||||
- Use readonly properties for tool definitions
|
||||
- Include comprehensive error handling
|
||||
- Follow the established documentation style
|
||||
- Import WITHOUT file extensions (esbuild resolves them automatically)
|
||||
|
||||
## Build System
|
||||
- Uses esbuild for fast bundling and TypeScript for declarations
|
||||
- Import statements should omit file extensions entirely
|
||||
- IDE refactoring is safe - no extension-related build failures
|
||||
130
mcp/.serena/project.yml
Normal file
130
mcp/.serena/project.yml
Normal file
@@ -0,0 +1,130 @@
|
||||
|
||||
|
||||
# whether to use the project's gitignore file to ignore files
|
||||
# Added on 2025-04-07
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# list of additional paths to ignore
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||
# Added (renamed) on 2025-04-07
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: |
|
||||
IMPORTANT: You use an idiomatic, object-oriented style.
|
||||
In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions
|
||||
rather than mere functions (i.e. use the strategy pattern, for example).
|
||||
|
||||
Comments:
|
||||
When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase
|
||||
clearly defines *what* it is. Any details then follow in subsequent sentences.
|
||||
|
||||
When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless
|
||||
the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is
|
||||
required for sentences).
|
||||
# the name by which the project can be referenced within Serena
|
||||
project_name: "penpot-mcp"
|
||||
|
||||
# list of mode names to that are always to be included in the set of active modes
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this setting overrides the global configuration.
|
||||
# Set this to [] to disable base modes for this project.
|
||||
# Set this to a list of mode names to always include the respective modes for this project.
|
||||
base_modes:
|
||||
|
||||
# list of mode names that are to be activated by default.
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||
default_modes:
|
||||
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
|
||||
included_optional_tools: []
|
||||
|
||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||
fixed_tools: []
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: utf-8
|
||||
|
||||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al bash clojure cpp csharp
|
||||
# csharp_omnisharp dart elixir elm erlang
|
||||
# fortran fsharp go groovy haskell
|
||||
# java julia kotlin lua markdown
|
||||
# matlab nix pascal perl php
|
||||
# powershell python python_jedi r rego
|
||||
# ruby ruby_solargraph rust scala swift
|
||||
# terraform toml typescript typescript_vts vue
|
||||
# yaml zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some languages require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- typescript
|
||||
291
mcp/README.md
Normal file
291
mcp/README.md
Normal file
@@ -0,0 +1,291 @@
|
||||

|
||||
|
||||
# Penpot's Official MCP Server
|
||||
|
||||
Penpot integrates a LLM layer built on the Model Context Protocol
|
||||
(MCP) via Penpot's Plugin API to interact with a Penpot design
|
||||
file. Penpot's MCP server enables LLMs to perfom data queries,
|
||||
transformation and creation operations.
|
||||
|
||||
Penpot's MCP Server is unlike any other you've seen. You get
|
||||
design-to- design, code-to-design and design-code supercharged
|
||||
workflows.
|
||||
|
||||
|
||||
[](https://www.youtube.com/playlist?list=PLgcCPfOv5v57SKMuw1NmS0-lkAXevpn10)
|
||||
|
||||
|
||||
## Architecture
|
||||
|
||||
The **Penpot MCP Server** exposes tools to AI clients (LLMs), which
|
||||
support the retrieval of design data as well as the modification and
|
||||
creation of design elements. The MCP server communicates with Penpot
|
||||
via the dedicated **Penpot MCP Plugin**,
|
||||
which connects to the MCP server via WebSocket.
|
||||
This enables the LLM to carry out tasks in the context of a design file by
|
||||
executing code that leverages the Penpot Plugin API.
|
||||
The LLM is free to write and execute arbitrary code snippets
|
||||
within the Penpot Plugin environment to accomplish its tasks.
|
||||
|
||||

|
||||
|
||||
This repository thus contains not only the MCP server implementation itself
|
||||
but also the supporting Penpot MCP Plugin
|
||||
(see section [Repository Structure](#repository-structure) below).
|
||||
|
||||
## Demonstration
|
||||
|
||||
[](https://v32155.1blu.de/penpot/PenpotFest2025.mp4)
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
To use the Penpot MCP server, you must
|
||||
* run the MCP server and connect your AI client to it,
|
||||
* run the web server providing the Penpot MCP plugin, and
|
||||
* open the Penpot MCP plugin in Penpot and connect it to the MCP server.
|
||||
|
||||
Follow the steps below to enable the integration.
|
||||
|
||||
|
||||
### Prerequisites
|
||||
|
||||
The project requires [Node.js](https://nodejs.org/) (tested with v22.x
|
||||
with corepack).
|
||||
|
||||
Following the installation of Node.js, the tools `pnpm` and `npx`
|
||||
should be available in your terminal. For ensure corepack installed
|
||||
and enabled correctly, just execute the `./scripts/setup`.
|
||||
|
||||
It is also required to have `caddy` executeable in the path, it is
|
||||
used for start a local server for generate types documentation from
|
||||
the current branch. If you want to run it outside devenv where all
|
||||
dependencies are already provided, please download caddy from
|
||||
[here](https://caddyserver.com/download).
|
||||
|
||||
You should probably be using penpot devenv, where all this
|
||||
dependencies are already present and correctly setup. But nothing
|
||||
prevents you execute this outside of devenv if you satisfy the
|
||||
specified dependencies.
|
||||
|
||||
|
||||
### 1. Build & Launch the MCP Server and the Plugin Server
|
||||
|
||||
If it's your first execution, install the required dependencies:
|
||||
|
||||
```shell
|
||||
cd mcp/
|
||||
./scripts/setup
|
||||
```
|
||||
|
||||
Then build all components and start the two servers:
|
||||
|
||||
```shell
|
||||
pnpm run bootstrap
|
||||
```
|
||||
|
||||
This bootstrap command will:
|
||||
|
||||
* install dependencies for all components (`pnpm -r run install`)
|
||||
* build all components (`pnpm -r run build`)
|
||||
* start all components (`pnpm -r --parallel run start`)
|
||||
|
||||
If you want to have types scrapped from a remote repository, the best
|
||||
approach is executing the following:
|
||||
|
||||
```shell
|
||||
PENPOT_PLUGINS_API_DOC_URL=https://doc.plugins.penpot.app pnpm run build:types
|
||||
pnpm run bootstrap
|
||||
```
|
||||
|
||||
Or this, if you want skip build step bacause you have already have all
|
||||
build artifacts ready (per example from previous `bootstrap` command):
|
||||
|
||||
```
|
||||
PENPOT_PLUGINS_API_DOC_URL=https://doc.plugins.penpot.app pnpm run build:types
|
||||
pnpm run start
|
||||
```
|
||||
|
||||
If you want just to update the types definitions with the plugins api doc from the
|
||||
current branch:
|
||||
|
||||
```shell
|
||||
pnpm run build:types
|
||||
```
|
||||
|
||||
(That command will build plugins doc locally and will generate the types yaml from
|
||||
the locally build documentation)
|
||||
|
||||
### 2. Load the Plugin in Penpot and Establish the Connection
|
||||
|
||||
> [!NOTE]
|
||||
> **Browser Connectivity Restrictions**
|
||||
>
|
||||
> Starting with Chromium version 142, the private network access (PNA) restrictions have been hardened,
|
||||
> and when connecting to `localhost` from a web application served from a different origin
|
||||
> (such as https://design.penpot.app), the connection must explicitly be allowed.
|
||||
>
|
||||
> Most Chromium-based browsers (e.g. Chrome, Vivaldi) will display a popup requesting permission
|
||||
> to access the local network. Be sure to approve the request to allow the connection.
|
||||
>
|
||||
> Some browsers take additional security measures, and you may need to disable them.
|
||||
> For example, in Brave, disable the "Shield" for the Penpot website to allow local network access.
|
||||
>
|
||||
> If your browser refuses to connect to the locally served plugin, check its configuration or
|
||||
> try a different browser (e.g. Firefox) that does not enforce these restrictions.
|
||||
|
||||
1. Open Penpot in your browser
|
||||
2. Navigate to a design file
|
||||
3. Open the Plugins menu
|
||||
4. Load the plugin using the development URL (`http://localhost:4400/manifest.json` by default)
|
||||
5. Open the plugin UI
|
||||
6. In the plugin UI, click "Connect to MCP server".
|
||||
The connection status should change from "Not connected" to "Connected to MCP server".
|
||||
(Check the browser's developer console for WebSocket connection logs.
|
||||
Check the MCP server terminal for WebSocket connection messages.)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Do not close the plugin's UI while using the MCP server, as this will close the connection.
|
||||
|
||||
### 3. Connect an MCP Client
|
||||
|
||||
By default, the server runs on port 4401 and provides:
|
||||
|
||||
- **Modern Streamable HTTP endpoint**: `http://localhost:4401/mcp`
|
||||
- **Legacy SSE endpoint**: `http://localhost:4401/sse`
|
||||
|
||||
These endpoints can be used directly by MCP clients that support them.
|
||||
Simply configure the client to connect the MCP server by providing the respective URL.
|
||||
|
||||
When using a client that only supports stdio transport,
|
||||
a proxy like `mcp-remote` is required.
|
||||
|
||||
#### Using a Proxy for stdio Transport
|
||||
|
||||
NOTE: only relevant if you are executing this outside of devenv
|
||||
|
||||
The `mcp-remote` package can proxy stdio transport to HTTP/SSE,
|
||||
allowing clients that support only stdio to connect to the MCP server indirectly.
|
||||
|
||||
1. Install `mcp-remote` globally if you haven't already:
|
||||
|
||||
npm install -g mcp-remote
|
||||
|
||||
2. Use `mcp-remote` to provide the launch command for your MCP client:
|
||||
|
||||
npx -y mcp-remote http://localhost:4401/sse --allow-http
|
||||
|
||||
#### Example: Claude Desktop
|
||||
|
||||
For Windows and macOS, there is the official [Claude Desktop app](https://claude.ai/download), which you can use as an MCP client.
|
||||
For Linux, there is an [unofficial community version](https://github.com/aaddrick/claude-desktop-debian).
|
||||
|
||||
Since Claude Desktop natively supports only stdio transport, you will need to use a proxy like `mcp-remote`.
|
||||
Install it as described above.
|
||||
|
||||
To add the server to Claude Desktop's configuration, locate the configuration file (or find it via Menu / File / Settings / Developer):
|
||||
|
||||
- **Windows**: `%APPDATA%/Claude/claude_desktop_config.json`
|
||||
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||
- **Linux**: `~/.config/Claude/claude_desktop_config.json`
|
||||
|
||||
Add a `penpot` entry under `mcpServers` with the following content:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"penpot": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-remote", "http://localhost:4401/sse", "--allow-http"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After updating the configuration file, restart Claude Desktop completely for the changes to take effect.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Be sure to fully quit the app for the changes to take effect; closing the window is *not* sufficient.
|
||||
> To fully terminate the app, choose Menu / File / Quit.
|
||||
|
||||
After the restart, you should see the MCP server listed when clicking on the "Search and tools" icon at the bottom
|
||||
of the prompt input area.
|
||||
|
||||
#### Example: Claude Code
|
||||
|
||||
To add the Penpot MCP server to a Claude Code project, issue the command
|
||||
|
||||
claude mcp add penpot -t http http://localhost:4401/mcp
|
||||
|
||||
## Repository Structure
|
||||
|
||||
This repository is a monorepo containing four main components:
|
||||
|
||||
1. **Common Types** (`common/`):
|
||||
- Shared TypeScript definitions for request/response protocol
|
||||
- Ensures type safety across server and plugin components
|
||||
|
||||
2. **Penpot MCP Server** (`mcp-server/`):
|
||||
- Provides MCP tools to LLMs for Penpot interaction
|
||||
- Runs a WebSocket server accepting connections from the Penpot MCP plugin
|
||||
- Implements request/response correlation with unique task IDs
|
||||
- Handles task timeouts and proper error reporting
|
||||
|
||||
3. **Penpot MCP Plugin** (`penpot-plugin/`):
|
||||
- Connects to the MCP server via WebSocket
|
||||
- Executes tasks in Penpot using the Plugin API
|
||||
- Sends structured responses back to the server#
|
||||
|
||||
4. **Helper Scripts** (`python-scripts/`):
|
||||
- Python scripts that prepare data for the MCP server (development use)
|
||||
|
||||
The core components are written in TypeScript, rendering interactions with the
|
||||
Penpot Plugin API both natural and type-safe.
|
||||
|
||||
## Configuration
|
||||
|
||||
The Penpot MCP server can be configured using environment variables. All configuration
|
||||
options use the `PENPOT_MCP_` prefix for consistency.
|
||||
|
||||
### Server Configuration
|
||||
|
||||
| Environment Variable | Description | Default |
|
||||
|------------------------------------|----------------------------------------------------------------------------|--------------|
|
||||
| `PENPOT_MCP_SERVER_LISTEN_ADDRESS` | Address on which the MCP server listens (binds to) | `localhost` |
|
||||
| `PENPOT_MCP_SERVER_PORT` | Port for the HTTP/SSE server | `4401` |
|
||||
| `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` |
|
||||
| `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` |
|
||||
| `PENPOT_MCP_SERVER_ADDRESS` | Hostname or IP address via which clients can reach the MCP server | `localhost` |
|
||||
| `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` |
|
||||
|
||||
### Logging Configuration
|
||||
|
||||
| Environment Variable | Description | Default |
|
||||
|------------------------|------------------------------------------------------|----------|
|
||||
| `PENPOT_MCP_LOG_LEVEL` | Log level: `trace`, `debug`, `info`, `warn`, `error` | `info` |
|
||||
| `PENPOT_MCP_LOG_DIR` | Directory for log files | `logs` |
|
||||
|
||||
### Plugin Server Configuration
|
||||
|
||||
| Environment Variable | Description | Default |
|
||||
|-------------------------------------------|-----------------------------------------------------------------------------------------|--------------|
|
||||
| `PENPOT_MCP_PLUGIN_SERVER_LISTEN_ADDRESS` | Address on which the plugin web server listens (single address or comma-separated list) | (local only) |
|
||||
|
||||
## Beyond Local Execution
|
||||
|
||||
The above instructions describe how to run the MCP server and plugin server locally.
|
||||
We are working on enabling remote deployments of the MCP server, particularly
|
||||
in [multi-user mode](docs/multi-user-mode.md), where multiple Penpot users will
|
||||
be able to connect to the same MCP server instance.
|
||||
|
||||
To run the server remotely (even for a single user),
|
||||
you may set the following environment variables to configure the two servers
|
||||
(MCP server & plugin server) appropriately:
|
||||
* `PENPOT_MCP_REMOTE_MODE=true`: This ensures that the MCP server is operating
|
||||
in remote mode, with local file system access disabled.
|
||||
* `PENPOT_MCP_SERVER_LISTEN_ADDRESS` and `PENPOT_MCP_PLUGIN_SERVER_LISTEN_ADDRESS`:
|
||||
Set these according to your requirements for remote connectivity.
|
||||
To bind all interfaces, use `0.0.0.0` (use caution in untrusted networks).
|
||||
* `PENPOT_MCP_SERVER_ADDRESS=<your-address>`: This sets the hostname or IP address
|
||||
where the MCP server can be reached. The Penpot MCP Plugin uses this to construct
|
||||
the WebSocket URL as `ws://<your-address>:<port>` (default port: `4402`).
|
||||
41
mcp/docs/multi-user-mode.md
Normal file
41
mcp/docs/multi-user-mode.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Multi-User Mode
|
||||
|
||||
> [!WARNING]
|
||||
> Multi-user mode is under development and not yet fully integrated.
|
||||
> This information is provided for testing purposes only.
|
||||
|
||||
The Penpot MCP server supports a multi-user mode, allowing multiple Penpot users
|
||||
to connect to the same MCP server instance simultaneously.
|
||||
This supports remote deployments of the MCP server, without requiring each user
|
||||
to run their own server instance.
|
||||
|
||||
## Limitations
|
||||
|
||||
Multi-user mode has the limitation that tools which read from or write to
|
||||
the local file system are not supported, as the server cannot access
|
||||
the client's file system. This affects the import and export tools.
|
||||
|
||||
## Running Components in Multi-User Mode
|
||||
|
||||
To run the MCP server and the Penpot MCP plugin in multi-user mode (for testing),
|
||||
you can use the following command:
|
||||
|
||||
```shell
|
||||
npm run bootstrap:multi-user
|
||||
```
|
||||
|
||||
This will:
|
||||
* launch the MCP server in multi-user mode (adding the `--multi-user` flag),
|
||||
* build and launch the Penpot MCP plugin server in multi-user mode.
|
||||
|
||||
See the package.json scripts for both `mcp-server` and `penpot-plugin` for details.
|
||||
|
||||
In multi-user mode, users are required to be authenticated via a token.
|
||||
|
||||
* This token is provided in the URL used to connect to the MCP server,
|
||||
e.g. `http://localhost:4401/mcp?userToken=USER_TOKEN`.
|
||||
* The same token must be provided when connecting the Penpot MCP plugin
|
||||
to the MCP server.
|
||||
In the future, the token will, most likely be generated by Penpot and
|
||||
provided to the plugin automatically.
|
||||
:warning: For now, it is hard-coded in the plugin's source code for testing purposes.
|
||||
26
mcp/package.json
Normal file
26
mcp/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "mcp-meta",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"build": "pnpm -r run build",
|
||||
"build:multi-user": "pnpm -r run build:multi-user",
|
||||
"build:types": "./scripts/build-types",
|
||||
"start": "pnpm -r --parallel run start",
|
||||
"start:multi-user": "pnpm -r --parallel --filter \"./packages/*\" run start:multi-user",
|
||||
"bootstrap": "pnpm -r install && pnpm run build && pnpm run start",
|
||||
"bootstrap:multi-user": "pnpm -r install && pnpm run build:multi-user && pnpm run start:multi-user",
|
||||
"fmt": "prettier --write packages/",
|
||||
"fmt:check": "prettier --check packages/"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/penpot/penpot.git"
|
||||
},
|
||||
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1",
|
||||
"prettier": "^3.0.0"
|
||||
}
|
||||
}
|
||||
20
mcp/packages/common/package.json
Normal file
20
mcp/packages/common/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "mcp-common",
|
||||
"version": "1.0.0",
|
||||
"description": "Shared type definitions and interfaces for Penpot MCP",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
|
||||
"scripts": {
|
||||
"build": "tsc --build --clean && tsc --build",
|
||||
"watch": "tsc --watch",
|
||||
"types:check": "tsc --noEmit",
|
||||
"clean": "rm -rf dist/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
]
|
||||
}
|
||||
1
mcp/packages/common/src/index.ts
Normal file
1
mcp/packages/common/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./types";
|
||||
85
mcp/packages/common/src/types.ts
Normal file
85
mcp/packages/common/src/types.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Result of a plugin task execution.
|
||||
*
|
||||
* Contains the outcome status of a task and any additional result data.
|
||||
*/
|
||||
export interface PluginTaskResult<T> {
|
||||
/**
|
||||
* Optional result data from the task execution.
|
||||
*/
|
||||
data?: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request message sent from server to plugin.
|
||||
*
|
||||
* Contains a unique identifier, task name, and parameters for execution.
|
||||
*/
|
||||
export interface PluginTaskRequest {
|
||||
/**
|
||||
* Unique identifier for request/response correlation.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The name of the task to execute.
|
||||
*/
|
||||
task: string;
|
||||
|
||||
/**
|
||||
* The parameters for task execution.
|
||||
*/
|
||||
params: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response message sent from plugin back to server.
|
||||
*
|
||||
* Contains the original request ID and the execution result.
|
||||
*/
|
||||
export interface PluginTaskResponse<T> {
|
||||
/**
|
||||
* Unique identifier matching the original request.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Whether the task completed successfully.
|
||||
*/
|
||||
success: boolean;
|
||||
|
||||
/**
|
||||
* Optional error message if the task failed.
|
||||
*/
|
||||
error?: string;
|
||||
|
||||
/**
|
||||
* The result of the task execution.
|
||||
*/
|
||||
data?: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for the executeCode task.
|
||||
*/
|
||||
export interface ExecuteCodeTaskParams {
|
||||
/**
|
||||
* The JavaScript code to be executed.
|
||||
*/
|
||||
code: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result data for the executeCode task.
|
||||
*/
|
||||
export interface ExecuteCodeTaskResultData<T> {
|
||||
/**
|
||||
* The result of the executed code, if any.
|
||||
*/
|
||||
result: T;
|
||||
|
||||
/**
|
||||
* Captured console output during code execution.
|
||||
*/
|
||||
log: string;
|
||||
}
|
||||
19
mcp/packages/common/tsconfig.json
Normal file
19
mcp/packages/common/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
24
mcp/packages/plugin/.gitignore
vendored
Normal file
24
mcp/packages/plugin/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
21
mcp/packages/plugin/README.md
Normal file
21
mcp/packages/plugin/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Penpot MCP Plugin
|
||||
|
||||
This project contains a Penpot plugin that accompanies the Penpot MCP server.
|
||||
It connects to the MCP server via WebSocket, subsequently allowing the MCP
|
||||
server to execute tasks in Penpot using the Plugin API.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install Dependencies
|
||||
|
||||
pnpm install
|
||||
|
||||
2. Build the Project
|
||||
|
||||
pnpm run build
|
||||
|
||||
3. Start a Local Development Server
|
||||
|
||||
pnpm run start
|
||||
|
||||
This will start a local development server at `http://localhost:4400`.
|
||||
15
mcp/packages/plugin/index.html
Normal file
15
mcp/packages/plugin/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Penpot plugin example</title>
|
||||
</head>
|
||||
<body>
|
||||
<button type="button" data-appearance="secondary" data-handler="connect-mcp">Connect to MCP server</button>
|
||||
|
||||
<div id="connection-status" style="margin-top: 10px; font-size: 12px; color: #666">Not connected</div>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
24
mcp/packages/plugin/package.json
Normal file
24
mcp/packages/plugin/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "mcp-plugin",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite build --watch --config vite.config.ts",
|
||||
"start:multi-user": "cross-env MULTI_USER_MODE=true vite build --watch --config vite.config.ts",
|
||||
"build": "tsc && vite build --config vite.release.config.ts",
|
||||
"build:multi-user": "tsc && cross-env MULTI_USER_MODE=true vite build --config vite.release.config.ts",
|
||||
"types:check": "tsc --noEmit",
|
||||
"clean": "rm -rf dist/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@penpot/plugin-styles": "1.4.1",
|
||||
"@penpot/plugin-types": "1.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.8",
|
||||
"vite-live-preview": "^0.3.2"
|
||||
}
|
||||
}
|
||||
7
mcp/packages/plugin/public/manifest.json
Normal file
7
mcp/packages/plugin/public/manifest.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "Penpot MCP Plugin",
|
||||
"code": "plugin.js",
|
||||
"version": 2,
|
||||
"description": "This plugin enables interaction with the Penpot MCP server",
|
||||
"permissions": ["content:read", "content:write", "library:read", "library:write", "comment:read", "comment:write"]
|
||||
}
|
||||
425
mcp/packages/plugin/src/PenpotUtils.ts
Normal file
425
mcp/packages/plugin/src/PenpotUtils.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import { Board, Fill, FlexLayout, GridLayout, Page, Rectangle, Shape } from "@penpot/plugin-types";
|
||||
|
||||
export class PenpotUtils {
|
||||
/**
|
||||
* Generates an overview structure of the given shape,
|
||||
* providing its id, name and type, and recursively its children's attributes.
|
||||
* The `type` field indicates the type in the Penpot API.
|
||||
* If the shape has a layout system (flex or grid), includes layout information.
|
||||
*
|
||||
* @param shape - The root shape to generate the structure from
|
||||
* @param maxDepth - Optional maximum depth to traverse (leave undefined for unlimited)
|
||||
* @returns An object representing the shape structure
|
||||
*/
|
||||
public static shapeStructure(shape: Shape, maxDepth: number | undefined = undefined): object {
|
||||
let children = undefined;
|
||||
if (maxDepth === undefined || maxDepth > 0) {
|
||||
if ("children" in shape && shape.children) {
|
||||
children = shape.children.map((child) =>
|
||||
this.shapeStructure(child, maxDepth === undefined ? undefined : maxDepth - 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result: any = {
|
||||
id: shape.id,
|
||||
name: shape.name,
|
||||
type: shape.type,
|
||||
children: children,
|
||||
};
|
||||
|
||||
// add layout information if present
|
||||
if ("flex" in shape && shape.flex) {
|
||||
const flex: FlexLayout = shape.flex;
|
||||
result.layout = {
|
||||
type: "flex",
|
||||
dir: flex.dir,
|
||||
rowGap: flex.rowGap,
|
||||
columnGap: flex.columnGap,
|
||||
};
|
||||
} else if ("grid" in shape && shape.grid) {
|
||||
const grid: GridLayout = shape.grid;
|
||||
result.layout = {
|
||||
type: "grid",
|
||||
rows: grid.rows,
|
||||
columns: grid.columns,
|
||||
rowGap: grid.rowGap,
|
||||
columnGap: grid.columnGap,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all shapes that matches the given predicate in the given shape tree.
|
||||
*
|
||||
* @param predicate - A function that takes a shape and returns true if it matches the criteria
|
||||
* @param root - The root shape to start the search from (defaults to penpot.root)
|
||||
*/
|
||||
public static findShapes(predicate: (shape: Shape) => boolean, root: Shape | null = penpot.root): Shape[] {
|
||||
let result = new Array<Shape>();
|
||||
|
||||
let find = function (shape: Shape | null) {
|
||||
if (!shape) {
|
||||
return;
|
||||
}
|
||||
if (predicate(shape)) {
|
||||
result.push(shape);
|
||||
}
|
||||
if ("children" in shape && shape.children) {
|
||||
for (let child of shape.children) {
|
||||
find(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
find(root);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the first shape that matches the given predicate in the given shape tree.
|
||||
*
|
||||
* @param predicate - A function that takes a shape and returns true if it matches the criteria
|
||||
* @param root - The root shape to start the search from (if null, searches all pages)
|
||||
*/
|
||||
public static findShape(predicate: (shape: Shape) => boolean, root: Shape | null = null): Shape | null {
|
||||
let find = function (shape: Shape | null): Shape | null {
|
||||
if (!shape) {
|
||||
return null;
|
||||
}
|
||||
if (predicate(shape)) {
|
||||
return shape;
|
||||
}
|
||||
if ("children" in shape && shape.children) {
|
||||
for (let child of shape.children) {
|
||||
let result = find(child);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (root === null) {
|
||||
const pages = penpot.currentFile?.pages;
|
||||
if (pages) {
|
||||
for (let page of pages) {
|
||||
let result = find(page.root);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
return find(root);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a shape by its unique ID.
|
||||
*
|
||||
* @param id - The unique ID of the shape to find
|
||||
* @returns The shape with the matching ID, or null if not found
|
||||
*/
|
||||
public static findShapeById(id: string): Shape | null {
|
||||
return this.findShape((shape) => shape.id === id);
|
||||
}
|
||||
|
||||
public static findPage(predicate: (page: Page) => boolean): Page | null {
|
||||
let page = penpot.currentFile!.pages.find(predicate);
|
||||
return page || null;
|
||||
}
|
||||
|
||||
public static getPages(): { id: string; name: string }[] {
|
||||
return penpot.currentFile!.pages.map((page) => ({ id: page.id, name: page.name }));
|
||||
}
|
||||
|
||||
public static getPageById(id: string): Page | null {
|
||||
return this.findPage((page) => page.id === id);
|
||||
}
|
||||
|
||||
public static getPageByName(name: string): Page | null {
|
||||
return this.findPage((page) => page.name.toLowerCase() === name.toLowerCase());
|
||||
}
|
||||
|
||||
public static getPageForShape(shape: Shape): Page | null {
|
||||
for (const page of penpot.currentFile!.pages) {
|
||||
if (page.getShapeById(shape.id)) {
|
||||
return page;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static generateCss(shape: Shape): string {
|
||||
const page = this.getPageForShape(shape);
|
||||
if (!page) {
|
||||
throw new Error("Shape is not part of any page");
|
||||
}
|
||||
penpot.openPage(page);
|
||||
return penpot.generateStyle([shape], { type: "css", includeChildren: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a child shape is fully contained within its parent's bounds.
|
||||
* Visual containment means all edges of the child are within the parent's bounding box.
|
||||
*
|
||||
* @param child - The child shape to check
|
||||
* @param parent - The parent shape to check against
|
||||
* @returns true if child is fully contained within parent bounds, false otherwise
|
||||
*/
|
||||
public static isContainedIn(child: Shape, parent: Shape): boolean {
|
||||
return (
|
||||
child.x >= parent.x &&
|
||||
child.y >= parent.y &&
|
||||
child.x + child.width <= parent.x + parent.width &&
|
||||
child.y + child.height <= parent.y + parent.height
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the position of a shape relative to its parent's position.
|
||||
* This is a convenience method since parentX and parentY are read-only properties.
|
||||
*
|
||||
* @param shape - The shape to position
|
||||
* @param parentX - The desired X position relative to the parent
|
||||
* @param parentY - The desired Y position relative to the parent
|
||||
* @throws Error if the shape has no parent
|
||||
*/
|
||||
public static setParentXY(shape: Shape, parentX: number, parentY: number): void {
|
||||
if (!shape.parent) {
|
||||
throw new Error("Shape has no parent - cannot set parent-relative position");
|
||||
}
|
||||
shape.x = shape.parent.x + parentX;
|
||||
shape.y = shape.parent.y + parentY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a flex layout to a container while preserving the visual order of existing children.
|
||||
* Without this, adding a flex layout can arbitrarily reorder children.
|
||||
*
|
||||
* The method sorts children by their current position (x for "row", y for "column") before
|
||||
* adding the layout, then reorders them to maintain that visual sequence.
|
||||
*
|
||||
* @param container - The container (board) to add the flex layout to
|
||||
* @param dir - The layout direction: "row" for horizontal, "column" for vertical
|
||||
* @returns The created FlexLayout instance
|
||||
*/
|
||||
public static addFlexLayout(container: Board, dir: "column" | "row"): FlexLayout {
|
||||
// obtain children sorted by position (ascending)
|
||||
const children = "children" in container && container.children ? [...container.children] : [];
|
||||
const sortedChildren = children.sort((a, b) => (dir === "row" ? a.x - b.x : a.y - b.y));
|
||||
|
||||
// add the flex layout
|
||||
const flexLayout = container.addFlexLayout();
|
||||
flexLayout.dir = dir;
|
||||
|
||||
// reorder children to preserve visual order; since the children array is reversed
|
||||
// relative to visual order for dir="column" or dir="row", we insert each child at
|
||||
// index 0 in sorted order, which places the first (smallest position) at the highest
|
||||
// index, making it appear first visually
|
||||
for (const child of sortedChildren) {
|
||||
child.setParentIndex(0);
|
||||
}
|
||||
|
||||
return flexLayout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes all descendants of a shape by applying an evaluator function to each.
|
||||
* Only descendants for which the evaluator returns a non-null/non-undefined value are included in the result.
|
||||
* This is a general-purpose utility for validation, analysis, or collecting corrector functions.
|
||||
*
|
||||
* @param root - The root shape whose descendants to analyze
|
||||
* @param evaluator - Function called for each descendant with (root, descendant); return null/undefined to skip
|
||||
* @param maxDepth - Optional maximum depth to traverse (undefined for unlimited)
|
||||
* @returns Array of objects containing the shape and the evaluator's result
|
||||
*/
|
||||
public static analyzeDescendants<T>(
|
||||
root: Shape,
|
||||
evaluator: (root: Shape, descendant: Shape) => T | null | undefined,
|
||||
maxDepth: number | undefined = undefined
|
||||
): Array<{ shape: Shape; result: NonNullable<T> }> {
|
||||
const results: Array<{ shape: Shape; result: NonNullable<T> }> = [];
|
||||
|
||||
const traverse = (shape: Shape, currentDepth: number): void => {
|
||||
const result = evaluator(root, shape);
|
||||
if (result !== null && result !== undefined) {
|
||||
results.push({ shape, result: result as NonNullable<T> });
|
||||
}
|
||||
|
||||
if (maxDepth === undefined || currentDepth < maxDepth) {
|
||||
if ("children" in shape && shape.children) {
|
||||
for (const child of shape.children) {
|
||||
traverse(child, currentDepth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Start traversal with root's children (not root itself)
|
||||
if ("children" in root && root.children) {
|
||||
for (const child of root.children) {
|
||||
traverse(child, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a base64 string to a Uint8Array.
|
||||
* This is required because the Penpot plugin environment does not provide the atob function.
|
||||
*
|
||||
* @param base64 - The base64-encoded string to decode
|
||||
* @returns The decoded data as a Uint8Array
|
||||
*/
|
||||
public static atob(base64: string): Uint8Array {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
const lookup = new Uint8Array(256);
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
lookup[chars.charCodeAt(i)] = i;
|
||||
}
|
||||
|
||||
let bufferLength = base64.length * 0.75;
|
||||
if (base64[base64.length - 1] === "=") {
|
||||
bufferLength--;
|
||||
if (base64[base64.length - 2] === "=") {
|
||||
bufferLength--;
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(bufferLength);
|
||||
let p = 0;
|
||||
for (let i = 0; i < base64.length; i += 4) {
|
||||
const encoded1 = lookup[base64.charCodeAt(i)];
|
||||
const encoded2 = lookup[base64.charCodeAt(i + 1)];
|
||||
const encoded3 = lookup[base64.charCodeAt(i + 2)];
|
||||
const encoded4 = lookup[base64.charCodeAt(i + 3)];
|
||||
|
||||
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4);
|
||||
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
|
||||
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports an image from base64 data into the Penpot design as a Rectangle shape filled with the image.
|
||||
* The rectangle has the image's original proportions by default.
|
||||
* Optionally accepts position (x, y) and dimensions (width, height) parameters.
|
||||
* If only one dimension is provided, the other is calculated to maintain the image's aspect ratio.
|
||||
*
|
||||
* This function is used internally by the ImportImageTool in the MCP server.
|
||||
*
|
||||
* @param base64 - The base64-encoded image data
|
||||
* @param mimeType - The MIME type of the image (e.g., "image/png")
|
||||
* @param name - The name to assign to the newly created rectangle shape
|
||||
* @param x - The x-coordinate for positioning the rectangle (optional)
|
||||
* @param y - The y-coordinate for positioning the rectangle (optional)
|
||||
* @param width - The desired width of the rectangle (optional)
|
||||
* @param height - The desired height of the rectangle (optional)
|
||||
*/
|
||||
public static async importImage(
|
||||
base64: string,
|
||||
mimeType: string,
|
||||
name: string,
|
||||
x: number | undefined,
|
||||
y: number | undefined,
|
||||
width: number | undefined,
|
||||
height: number | undefined
|
||||
): Promise<Rectangle> {
|
||||
// convert base64 to Uint8Array
|
||||
const bytes = PenpotUtils.atob(base64);
|
||||
|
||||
// upload the image data to Penpot
|
||||
const imageData = await penpot.uploadMediaData(name, bytes, mimeType);
|
||||
|
||||
// create a rectangle shape
|
||||
const rect = penpot.createRectangle();
|
||||
rect.name = name;
|
||||
|
||||
// calculate dimensions
|
||||
let rectWidth, rectHeight;
|
||||
const hasWidth = width !== undefined;
|
||||
const hasHeight = height !== undefined;
|
||||
|
||||
if (hasWidth && hasHeight) {
|
||||
// both width and height provided - use them directly
|
||||
rectWidth = width;
|
||||
rectHeight = height;
|
||||
} else if (hasWidth) {
|
||||
// only width provided - maintain aspect ratio
|
||||
rectWidth = width;
|
||||
rectHeight = rectWidth * (imageData.height / imageData.width);
|
||||
} else if (hasHeight) {
|
||||
// only height provided - maintain aspect ratio
|
||||
rectHeight = height;
|
||||
rectWidth = rectHeight * (imageData.width / imageData.height);
|
||||
} else {
|
||||
// neither provided - use original dimensions
|
||||
rectWidth = imageData.width;
|
||||
rectHeight = imageData.height;
|
||||
}
|
||||
|
||||
// set rectangle dimensions
|
||||
rect.resize(rectWidth, rectHeight);
|
||||
|
||||
// set position if provided
|
||||
if (x !== undefined) {
|
||||
rect.x = x;
|
||||
}
|
||||
if (y !== undefined) {
|
||||
rect.y = y;
|
||||
}
|
||||
|
||||
// apply the image as a fill
|
||||
rect.fills = [{ fillOpacity: 1, fillImage: imageData }];
|
||||
|
||||
return rect;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the given shape (or its fill) to BASE64 image data.
|
||||
*
|
||||
* This function is used internally by the ExportImageTool in the MCP server.
|
||||
*
|
||||
* @param shape - The shape whose image data to export
|
||||
* @param mode - Either "shape" (to export the entire shape, including descendants) or "fill"
|
||||
* to export the shape's raw fill image data
|
||||
* @param asSVG - Whether to export as SVG rather than as a pixel image (only supported for mode "shape")
|
||||
* @returns A byte array containing the exported image data.
|
||||
* - For mode="shape", it will be PNG or SVG data depending on the value of `asSVG`.
|
||||
* - For mode="fill", it will be whatever format the fill image is stored in.
|
||||
*/
|
||||
public static async exportImage(shape: Shape, mode: "shape" | "fill", asSVG: boolean): Promise<Uint8Array> {
|
||||
switch (mode) {
|
||||
case "shape":
|
||||
return shape.export({ type: asSVG ? "svg" : "png" });
|
||||
case "fill":
|
||||
if (asSVG) {
|
||||
throw new Error("Image fills cannot be exported as SVG");
|
||||
}
|
||||
// check whether the shape has the `fills` member
|
||||
if (!("fills" in shape)) {
|
||||
throw new Error("Shape with `fills` member is required for fill export mode");
|
||||
}
|
||||
// find first fill that has fillImage
|
||||
const fills: Fill[] = (shape as any).fills;
|
||||
for (const fill of fills) {
|
||||
if (fill.fillImage) {
|
||||
const imageData = fill.fillImage;
|
||||
return imageData.data();
|
||||
}
|
||||
}
|
||||
throw new Error("No fill with image data found in the shape");
|
||||
default:
|
||||
throw new Error(`Unsupported export mode: ${mode}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
77
mcp/packages/plugin/src/TaskHandler.ts
Normal file
77
mcp/packages/plugin/src/TaskHandler.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Represents a task received from the MCP server in the Penpot MCP plugin
|
||||
*/
|
||||
export class Task<TParams = any> {
|
||||
public isResponseSent: boolean = false;
|
||||
|
||||
/**
|
||||
* @param requestId Unique identifier for the task request
|
||||
* @param taskType The type of the task to execute
|
||||
* @param params Task parameters/arguments
|
||||
*/
|
||||
constructor(
|
||||
public requestId: string,
|
||||
public taskType: string,
|
||||
public params: TParams
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Sends a task response back to the MCP server.
|
||||
*/
|
||||
protected sendResponse(success: boolean, data: any = undefined, error: any = undefined): void {
|
||||
if (this.isResponseSent) {
|
||||
console.error("Response already sent for task:", this.requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = {
|
||||
type: "task-response",
|
||||
response: {
|
||||
id: this.requestId,
|
||||
success: success,
|
||||
data: data,
|
||||
error: error,
|
||||
},
|
||||
};
|
||||
|
||||
// Send to main.ts which will forward to MCP server via WebSocket
|
||||
penpot.ui.sendMessage(response);
|
||||
console.log("Sent task response:", response);
|
||||
this.isResponseSent = true;
|
||||
}
|
||||
|
||||
public sendSuccess(data: any = undefined): void {
|
||||
this.sendResponse(true, data);
|
||||
}
|
||||
|
||||
public sendError(error: string): void {
|
||||
this.sendResponse(false, undefined, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract base class for task handlers in the Penpot MCP plugin.
|
||||
*
|
||||
* @template TParams - The type of parameters this handler expects
|
||||
*/
|
||||
export abstract class TaskHandler<TParams = any> {
|
||||
/** The task type this handler is responsible for */
|
||||
abstract readonly taskType: string;
|
||||
|
||||
/**
|
||||
* Checks if this handler can process the given task.
|
||||
*
|
||||
* @param task - The task identifier to check
|
||||
* @returns True if this handler applies to the given task
|
||||
*/
|
||||
isApplicableTo(task: Task): boolean {
|
||||
return this.taskType === task.taskType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the task with the provided parameters.
|
||||
*
|
||||
* @param task - The task to be handled
|
||||
*/
|
||||
abstract handle(task: Task<TParams>): Promise<void>;
|
||||
}
|
||||
110
mcp/packages/plugin/src/main.ts
Normal file
110
mcp/packages/plugin/src/main.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import "./style.css";
|
||||
|
||||
// get the current theme from the URL
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
document.body.dataset.theme = searchParams.get("theme") ?? "light";
|
||||
|
||||
// Determine whether multi-user mode is enabled based on URL parameters
|
||||
const isMultiUserMode = searchParams.get("multiUser") === "true";
|
||||
console.log("Penpot MCP multi-user mode:", isMultiUserMode);
|
||||
|
||||
// WebSocket connection management
|
||||
let ws: WebSocket | null = null;
|
||||
const statusElement = document.getElementById("connection-status");
|
||||
|
||||
/**
|
||||
* Updates the connection status display element.
|
||||
*
|
||||
* @param status - the base status text to display
|
||||
* @param isConnectedState - whether the connection is in a connected state (affects color)
|
||||
* @param message - optional additional message to append to the status
|
||||
*/
|
||||
function updateConnectionStatus(status: string, isConnectedState: boolean, message?: string): void {
|
||||
if (statusElement) {
|
||||
const displayText = message ? `${status}: ${message}` : status;
|
||||
statusElement.textContent = displayText;
|
||||
statusElement.style.color = isConnectedState ? "var(--accent-primary)" : "var(--error-700)";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a task response back to the MCP server via WebSocket.
|
||||
*
|
||||
* @param response - The response containing task ID and result
|
||||
*/
|
||||
function sendTaskResponse(response: any): void {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(response));
|
||||
console.log("Sent response to MCP server:", response);
|
||||
} else {
|
||||
console.error("WebSocket not connected, cannot send response");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes a WebSocket connection to the MCP server.
|
||||
*/
|
||||
function connectToMcpServer(): void {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
updateConnectionStatus("Already connected", true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let wsUrl = PENPOT_MCP_WEBSOCKET_URL;
|
||||
if (isMultiUserMode) {
|
||||
// TODO obtain proper userToken from penpot
|
||||
const userToken = "dummyToken";
|
||||
wsUrl += `?userToken=${encodeURIComponent(userToken)}`;
|
||||
}
|
||||
ws = new WebSocket(wsUrl);
|
||||
updateConnectionStatus("Connecting...", false);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("Connected to MCP server");
|
||||
updateConnectionStatus("Connected to MCP server", true);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
console.log("Received from MCP server:", event.data);
|
||||
try {
|
||||
const request = JSON.parse(event.data);
|
||||
// Forward the task request to the plugin for execution
|
||||
parent.postMessage(request, "*");
|
||||
} catch (error) {
|
||||
console.error("Failed to parse WebSocket message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event: CloseEvent) => {
|
||||
console.log("Disconnected from MCP server");
|
||||
const message = event.reason || undefined;
|
||||
updateConnectionStatus("Disconnected", false, message);
|
||||
ws = null;
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error("WebSocket error:", error);
|
||||
// note: WebSocket error events typically don't contain detailed error messages
|
||||
updateConnectionStatus("Connection error", false);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to connect to MCP server:", error);
|
||||
const message = error instanceof Error ? error.message : undefined;
|
||||
updateConnectionStatus("Connection failed", false, message);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelector("[data-handler='connect-mcp']")?.addEventListener("click", () => {
|
||||
connectToMcpServer();
|
||||
});
|
||||
|
||||
// Listen plugin.ts messages
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.data.source === "penpot") {
|
||||
document.body.dataset.theme = event.data.theme;
|
||||
} else if (event.data.type === "task-response") {
|
||||
// Forward task response back to MCP server
|
||||
sendTaskResponse(event.data.response);
|
||||
}
|
||||
});
|
||||
69
mcp/packages/plugin/src/plugin.ts
Normal file
69
mcp/packages/plugin/src/plugin.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler";
|
||||
import { Task, TaskHandler } from "./TaskHandler";
|
||||
|
||||
/**
|
||||
* Registry of all available task handlers.
|
||||
*/
|
||||
const taskHandlers: TaskHandler[] = [new ExecuteCodeTaskHandler()];
|
||||
|
||||
// Determine whether multi-user mode is enabled based on build-time configuration
|
||||
declare const IS_MULTI_USER_MODE: boolean;
|
||||
const isMultiUserMode = typeof IS_MULTI_USER_MODE !== "undefined" ? IS_MULTI_USER_MODE : false;
|
||||
|
||||
// Open the plugin UI (main.ts)
|
||||
penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}&multiUser=${isMultiUserMode}`, { width: 158, height: 200 });
|
||||
|
||||
// Handle messages
|
||||
penpot.ui.onMessage<string | { id: string; task: string; params: any }>((message) => {
|
||||
// Handle plugin task requests
|
||||
if (typeof message === "object" && message.task && message.id) {
|
||||
handlePluginTaskRequest(message).catch((error) => {
|
||||
console.error("Error in handlePluginTaskRequest:", error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Handles plugin task requests received from the MCP server via WebSocket.
|
||||
*
|
||||
* @param request - The task request containing ID, task type and parameters
|
||||
*/
|
||||
async function handlePluginTaskRequest(request: { id: string; task: string; params: any }): Promise<void> {
|
||||
console.log("Executing plugin task:", request.task, request.params);
|
||||
const task = new Task(request.id, request.task, request.params);
|
||||
|
||||
// Find the appropriate handler
|
||||
const handler = taskHandlers.find((h) => h.isApplicableTo(task));
|
||||
|
||||
if (handler) {
|
||||
try {
|
||||
// Cast the params to the expected type and handle the task
|
||||
console.log("Processing task with handler:", handler);
|
||||
await handler.handle(task);
|
||||
|
||||
// check whether a response was sent and send a generic success if not
|
||||
if (!task.isResponseSent) {
|
||||
console.warn("Handler did not send a response, sending generic success.");
|
||||
task.sendSuccess("Task completed without a specific response.");
|
||||
}
|
||||
|
||||
console.log("Task handled successfully:", task);
|
||||
} catch (error) {
|
||||
console.error("Error handling task:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
task.sendError(`Error handling task: ${errorMessage}`);
|
||||
}
|
||||
} else {
|
||||
console.error("Unknown plugin task:", request.task);
|
||||
task.sendError(`Unknown task type: ${request.task}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle theme change in the iframe
|
||||
penpot.on("themechange", (theme) => {
|
||||
penpot.ui.sendMessage({
|
||||
source: "penpot",
|
||||
type: "themechange",
|
||||
theme,
|
||||
});
|
||||
});
|
||||
10
mcp/packages/plugin/src/style.css
Normal file
10
mcp/packages/plugin/src/style.css
Normal file
@@ -0,0 +1,10 @@
|
||||
@import "@penpot/plugin-styles/styles.css";
|
||||
|
||||
body {
|
||||
line-height: 1.5;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-block-end: 0.75rem;
|
||||
}
|
||||
212
mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts
Normal file
212
mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { Task, TaskHandler } from "../TaskHandler";
|
||||
import { ExecuteCodeTaskParams, ExecuteCodeTaskResultData } from "../../../common/src";
|
||||
import { PenpotUtils } from "../PenpotUtils.ts";
|
||||
|
||||
/**
|
||||
* Console implementation that captures all log output for code execution.
|
||||
*
|
||||
* Provides the same interface as the native console object but appends
|
||||
* all output to an internal log string that can be retrieved.
|
||||
*/
|
||||
class ExecuteCodeTaskConsole {
|
||||
/**
|
||||
* Accumulated log output from all console method calls.
|
||||
*/
|
||||
private logOutput: string = "";
|
||||
|
||||
/**
|
||||
* Resets the accumulated log output to empty string.
|
||||
* Should be called before each code execution to start with clean logs.
|
||||
*/
|
||||
resetLog(): void {
|
||||
this.logOutput = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the accumulated log output from all console method calls.
|
||||
* @returns The complete log output as a string
|
||||
*/
|
||||
getLog(): string {
|
||||
return this.logOutput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a formatted message to the log output.
|
||||
* @param level - Log level prefix (e.g., "LOG", "WARN", "ERROR")
|
||||
* @param args - Arguments to log, will be stringified and joined
|
||||
*/
|
||||
private appendToLog(level: string, ...args: any[]): void {
|
||||
const message = args
|
||||
.map((arg) => (typeof arg === "object" ? JSON.stringify(arg, null, 2) : String(arg)))
|
||||
.join(" ");
|
||||
this.logOutput += `[${level}] ${message}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a message to the captured output.
|
||||
*/
|
||||
log(...args: any[]): void {
|
||||
this.appendToLog("LOG", ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a warning message to the captured output.
|
||||
*/
|
||||
warn(...args: any[]): void {
|
||||
this.appendToLog("WARN", ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an error message to the captured output.
|
||||
*/
|
||||
error(...args: any[]): void {
|
||||
this.appendToLog("ERROR", ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an informational message to the captured output.
|
||||
*/
|
||||
info(...args: any[]): void {
|
||||
this.appendToLog("INFO", ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a debug message to the captured output.
|
||||
*/
|
||||
debug(...args: any[]): void {
|
||||
this.appendToLog("DEBUG", ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a message with trace information to the captured output.
|
||||
*/
|
||||
trace(...args: any[]): void {
|
||||
this.appendToLog("TRACE", ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a table to the captured output (simplified as JSON).
|
||||
*/
|
||||
table(data: any): void {
|
||||
this.appendToLog("TABLE", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a timer (simplified implementation that just logs).
|
||||
*/
|
||||
time(label?: string): void {
|
||||
this.appendToLog("TIME", `Timer started: ${label || "default"}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends a timer (simplified implementation that just logs).
|
||||
*/
|
||||
timeEnd(label?: string): void {
|
||||
this.appendToLog("TIME_END", `Timer ended: ${label || "default"}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs messages in a group (simplified to just log the label).
|
||||
*/
|
||||
group(label?: string): void {
|
||||
this.appendToLog("GROUP", label || "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs messages in a collapsed group (simplified to just log the label).
|
||||
*/
|
||||
groupCollapsed(label?: string): void {
|
||||
this.appendToLog("GROUP_COLLAPSED", label || "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the current group (simplified implementation).
|
||||
*/
|
||||
groupEnd(): void {
|
||||
this.appendToLog("GROUP_END", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the console (no-op in this implementation since we want to capture logs).
|
||||
*/
|
||||
clear(): void {
|
||||
// intentionally empty - we don't want to clear captured logs
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts occurrences of calls with the same label (simplified implementation).
|
||||
*/
|
||||
count(label?: string): void {
|
||||
this.appendToLog("COUNT", label || "default");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the count for a label (simplified implementation).
|
||||
*/
|
||||
countReset(label?: string): void {
|
||||
this.appendToLog("COUNT_RESET", label || "default");
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an assertion (simplified to just log if condition is false).
|
||||
*/
|
||||
assert(condition: boolean, ...args: any[]): void {
|
||||
if (!condition) {
|
||||
this.appendToLog("ASSERT", ...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Task handler for executing JavaScript code in the plugin context.
|
||||
*
|
||||
* Maintains a persistent context object that preserves state between code executions
|
||||
* and captures all console output during execution.
|
||||
*/
|
||||
export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
|
||||
readonly taskType = "executeCode";
|
||||
|
||||
/**
|
||||
* Persistent context object that maintains state between code executions.
|
||||
* Contains the penpot API, storage object, and custom console implementation.
|
||||
*/
|
||||
private readonly context: any;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// initialize context, making penpot, penpotUtils, storage and the custom console available
|
||||
this.context = {
|
||||
penpot: penpot,
|
||||
storage: {},
|
||||
console: new ExecuteCodeTaskConsole(),
|
||||
penpotUtils: PenpotUtils,
|
||||
};
|
||||
}
|
||||
|
||||
async handle(task: Task<ExecuteCodeTaskParams>): Promise<void> {
|
||||
if (!task.params.code) {
|
||||
task.sendError("executeCode task requires 'code' parameter");
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.console.resetLog();
|
||||
|
||||
const context = this.context;
|
||||
const code = task.params.code;
|
||||
|
||||
let result: any = await (async (ctx) => {
|
||||
const fn = new Function(...Object.keys(ctx), `return (async () => { ${code} })();`);
|
||||
return fn(...Object.values(ctx));
|
||||
})(context);
|
||||
|
||||
console.log("Code execution result:", result);
|
||||
|
||||
// return result and captured log
|
||||
let resultData: ExecuteCodeTaskResultData<any> = {
|
||||
result: result,
|
||||
log: this.context.console.getLog(),
|
||||
};
|
||||
task.sendSuccess(resultData);
|
||||
}
|
||||
}
|
||||
4
mcp/packages/plugin/src/vite-env.d.ts
vendored
Normal file
4
mcp/packages/plugin/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const IS_MULTI_USER_MODE: boolean;
|
||||
declare const PENPOT_MCP_WEBSOCKET_URL: string;
|
||||
24
mcp/packages/plugin/tsconfig.json
Normal file
24
mcp/packages/plugin/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"typeRoots": ["./node_modules/@types", "./node_modules/@penpot"],
|
||||
"types": ["plugin-types"],
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
43
mcp/packages/plugin/vite.config.ts
Normal file
43
mcp/packages/plugin/vite.config.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { defineConfig } from "vite";
|
||||
import livePreview from "vite-live-preview";
|
||||
|
||||
// Debug: Log the environment variables
|
||||
console.log("MULTI_USER_MODE env:", process.env.MULTI_USER_MODE);
|
||||
console.log("Will define IS_MULTI_USER_MODE as:", JSON.stringify(process.env.MULTI_USER_MODE === "true"));
|
||||
|
||||
let WS_URI = "http://localhost:4402";
|
||||
console.log("Will define PENPOT_MCP_WEBSOCKET_URL as:", JSON.stringify(WS_URI));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
livePreview({
|
||||
reload: true,
|
||||
config: {
|
||||
build: {
|
||||
sourcemap: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
plugin: "src/plugin.ts",
|
||||
index: "./index.html",
|
||||
},
|
||||
output: {
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
host: "0.0.0.0",
|
||||
port: 4400,
|
||||
cors: true,
|
||||
allowedHosts: [],
|
||||
},
|
||||
define: {
|
||||
IS_MULTI_USER_MODE: JSON.stringify(process.env.MULTI_USER_MODE === "true"),
|
||||
PENPOT_MCP_WEBSOCKET_URL: JSON.stringify(WS_URI),
|
||||
},
|
||||
});
|
||||
10
mcp/packages/plugin/vite.release.config.ts
Normal file
10
mcp/packages/plugin/vite.release.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig, mergeConfig } from "vite";
|
||||
import baseConfig from "./vite.config";
|
||||
|
||||
export default mergeConfig(
|
||||
baseConfig,
|
||||
defineConfig({
|
||||
base: "./",
|
||||
plugins: [],
|
||||
})
|
||||
);
|
||||
0
mcp/packages/server/.gitignore
vendored
Normal file
0
mcp/packages/server/.gitignore
vendored
Normal file
24
mcp/packages/server/README.md
Normal file
24
mcp/packages/server/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Penpot MCP Server
|
||||
|
||||
A Model Context Protocol (MCP) server that provides Penpot integration
|
||||
capabilities for AI clients supporting the model context protocol (MCP).
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install Dependencies
|
||||
|
||||
pnpm install
|
||||
|
||||
2. Build the Project
|
||||
|
||||
pnpm run build
|
||||
|
||||
3. Run the Server
|
||||
|
||||
pnpm run start
|
||||
|
||||
|
||||
## Penpot Plugin API REPL
|
||||
|
||||
The MCP server includes a REPL interface for testing Penpot Plugin API calls.
|
||||
To use it, connect to the URL reported at startup.
|
||||
18268
mcp/packages/server/data/api_types.yml
Normal file
18268
mcp/packages/server/data/api_types.yml
Normal file
File diff suppressed because it is too large
Load Diff
267
mcp/packages/server/data/prompts.yml
Normal file
267
mcp/packages/server/data/prompts.yml
Normal file
@@ -0,0 +1,267 @@
|
||||
# Prompts configuration for Penpot MCP Server
|
||||
# This file contains various prompts and instructions that can be used by the server
|
||||
|
||||
initial_instructions: |
|
||||
You have access to Penpot tools in order to interact with a Penpot design project directly.
|
||||
As a precondition, the user must connect the Penpot design project to the MCP server using the Penpot MCP Plugin.
|
||||
|
||||
IMPORTANT: When transferring styles from a Penpot design to code, make sure that you strictly adhere to the design.
|
||||
NEVER make assumptions about missing values and don't get overly creative (e.g. don't pick your own colours and stick to
|
||||
non-creative defaults such as white/black if you are lacking information).
|
||||
|
||||
# Executing Code
|
||||
|
||||
One of your key tools is the `execute_code` tool, which allows you to run JavaScript code using the Penpot Plugin API
|
||||
directly in the connected project.
|
||||
|
||||
VERY IMPORTANT: When writing code, NEVER LOG INFORMATION YOU ARE ALSO RETURNING. It would duplicate the information you receive!
|
||||
|
||||
To execute code correctly, you need to understand the Penpot Plugin API. You can retrieve API documentation via
|
||||
the `penpot_api_info` tool.
|
||||
|
||||
This is the full list of types/interfaces in the Penpot API: $api_types
|
||||
|
||||
You use the `storage` object extensively to store data and utility functions you define across tool calls.
|
||||
This allows you to inspect intermediate results while still being able to build on them in subsequent code executions.
|
||||
|
||||
# The Structure of Penpot Designs
|
||||
|
||||
A Penpot design ultimately consists of shapes.
|
||||
The type `Shape` is a union type, which encompasses both containers and low-level shapes.
|
||||
Shapes in a Penpot design are organized hierarchically.
|
||||
At the top level, a design project contains one or more `Page` objects.
|
||||
Each `Page` contains a tree of elements. For a given instance `page`, its root shape is `page.root`.
|
||||
A Page is frequently structured into boards. A `Board` is a high-level grouping element.
|
||||
A `Group` is a more low-level grouping element used to organize low-level shapes into a logical unit.
|
||||
Actual low-level shape types are `Rectangle`, `Path`, `Text`, `Ellipse`, `Image`, `Boolean`, and `SvgRaw`.
|
||||
`ShapeBase` is a base type most shapes build upon.
|
||||
|
||||
# Core Shape Properties and Methods
|
||||
|
||||
**Type**:
|
||||
Any given shape contains information on the concrete type via its `type` field.
|
||||
|
||||
**Position and Dimensions**:
|
||||
* The location properties `x` and `y` refer to the top left corner of a shape's bounding box in the absolute (Page) coordinate system.
|
||||
These are writable - set them directly to position shapes.
|
||||
* `parentX` and `parentY` (as well as `boardX` and `boardY`) are READ-ONLY computed properties showing position relative to parent/board.
|
||||
To position relative to parent, use `penpotUtils.setParentXY(shape, parentX, parentY)` or manually set `shape.x = parent.x + parentX`.
|
||||
* `width` and `height` are READ-ONLY. Use `resize(width, height)` method to change dimensions.
|
||||
* `bounds` is a READ-ONLY property. Use `x`, `y` with `resize()` to modify shape bounds.
|
||||
|
||||
**Other Writable Properties**:
|
||||
* `name` - Shape name
|
||||
* `fills`, `strokes` - Styling properties
|
||||
* `rotation`, `opacity`, `blocked`, `hidden`, `visible`
|
||||
|
||||
**Z-Order**:
|
||||
* The z-order of shapes is determined by the order in the `children` array of the parent shape.
|
||||
Therefore, when creating shapes that should be on top of each other, add them to the parent in the correct order
|
||||
(i.e. add background shapes first, then foreground shapes later).
|
||||
CRITICAL: NEVER use the broken function `appendChild` to achieve this, ALWAYS use `parent.insertChild(parent.children.length, shape)`
|
||||
* To modify z-order after creation, use these methods: `bringToFront()`, `sendToBack()`, `bringForward()`, `sendBackward()`,
|
||||
and, for precise control, `setParentIndex(index)` (0-based).
|
||||
|
||||
**Modification Methods**:
|
||||
* `resize(width, height)` - Change dimensions (required for width/height since they're read-only)
|
||||
* `rotate(angle, center?)` - Rotate shape
|
||||
* `remove()` - Permanently destroy the shape (use only for deletion, NOT for reparenting)
|
||||
|
||||
**Hierarchical Structure**:
|
||||
* `parent` - The parent shape (null for root shapes)
|
||||
Note: Hierarchical nesting does not necessarily imply visual containment
|
||||
* CRITICAL: To add children to a parent shape (e.g. a `Board`):
|
||||
- ALWAYS use `parent.insertChild(index, shape)` to add a child, e.g. `parent.insertChild(parent.children.length, shape)` to append
|
||||
- NEVER use `parent.appendChild(shape)` as it is BROKEN and will not insert in a predictable place (except in flex layout boards)
|
||||
* Reparenting: `newParent.appendChild(shape)` or `newParent.insertChild(index, shape)` will move a shape to new parent
|
||||
- Automatically removes the shape from its old parent
|
||||
- Absolute x/y positions are preserved (use `penpotUtils.setParentXY` to adjust relative position)
|
||||
|
||||
# Images
|
||||
|
||||
The `Image` type is a legacy type. Images are now typically embedded in a `Fill`, with `fillImage` set to an
|
||||
`ImageData` object, i.e. the `fills` property of of a shape (e.g. a `Rectangle`) will contain a fill where `fillImage` is set.
|
||||
Use the `export_shape` and `import_image` tools to export and import images.
|
||||
|
||||
# Layout Systems
|
||||
|
||||
Boards can have layout systems that automatically control the positioning and spacing of their children:
|
||||
|
||||
* If a board has a layout system, then child positions are controlled by the layout system.
|
||||
For every child, key properties of the child within the layout are stored in `child.layoutChild: LayoutChildProperties`:
|
||||
- `absolute: boolean` - if true, child position is not controlled by layout system. x/y will set *relative* position within parent!
|
||||
- margins (`topMargin`, `rightMargin`, `bottomMargin`, `leftMargin` or combined `verticalMargin`, `horizontalMargin`)
|
||||
- sizing (`verticalSizing`, `horizontalSizing`: "fill" | "auto" | "fix")
|
||||
- min/max sizes (`minWidth`, `maxWidth`, `minHeight`, `maxHeight`)
|
||||
- `zIndex: number` (higher numbers on top)
|
||||
|
||||
* **Flex Layout**: A flexbox-style layout system
|
||||
- Properties: `dir`, `rowGap`, `columnGap`, `alignItems`, `justifyContent`;
|
||||
- `dir`: "row" | "column" | "row-reverse" | "column-reverse"
|
||||
- Padding: `topPadding`, `rightPadding`, `bottomPadding`, `leftPadding`, or combined `verticalPadding`, `horizontalPadding`
|
||||
- To modify spacing: adjust `rowGap` and `columnGap` properties, not individual child positions.
|
||||
Optionally, adjust indivudual child margins via `child.layoutChild`.
|
||||
- When a board has flex layout,
|
||||
- child positions are controlled by the layout system, not by individual x/y coordinates (unless `child.layoutChild.absolute` is true);
|
||||
appending or inserting children automatically positions them according to the layout rules.
|
||||
- CRITICAL: For for dir="column" or dir="row", the order of the `children` array is reversed relative to the visual order!
|
||||
Therefore, the element that appears first in the array, appears visually at the end (bottom/right) and vice versa.
|
||||
ALWAYS BEAR IN MIND THAT THE CHILDREN ARRAY ORDER IS REVERSED FOR dir="column" OR dir="row"!
|
||||
- CRITICAL: The FlexLayout method `board.flex.appendChild` is BROKEN. To append children to a flex layout board such that
|
||||
they appear visually at the end, ALWAYS use the Board's method `board.appendChild(shape)`; it will insert at the front
|
||||
of the `children` array for dir="column" or dir="row", which is what you want. So call it in the order of visual appearance.
|
||||
To insert at a specific index, use `board.insertChild(index, shape)`, bearing in mind the reversed order for dir="column"
|
||||
or dir="row".
|
||||
- Add to a board with `board.addFlexLayout(): FlexLayout`; instance then accessible via `board.flex`.
|
||||
IMPORTANT: When adding a flex layout to a container that already has children,
|
||||
use `penpotUtils.addFlexLayout(container, dir)` instead! This preserves the existing visual order of children.
|
||||
Otherwise, children will be arbitrarily reordered when the children order suddenly determines the display order.
|
||||
- Check with: `if (board.flex) { ... }`
|
||||
|
||||
* **Grid Layout**: A CSS grid-style layout system
|
||||
- Add to a board with `board.addGridLayout(): GridLayout`; instance then accessibly via `board.grid`;
|
||||
Check with: `if (board.grid) { ... }`
|
||||
- Properties: `rows`, `columns`, `rowGap`, `columnGap`
|
||||
- Children are positioned via 1-based row/column indices
|
||||
- Add to grid via `board.flex.appendChild(shape, row, column)`
|
||||
- Modify grid positioning after the fact via `shape.layoutCell: LayoutCellProperties`
|
||||
|
||||
* When working with boards:
|
||||
- ALWAYS check if the board has a layout system before attempting to reposition children
|
||||
- Modify layout properties (gaps, padding) instead of trying to set child x/y positions directly
|
||||
- Layout systems override manual positioning of children
|
||||
|
||||
# Text Elements
|
||||
|
||||
The rendered content of `Text` element is given by the `characters` property.
|
||||
|
||||
To change the size of the text, change the `fontSize` property; applying `resize()` does NOT change the font size,
|
||||
it only changes the formal bounding box; if the text does not fit it, it will overflow.
|
||||
The bounding box is sized automatically as long as the `growType` property is set to "auto-width" or "auto-height".
|
||||
`resize` always sets `growType` to "fixed", so ALWAYS set it back to "auto-*" if you want automatic sizing - otherwise the bounding box will be meaningless, with the text overflowing!
|
||||
The auto-sizing is not immediate; sleep for a short time (100ms) if you want to read the updated bounding box.
|
||||
|
||||
# The `penpot` and `penpotUtils` Objects, Exploring Designs
|
||||
|
||||
A key object to use in your code is the `penpot` object (which is of type `Penpot`):
|
||||
* `penpot.selection` provides the list of shapes the user has selected in the Penpot UI.
|
||||
If it is unclear which elements to work on, you can ask the user to select them for you.
|
||||
ALWAYS immediately copy the selected shape(s) into `storage`! Do not assume that the selection remains unchanged.
|
||||
* `penpot.root` provides the root shape of the currently active page.
|
||||
* Generation of CSS content for elements via `penpot.generateStyle`
|
||||
* Generation of HTML/SVG content for elements via `penpot.generateMarkup`
|
||||
|
||||
For example, to generate CSS for the currently selected elements, you can execute this:
|
||||
return penpot.generateStyle(penpot.selection, { type: "css", withChildren: true });
|
||||
|
||||
CRITICAL: The `penpotUtils` object provides essential utilities - USE THESE INSTEAD OF WRITING YOUR OWN:
|
||||
* getPages(): { id: string; name: string }[]
|
||||
* getPageById(id: string): Page | null
|
||||
* getPageByName(name: string): Page | null
|
||||
* shapeStructure(shape: Shape, maxDepth: number | undefined = undefined): { id, name, type, children?, layout? }
|
||||
Generates an overview structure of the given shape.
|
||||
- children: recursive, limited by maxDepth
|
||||
- layout: present if shape has flex/grid layout, contains { type: "flex" | "grid", ... }
|
||||
* findShapeById(id: string): Shape | null
|
||||
* findShape(predicate: (shape: Shape) => boolean, root: Shape | null = null): Shape | null
|
||||
If no root is provided, search globally (in all pages).
|
||||
* findShapes(predicate: (shape: Shape) => boolean, root: Shape | null = null): Shape[]
|
||||
* isContainedIn(shape: Shape, container: Shape): boolean
|
||||
Returns true iff shape is fully within the container's geometric bounds.
|
||||
Note that a shape's bounds may not always reflect its actual visual content - descendants can overflow; check using analyzeDescendants (see below).
|
||||
* setParentXY(shape: Shape, parentX: number, parentY: number): void
|
||||
Sets shape position relative to its parent (since parentX/parentY are read-only)
|
||||
* analyzeDescendants<T>(root: Shape, evaluator: (root: Shape, descendant: Shape) => T | null | undefined, maxDepth?: number): Array<{ shape: Shape, result: T }>
|
||||
General-purpose utility for analyzing/validating descendants
|
||||
Calls evaluator on each descendant; collects non-null/undefined results
|
||||
Powerful pattern: evaluator can return corrector functions or diagnostic data
|
||||
|
||||
General pointers for working with Penpot designs:
|
||||
* Prefer `penpotUtils` helper functions — avoid reimplementing shape searching.
|
||||
* To get an overview of a single page, use `penpotUtils.shapeStructure(page.root, 3)`.
|
||||
Note that `penpot.root` refers to the current page only. When working across pages, first determine the relevant page(s).
|
||||
* Use `penpotUtils.findShapes()` or `penpotUtils.findShape()` with predicates to locate elements efficiently.
|
||||
|
||||
Common tasks - Quick Reference (ALWAYS use penpotUtils for these):
|
||||
* Find all images:
|
||||
const images = penpotUtils.findShapes(
|
||||
shape => shape.type === 'image' || shape.fills?.some(fill => fill.fillImage),
|
||||
penpot.root
|
||||
);
|
||||
* Find text elements:
|
||||
const texts = penpotUtils.findShapes(shape => shape.type === 'text', penpot.root);
|
||||
* Find (the first) shape with a given name:
|
||||
const shape = penpotUtils.findShape(shape => shape.name === 'MyShape');
|
||||
* Get structure of current selection:
|
||||
const structure = penpotUtils.shapeStructure(penpot.selection[0]);
|
||||
* Find shapes in current selection/board:
|
||||
const shapes = penpotUtils.findShapes(predicate, penpot.selection[0] || penpot.root);
|
||||
* Validate/analyze descendants (returning corrector functions):
|
||||
const fixes = penpotUtils.analyzeDescendants(board, (root, shape) => {
|
||||
const xMod = shape.parentX % 4;
|
||||
if (xMod !== 0) {
|
||||
return () => penpotUtils.setParentXY(shape, Math.round(shape.parentX / 4) * 4, shape.parentY);
|
||||
}
|
||||
});
|
||||
fixes.forEach(f => f.result()); // Apply all fixes
|
||||
* Find containment violations:
|
||||
const violations = penpotUtils.analyzeDescendants(board, (root, shape) => {
|
||||
return !penpotUtils.isContainedIn(shape, root) ? 'outside-bounds' : null;
|
||||
});
|
||||
Always validate against the root container that is supposed to contain the shapes.
|
||||
|
||||
# Visual Inspection of Designs
|
||||
|
||||
For many tasks, it can be critical to visually inspect the design. Remember to use the `export_shape` tool for this purpose!
|
||||
|
||||
# Revising Designs
|
||||
|
||||
* Before applying design changes, ask: "Would a designer consider this appropriate?"
|
||||
* When dealing with containment issues, ask: Is the parent too small OR is the child too large?
|
||||
Container sizes are usually intentional, check content first.
|
||||
* Check for reasonable font sizes and typefaces
|
||||
* The use of flex layouts is encouraged for cases where elements are arranged in rows or columns with consistent spacing/positioning.
|
||||
Consider converting boards to flex layout when appropriate.
|
||||
|
||||
# Asset Libraries
|
||||
|
||||
Libraries in Penpot are collections of reusable design assets (components, colors, and typographies) that can be shared across files.
|
||||
They enable design systems and consistent styling across projects.
|
||||
Each Penpot file has its own local library and can connect to external shared libraries.
|
||||
|
||||
Accessing libraries: via `penpot.library` (type: `LibraryContext`):
|
||||
* `penpot.library.local` (type: `Library`) - The current file's own library
|
||||
* `penpot.library.connected` (type: `Library[]`) - Array of already-connected external libraries
|
||||
* `penpot.library.availableLibraries()` (returns: `Promise<LibrarySummary[]>`) - Libraries available to connect
|
||||
* `penpot.library.connectLibrary(libraryId: string)` (returns: `Promise<Library>`) - Connect a new library
|
||||
|
||||
Each `Library` object has:
|
||||
* `id: string`
|
||||
* `name: string`
|
||||
* `components: LibraryComponent[]` - Array of components
|
||||
* `colors: LibraryColor[]` - Array of colors
|
||||
* `typographies: LibraryTypography[]` - Array of typographies
|
||||
|
||||
Using library components:
|
||||
* find a component in the library by name:
|
||||
const component: LibraryComponent = library.components.find(comp => comp.name.includes('Button'));
|
||||
* create a new instance of the component on the current page:
|
||||
const instance: Shape = component.instance();
|
||||
This returns a `Shape` (often a `Board` containing child elements).
|
||||
After instantiation, modify the instance's properties as desired.
|
||||
* get the reference to the main component shape:
|
||||
const mainShape: Shape = component.mainInstance();
|
||||
|
||||
Adding assets to a library:
|
||||
* const newColor: LibraryColor = penpot.library.local.createColor();
|
||||
newColor.name = 'Brand Primary';
|
||||
newColor.color = '#0066FF';
|
||||
* const newTypo: LibraryTypography = penpot.library.local.createTypography();
|
||||
newTypo.name = 'Heading Large';
|
||||
// Set typography properties...
|
||||
* const shapes: Shape[] = [shape1, shape2]; // shapes to include
|
||||
const newComponent: LibraryComponent = penpot.library.local.createComponent(shapes);
|
||||
newComponent.name = 'My Button';
|
||||
|
||||
--
|
||||
You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it again.
|
||||
54
mcp/packages/server/package.json
Normal file
54
mcp/packages/server/package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "mcp-server",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP server for Penpot integration",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build:server": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/* --external:ws --external:express --external:class-transformer --external:class-validator --external:reflect-metadata --external:pino --external:pino-pretty --external:js-yaml --external:sharp",
|
||||
"build": "pnpm run build:server && cp -r src/static dist/static && cp -r data dist/data",
|
||||
"build:multi-user": "pnpm run build",
|
||||
"build:types": "tsc --emitDeclarationOnly --outDir dist",
|
||||
"start": "node dist/index.js",
|
||||
"start:multi-user": "node dist/index.js --multi-user",
|
||||
"start:dev": "node --import ts-node/register src/index.ts",
|
||||
"start:dev:multi-user": "node --loader ts-node/esm src/index.ts --multi-user",
|
||||
"types:check": "tsc --noEmit",
|
||||
"clean": "rm -rf dist/"
|
||||
},
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"penpot",
|
||||
"server"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.24.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"express": "^5.1.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"penpot-mcp": "file:..",
|
||||
"pino": "^9.10.0",
|
||||
"pino-pretty": "^13.1.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"sharp": "^0.34.5",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@penpot/mcp-common": "workspace:../common",
|
||||
"@types/express": "^4.17.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/ws": "^8.5.10",
|
||||
"esbuild": "^0.25.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"ts-node": {
|
||||
"esm": true
|
||||
}
|
||||
}
|
||||
2840
mcp/packages/server/pnpm-lock.yaml
generated
Normal file
2840
mcp/packages/server/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
128
mcp/packages/server/src/ApiDocs.ts
Normal file
128
mcp/packages/server/src/ApiDocs.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import * as yaml from "js-yaml";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
/**
|
||||
* Represents a single type/interface defined in the Penpot API
|
||||
*/
|
||||
export class ApiType {
|
||||
private readonly name: string;
|
||||
private readonly overview: string;
|
||||
private readonly members: Record<string, Record<string, string>>;
|
||||
private cachedFullText: string | null = null;
|
||||
|
||||
constructor(name: string, overview: string, members: Record<string, Record<string, string>>) {
|
||||
this.name = name;
|
||||
this.overview = overview;
|
||||
this.members = members;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the original name of this API type.
|
||||
*/
|
||||
getName(): string {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the overview text of this API type (which all signature/type declarations)
|
||||
*/
|
||||
getOverviewText() {
|
||||
return this.overview;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a single markdown text document from all parts of this API type.
|
||||
*
|
||||
* The full text is cached within the object for performance.
|
||||
*/
|
||||
getFullText(): string {
|
||||
if (this.cachedFullText === null) {
|
||||
let text = this.overview;
|
||||
|
||||
for (const [memberType, memberEntries] of Object.entries(this.members)) {
|
||||
text += `\n\n## ${memberType}\n`;
|
||||
|
||||
for (const [memberName, memberDescription] of Object.entries(memberEntries)) {
|
||||
text += `\n### ${memberName}\n\n${memberDescription}`;
|
||||
}
|
||||
}
|
||||
|
||||
this.cachedFullText = text;
|
||||
}
|
||||
|
||||
return this.cachedFullText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the description of the member with the given name.
|
||||
*
|
||||
* The member type doesn't matter for the search, as member names are unique
|
||||
* across all member types within a single API type.
|
||||
*/
|
||||
getMember(memberName: string): string | null {
|
||||
for (const memberEntries of Object.values(this.members)) {
|
||||
if (memberName in memberEntries) {
|
||||
return memberEntries[memberName];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and manages API documentation from YAML files.
|
||||
*
|
||||
* This class provides case-insensitive access to API type documentation
|
||||
* loaded from the data/api_types.yml file.
|
||||
*/
|
||||
export class ApiDocs {
|
||||
private readonly apiTypes: Map<string, ApiType> = new Map();
|
||||
|
||||
/**
|
||||
* Creates a new ApiDocs instance and loads the API types from the YAML file.
|
||||
*/
|
||||
constructor() {
|
||||
this.loadApiTypes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads API types from the data/api_types.yml file.
|
||||
*/
|
||||
private loadApiTypes(): void {
|
||||
const yamlPath = path.join(process.cwd(), "data", "api_types.yml");
|
||||
const yamlContent = fs.readFileSync(yamlPath, "utf8");
|
||||
const data = yaml.load(yamlContent) as Record<string, any>;
|
||||
|
||||
for (const [typeName, typeData] of Object.entries(data)) {
|
||||
const overview = typeData.overview || "";
|
||||
const members = typeData.members || {};
|
||||
|
||||
const apiType = new ApiType(typeName, overview, members);
|
||||
|
||||
// store with lower-case key for case-insensitive retrieval
|
||||
this.apiTypes.set(typeName.toLowerCase(), apiType);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an API type by name (case-insensitive).
|
||||
*/
|
||||
getType(typeName: string): ApiType | null {
|
||||
return this.apiTypes.get(typeName.toLowerCase()) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all available type names.
|
||||
*/
|
||||
getTypeNames(): string[] {
|
||||
return Array.from(this.apiTypes.values()).map((type) => type.getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of loaded API types.
|
||||
*/
|
||||
getTypeCount(): number {
|
||||
return this.apiTypes.size;
|
||||
}
|
||||
}
|
||||
85
mcp/packages/server/src/ConfigurationLoader.ts
Normal file
85
mcp/packages/server/src/ConfigurationLoader.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import yaml from "js-yaml";
|
||||
import { createLogger } from "./logger.js";
|
||||
|
||||
/**
|
||||
* Interface defining the structure of the prompts configuration file.
|
||||
*/
|
||||
export interface PromptsConfig {
|
||||
/** Initial instructions displayed when the server starts or connects to a client */
|
||||
initial_instructions: string;
|
||||
[key: string]: any; // Allow for future extension with additional prompt types
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration loader for prompts and server settings.
|
||||
*
|
||||
* Handles loading and parsing of YAML configuration files,
|
||||
* providing type-safe access to configuration values with
|
||||
* appropriate fallbacks for missing files or values.
|
||||
*/
|
||||
export class ConfigurationLoader {
|
||||
private readonly logger = createLogger("ConfigurationLoader");
|
||||
private readonly baseDir: string;
|
||||
private promptsConfig: PromptsConfig | null = null;
|
||||
|
||||
/**
|
||||
* Creates a new configuration loader instance.
|
||||
*
|
||||
* @param baseDir - Base directory for resolving configuration file paths
|
||||
*/
|
||||
constructor(baseDir: string) {
|
||||
this.baseDir = baseDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the prompts configuration from the YAML file.
|
||||
*
|
||||
* Reads and parses the prompts.yml file, providing cached access
|
||||
* to configuration values on subsequent calls.
|
||||
*
|
||||
* @returns The parsed prompts configuration object
|
||||
*/
|
||||
public getPromptsConfig(): PromptsConfig {
|
||||
if (this.promptsConfig !== null) {
|
||||
return this.promptsConfig;
|
||||
}
|
||||
|
||||
const promptsPath = join(this.baseDir, "data", "prompts.yml");
|
||||
|
||||
if (!existsSync(promptsPath)) {
|
||||
throw new Error(`Prompts configuration file not found at ${promptsPath}, using defaults`);
|
||||
}
|
||||
|
||||
const fileContent = readFileSync(promptsPath, "utf8");
|
||||
const parsedConfig = yaml.load(fileContent) as PromptsConfig;
|
||||
|
||||
this.promptsConfig = parsedConfig || {};
|
||||
this.logger.info(`Loaded prompts configuration from ${promptsPath}`);
|
||||
|
||||
return this.promptsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the initial instructions for the MCP server.
|
||||
*
|
||||
* @returns The initial instructions string, or undefined if not configured
|
||||
*/
|
||||
public getInitialInstructions(): string {
|
||||
const config = this.getPromptsConfig();
|
||||
return config.initial_instructions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the configuration from disk.
|
||||
*
|
||||
* Forces a fresh read of the configuration file on the next access,
|
||||
* useful for development or when configuration files are updated at runtime.
|
||||
*/
|
||||
public reloadConfiguration(): void {
|
||||
this.promptsConfig = null;
|
||||
this.logger.info("Configuration cache cleared, will reload on next access");
|
||||
}
|
||||
}
|
||||
262
mcp/packages/server/src/PenpotMcpServer.ts
Normal file
262
mcp/packages/server/src/PenpotMcpServer.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { AsyncLocalStorage } from "async_hooks";
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { ExecuteCodeTool } from "./tools/ExecuteCodeTool";
|
||||
import { PluginBridge } from "./PluginBridge";
|
||||
import { ConfigurationLoader } from "./ConfigurationLoader";
|
||||
import { createLogger } from "./logger";
|
||||
import { Tool } from "./Tool";
|
||||
import { HighLevelOverviewTool } from "./tools/HighLevelOverviewTool";
|
||||
import { PenpotApiInfoTool } from "./tools/PenpotApiInfoTool";
|
||||
import { ExportShapeTool } from "./tools/ExportShapeTool";
|
||||
import { ImportImageTool } from "./tools/ImportImageTool";
|
||||
import { ReplServer } from "./ReplServer";
|
||||
import { ApiDocs } from "./ApiDocs";
|
||||
|
||||
/**
|
||||
* Session context for request-scoped data.
|
||||
*/
|
||||
export interface SessionContext {
|
||||
userToken?: string;
|
||||
}
|
||||
|
||||
export class PenpotMcpServer {
|
||||
private readonly logger = createLogger("PenpotMcpServer");
|
||||
private readonly server: McpServer;
|
||||
private readonly tools: Map<string, Tool<any>>;
|
||||
public readonly configLoader: ConfigurationLoader;
|
||||
private app: any;
|
||||
public readonly pluginBridge: PluginBridge;
|
||||
private readonly replServer: ReplServer;
|
||||
private apiDocs: ApiDocs;
|
||||
|
||||
/**
|
||||
* Manages session-specific context, particularly user tokens for each request.
|
||||
*/
|
||||
private readonly sessionContext = new AsyncLocalStorage<SessionContext>();
|
||||
|
||||
private readonly transports = {
|
||||
streamable: {} as Record<string, StreamableHTTPServerTransport>,
|
||||
sse: {} as Record<string, { transport: SSEServerTransport; userToken?: string }>,
|
||||
};
|
||||
|
||||
private readonly port: number;
|
||||
private readonly webSocketPort: number;
|
||||
private readonly replPort: number;
|
||||
private readonly listenAddress: string;
|
||||
/**
|
||||
* the address (domain name or IP address) via which clients can reach the MCP server
|
||||
*/
|
||||
public readonly serverAddress: string;
|
||||
|
||||
constructor(private isMultiUser: boolean = false) {
|
||||
// read port configuration from environment variables
|
||||
this.port = parseInt(process.env.PENPOT_MCP_SERVER_PORT ?? "4401", 10);
|
||||
this.webSocketPort = parseInt(process.env.PENPOT_MCP_WEBSOCKET_PORT ?? "4402", 10);
|
||||
this.replPort = parseInt(process.env.PENPOT_MCP_REPL_PORT ?? "4403", 10);
|
||||
this.listenAddress = process.env.PENPOT_MCP_SERVER_LISTEN_ADDRESS ?? "0.0.0.0";
|
||||
this.serverAddress = process.env.PENPOT_MCP_SERVER_ADDRESS ?? "0.0.0.0";
|
||||
|
||||
this.configLoader = new ConfigurationLoader(process.cwd());
|
||||
this.apiDocs = new ApiDocs();
|
||||
|
||||
this.server = new McpServer(
|
||||
{
|
||||
name: "penpot-mcp-server",
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
instructions: this.getInitialInstructions(),
|
||||
}
|
||||
);
|
||||
|
||||
this.tools = new Map<string, Tool<any>>();
|
||||
this.pluginBridge = new PluginBridge(this, this.webSocketPort);
|
||||
this.replServer = new ReplServer(this.pluginBridge, this.replPort);
|
||||
|
||||
this.registerTools();
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the server is running in multi-user mode,
|
||||
* where user tokens are required for authentication.
|
||||
*/
|
||||
public isMultiUserMode(): boolean {
|
||||
return this.isMultiUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the server is running in remote mode.
|
||||
*
|
||||
* In remote mode, the server is not assumed to be accessed only by a local user on the same machine,
|
||||
* with corresponding limitations being enforced.
|
||||
* Remote mode can be explicitly enabled by setting the environment variable PENPOT_MCP_REMOTE_MODE
|
||||
* to "true". Enabling multi-user mode forces remote mode, regardless of the value of the environment
|
||||
* variable.
|
||||
*/
|
||||
public isRemoteMode(): boolean {
|
||||
const isRemoteModeRequested: boolean = process.env.PENPOT_MCP_REMOTE_MODE === "true";
|
||||
return this.isMultiUserMode() || isRemoteModeRequested;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether file system access is enabled for MCP tools.
|
||||
* Access is enabled only in local mode, where the file system is assumed
|
||||
* to belong to the user running the server locally.
|
||||
*/
|
||||
public isFileSystemAccessEnabled(): boolean {
|
||||
return !this.isRemoteMode();
|
||||
}
|
||||
|
||||
public getInitialInstructions(): string {
|
||||
let instructions = this.configLoader.getInitialInstructions();
|
||||
instructions = instructions.replace("$api_types", this.apiDocs.getTypeNames().join(", "));
|
||||
return instructions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the current session context.
|
||||
*
|
||||
* @returns The session context for the current request, or undefined if not in a request context
|
||||
*/
|
||||
public getSessionContext(): SessionContext | undefined {
|
||||
return this.sessionContext.getStore();
|
||||
}
|
||||
|
||||
private registerTools(): void {
|
||||
// Create relevant tool instances (depending on file system access)
|
||||
const toolInstances: Tool<any>[] = [
|
||||
new ExecuteCodeTool(this),
|
||||
new HighLevelOverviewTool(this),
|
||||
new PenpotApiInfoTool(this, this.apiDocs),
|
||||
new ExportShapeTool(this), // tool adapts to file system access internally
|
||||
];
|
||||
if (this.isFileSystemAccessEnabled()) {
|
||||
toolInstances.push(new ImportImageTool(this));
|
||||
}
|
||||
|
||||
for (const tool of toolInstances) {
|
||||
const toolName = tool.getToolName();
|
||||
this.tools.set(toolName, tool);
|
||||
|
||||
// Register each tool with McpServer
|
||||
this.logger.info(`Registering tool: ${toolName}`);
|
||||
this.server.registerTool(
|
||||
toolName,
|
||||
{
|
||||
description: tool.getToolDescription(),
|
||||
inputSchema: tool.getInputSchema(),
|
||||
},
|
||||
async (args) => {
|
||||
return tool.execute(args);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private setupHttpEndpoints(): void {
|
||||
/**
|
||||
* Modern Streamable HTTP connection endpoint
|
||||
*/
|
||||
this.app.all("/mcp", async (req: any, res: any) => {
|
||||
const userToken = req.query.userToken as string | undefined;
|
||||
|
||||
await this.sessionContext.run({ userToken }, async () => {
|
||||
const { randomUUID } = await import("node:crypto");
|
||||
|
||||
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
||||
let transport: StreamableHTTPServerTransport;
|
||||
|
||||
if (sessionId && this.transports.streamable[sessionId]) {
|
||||
transport = this.transports.streamable[sessionId];
|
||||
} else {
|
||||
transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (id: string) => {
|
||||
this.transports.streamable[id] = transport;
|
||||
},
|
||||
});
|
||||
|
||||
transport.onclose = () => {
|
||||
if (transport.sessionId) {
|
||||
delete this.transports.streamable[transport.sessionId];
|
||||
}
|
||||
};
|
||||
|
||||
await this.server.connect(transport);
|
||||
}
|
||||
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Legacy SSE connection endpoint
|
||||
*/
|
||||
this.app.get("/sse", async (req: any, res: any) => {
|
||||
const userToken = req.query.userToken as string | undefined;
|
||||
|
||||
await this.sessionContext.run({ userToken }, async () => {
|
||||
const transport = new SSEServerTransport("/messages", res);
|
||||
this.transports.sse[transport.sessionId] = { transport, userToken };
|
||||
|
||||
res.on("close", () => {
|
||||
delete this.transports.sse[transport.sessionId];
|
||||
});
|
||||
|
||||
await this.server.connect(transport);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* SSE message POST endpoint (using previously established session)
|
||||
*/
|
||||
this.app.post("/messages", async (req: any, res: any) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const session = this.transports.sse[sessionId];
|
||||
|
||||
if (session) {
|
||||
await this.sessionContext.run({ userToken: session.userToken }, async () => {
|
||||
await session.transport.handlePostMessage(req, res, req.body);
|
||||
});
|
||||
} else {
|
||||
res.status(400).send("No transport found for sessionId");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const { default: express } = await import("express");
|
||||
this.app = express();
|
||||
this.app.use(express.json());
|
||||
|
||||
this.setupHttpEndpoints();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.app.listen(this.port, this.listenAddress, async () => {
|
||||
this.logger.info(`Multi-user mode: ${this.isMultiUserMode()}`);
|
||||
this.logger.info(`Remote mode: ${this.isRemoteMode()}`);
|
||||
this.logger.info(`Modern Streamable HTTP endpoint: http://${this.serverAddress}:${this.port}/mcp`);
|
||||
this.logger.info(`Legacy SSE endpoint: http://${this.serverAddress}:${this.port}/sse`);
|
||||
this.logger.info(`WebSocket server URL: ws://${this.serverAddress}:${this.webSocketPort}`);
|
||||
|
||||
// start the REPL server
|
||||
await this.replServer.start();
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the MCP server and associated services.
|
||||
*
|
||||
* Gracefully shuts down the REPL server and other components.
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
this.logger.info("Stopping Penpot MCP Server...");
|
||||
await this.replServer.stop();
|
||||
this.logger.info("Penpot MCP Server stopped");
|
||||
}
|
||||
}
|
||||
227
mcp/packages/server/src/PluginBridge.ts
Normal file
227
mcp/packages/server/src/PluginBridge.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import * as http from "http";
|
||||
import { PluginTask } from "./PluginTask";
|
||||
import { PluginTaskResponse, PluginTaskResult } from "@penpot/mcp-common";
|
||||
import { createLogger } from "./logger";
|
||||
import type { PenpotMcpServer } from "./PenpotMcpServer";
|
||||
|
||||
interface ClientConnection {
|
||||
socket: WebSocket;
|
||||
userToken: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages WebSocket connections to Penpot plugin instances and handles plugin tasks
|
||||
* over these connections.
|
||||
*/
|
||||
export class PluginBridge {
|
||||
private readonly logger = createLogger("PluginBridge");
|
||||
private readonly wsServer: WebSocketServer;
|
||||
private readonly connectedClients: Map<WebSocket, ClientConnection> = new Map();
|
||||
private readonly clientsByToken: Map<string, ClientConnection> = new Map();
|
||||
private readonly pendingTasks: Map<string, PluginTask<any, any>> = new Map();
|
||||
private readonly taskTimeouts: Map<string, NodeJS.Timeout> = new Map();
|
||||
|
||||
constructor(
|
||||
public readonly mcpServer: PenpotMcpServer,
|
||||
private port: number,
|
||||
private taskTimeoutSecs: number = 30
|
||||
) {
|
||||
this.wsServer = new WebSocketServer({ port: port });
|
||||
this.setupWebSocketHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up WebSocket connection handlers for plugin communication.
|
||||
*
|
||||
* Manages client connections and provides bidirectional communication
|
||||
* channel between the MCP mcpServer and Penpot plugin instances.
|
||||
*/
|
||||
private setupWebSocketHandlers(): void {
|
||||
this.wsServer.on("connection", (ws: WebSocket, request: http.IncomingMessage) => {
|
||||
// extract userToken from query parameters
|
||||
const url = new URL(request.url!, `ws://${request.headers.host}`);
|
||||
const userToken = url.searchParams.get("userToken");
|
||||
|
||||
// require userToken if running in multi-user mode
|
||||
if (this.mcpServer.isMultiUserMode() && !userToken) {
|
||||
this.logger.warn("Connection attempt without userToken in multi-user mode - rejecting");
|
||||
ws.close(1008, "Missing userToken parameter");
|
||||
return;
|
||||
}
|
||||
|
||||
if (userToken) {
|
||||
this.logger.info("New WebSocket connection established (token provided)");
|
||||
} else {
|
||||
this.logger.info("New WebSocket connection established");
|
||||
}
|
||||
|
||||
// register the client connection with both indexes
|
||||
const connection: ClientConnection = { socket: ws, userToken };
|
||||
this.connectedClients.set(ws, connection);
|
||||
if (userToken) {
|
||||
// ensure only one connection per userToken
|
||||
if (this.clientsByToken.has(userToken)) {
|
||||
this.logger.warn("Duplicate connection for given user token; rejecting new connection");
|
||||
ws.close(1008, "Duplicate connection for given user token; close previous connection first.");
|
||||
}
|
||||
|
||||
this.clientsByToken.set(userToken, connection);
|
||||
}
|
||||
|
||||
ws.on("message", (data: Buffer) => {
|
||||
this.logger.debug("Received WebSocket message: %s", data.toString());
|
||||
try {
|
||||
const response: PluginTaskResponse<any> = JSON.parse(data.toString());
|
||||
this.handlePluginTaskResponse(response);
|
||||
} catch (error) {
|
||||
this.logger.error(error, "Failure while processing WebSocket message");
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
this.logger.info("WebSocket connection closed");
|
||||
const connection = this.connectedClients.get(ws);
|
||||
this.connectedClients.delete(ws);
|
||||
if (connection?.userToken) {
|
||||
this.clientsByToken.delete(connection.userToken);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("error", (error) => {
|
||||
this.logger.error(error, "WebSocket connection error");
|
||||
const connection = this.connectedClients.get(ws);
|
||||
this.connectedClients.delete(ws);
|
||||
if (connection?.userToken) {
|
||||
this.clientsByToken.delete(connection.userToken);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.logger.info("WebSocket mcpServer started on port %d", this.port);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles responses from the plugin for completed tasks.
|
||||
*
|
||||
* Finds the pending task by ID and resolves or rejects its promise
|
||||
* based on the execution result.
|
||||
*
|
||||
* @param response - The plugin task response containing ID and result
|
||||
*/
|
||||
private handlePluginTaskResponse(response: PluginTaskResponse<any>): void {
|
||||
const task = this.pendingTasks.get(response.id);
|
||||
if (!task) {
|
||||
this.logger.info(`Received response for unknown task ID: ${response.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the timeout and remove the task from pending tasks
|
||||
const timeoutHandle = this.taskTimeouts.get(response.id);
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
this.taskTimeouts.delete(response.id);
|
||||
}
|
||||
this.pendingTasks.delete(response.id);
|
||||
|
||||
// Resolve or reject the task's promise based on the result
|
||||
if (response.success) {
|
||||
task.resolveWithResult({ data: response.data });
|
||||
} else {
|
||||
const error = new Error(response.error || "Task execution failed (details not provided)");
|
||||
task.rejectWithError(error);
|
||||
}
|
||||
|
||||
this.logger.info(`Task ${response.id} completed: success=${response.success}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the client connection to use for executing a task.
|
||||
*
|
||||
* In single-user mode, returns the single connected client.
|
||||
* In multi-user mode, returns the client matching the session's userToken.
|
||||
*
|
||||
* @returns The client connection to use
|
||||
* @throws Error if no suitable connection is found or if configuration is invalid
|
||||
*/
|
||||
private getClientConnection(): ClientConnection {
|
||||
if (this.mcpServer.isMultiUserMode()) {
|
||||
const sessionContext = this.mcpServer.getSessionContext();
|
||||
if (!sessionContext?.userToken) {
|
||||
throw new Error("No userToken found in session context. Multi-user mode requires authentication.");
|
||||
}
|
||||
|
||||
const connection = this.clientsByToken.get(sessionContext.userToken);
|
||||
if (!connection) {
|
||||
throw new Error(
|
||||
`No plugin instance connected for user token. Please ensure the plugin is running and connected with the correct token.`
|
||||
);
|
||||
}
|
||||
|
||||
return connection;
|
||||
} else {
|
||||
// single-user mode: return the single connected client
|
||||
if (this.connectedClients.size === 0) {
|
||||
throw new Error(
|
||||
`No Penpot plugin instances are currently connected. Please ensure the plugin is running and connected.`
|
||||
);
|
||||
}
|
||||
if (this.connectedClients.size > 1) {
|
||||
throw new Error(
|
||||
`Multiple (${this.connectedClients.size}) Penpot MCP Plugin instances are connected. ` +
|
||||
`Ask the user to ensure that only one instance is connected at a time.`
|
||||
);
|
||||
}
|
||||
|
||||
// return the first (and only) connection
|
||||
const connection = this.connectedClients.values().next().value;
|
||||
return <ClientConnection>connection;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a plugin task by sending it to connected clients.
|
||||
*
|
||||
* Registers the task for result correlation and returns a promise
|
||||
* that resolves when the plugin responds with the execution result.
|
||||
*
|
||||
* @param task - The plugin task to execute
|
||||
* @throws Error if no plugin instances are connected or available
|
||||
*/
|
||||
public async executePluginTask<TResult extends PluginTaskResult<any>>(
|
||||
task: PluginTask<any, TResult>
|
||||
): Promise<TResult> {
|
||||
// get the appropriate client connection based on mode
|
||||
const connection = this.getClientConnection();
|
||||
|
||||
// register the task for result correlation
|
||||
this.pendingTasks.set(task.id, task);
|
||||
|
||||
// send task to the selected client
|
||||
const requestMessage = JSON.stringify(task.toRequest());
|
||||
if (connection.socket.readyState !== 1) {
|
||||
// WebSocket is not open
|
||||
this.pendingTasks.delete(task.id);
|
||||
throw new Error(`Plugin instance is disconnected. Task could not be sent.`);
|
||||
}
|
||||
|
||||
connection.socket.send(requestMessage);
|
||||
|
||||
// Set up a timeout to reject the task if no response is received
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
const pendingTask = this.pendingTasks.get(task.id);
|
||||
if (pendingTask) {
|
||||
this.pendingTasks.delete(task.id);
|
||||
this.taskTimeouts.delete(task.id);
|
||||
pendingTask.rejectWithError(
|
||||
new Error(`Task ${task.id} timed out after ${this.taskTimeoutSecs} seconds`)
|
||||
);
|
||||
}
|
||||
}, this.taskTimeoutSecs * 1000);
|
||||
|
||||
this.taskTimeouts.set(task.id, timeoutHandle);
|
||||
this.logger.info(`Sent task ${task.id} to connected client`);
|
||||
|
||||
return await task.getResultPromise();
|
||||
}
|
||||
}
|
||||
122
mcp/packages/server/src/PluginTask.ts
Normal file
122
mcp/packages/server/src/PluginTask.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Base class for plugin tasks that are sent over WebSocket.
|
||||
*
|
||||
* Each task defines a specific operation for the plugin to execute
|
||||
* along with strongly-typed parameters.
|
||||
*
|
||||
* @template TParams - The strongly-typed parameters for this task
|
||||
*/
|
||||
import { PluginTaskRequest, PluginTaskResult } from "@penpot/mcp-common";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
/**
|
||||
* Base class for plugin tasks that are sent over WebSocket.
|
||||
*
|
||||
* Each task defines a specific operation for the plugin to execute
|
||||
* along with strongly-typed parameters and request/response correlation.
|
||||
*
|
||||
* @template TParams - The strongly-typed parameters for this task
|
||||
* @template TResult - The expected result type from task execution
|
||||
*/
|
||||
export abstract class PluginTask<TParams = any, TResult extends PluginTaskResult<any> = PluginTaskResult<any>> {
|
||||
/**
|
||||
* Unique identifier for request/response correlation.
|
||||
*/
|
||||
public readonly id: string;
|
||||
|
||||
/**
|
||||
* The name of the task to execute on the plugin side.
|
||||
*/
|
||||
public readonly task: string;
|
||||
|
||||
/**
|
||||
* The parameters for this task execution.
|
||||
*/
|
||||
public readonly params: TParams;
|
||||
|
||||
/**
|
||||
* Promise that resolves when the task execution completes.
|
||||
*/
|
||||
private readonly result: Promise<TResult>;
|
||||
|
||||
/**
|
||||
* Resolver function for the result promise.
|
||||
*/
|
||||
private resolveResult?: (result: TResult) => void;
|
||||
|
||||
/**
|
||||
* Rejector function for the result promise.
|
||||
*/
|
||||
private rejectResult?: (error: Error) => void;
|
||||
|
||||
/**
|
||||
* Creates a new plugin task instance.
|
||||
*
|
||||
* @param task - The name of the task to execute
|
||||
* @param params - The parameters for task execution
|
||||
*/
|
||||
constructor(task: string, params: TParams) {
|
||||
this.id = randomUUID();
|
||||
this.task = task;
|
||||
this.params = params;
|
||||
this.result = new Promise<TResult>((resolve, reject) => {
|
||||
this.resolveResult = resolve;
|
||||
this.rejectResult = reject;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the result promise for this task.
|
||||
*
|
||||
* @returns Promise that resolves when the task execution completes
|
||||
*/
|
||||
getResultPromise(): Promise<TResult> {
|
||||
if (!this.result) {
|
||||
throw new Error("Result promise not initialized");
|
||||
}
|
||||
return this.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the task with the given result.
|
||||
*
|
||||
* This method should be called when a task response is received
|
||||
* from the plugin with matching ID.
|
||||
*
|
||||
* @param result - The task execution result
|
||||
*/
|
||||
resolveWithResult(result: TResult): void {
|
||||
if (!this.resolveResult) {
|
||||
throw new Error("Result promise not initialized");
|
||||
}
|
||||
this.resolveResult(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects the task with the given error.
|
||||
*
|
||||
* This method should be called when task execution fails
|
||||
* or times out.
|
||||
*
|
||||
* @param error - The error that occurred during task execution
|
||||
*/
|
||||
rejectWithError(error: Error): void {
|
||||
if (!this.rejectResult) {
|
||||
throw new Error("Result promise not initialized");
|
||||
}
|
||||
this.rejectResult(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the task to a request message for WebSocket transmission.
|
||||
*
|
||||
* @returns The request message containing ID, task name, and parameters
|
||||
*/
|
||||
toRequest(): PluginTaskRequest {
|
||||
return {
|
||||
id: this.id,
|
||||
task: this.task,
|
||||
params: this.params,
|
||||
};
|
||||
}
|
||||
}
|
||||
112
mcp/packages/server/src/ReplServer.ts
Normal file
112
mcp/packages/server/src/ReplServer.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import express from "express";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { PluginBridge } from "./PluginBridge";
|
||||
import { ExecuteCodePluginTask } from "./tasks/ExecuteCodePluginTask";
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
/**
|
||||
* Web-based REPL server for executing code through the PluginBridge.
|
||||
*
|
||||
* Provides a REPL-style HTML interface that allows users to input
|
||||
* JavaScript code and execute it via ExecuteCodePluginTask instances.
|
||||
* The interface maintains command history, displays logs in <pre> tags,
|
||||
* and shows results in visually separated blocks.
|
||||
*/
|
||||
export class ReplServer {
|
||||
private readonly logger = createLogger("ReplServer");
|
||||
private readonly app: express.Application;
|
||||
private readonly port: number;
|
||||
private server: any;
|
||||
|
||||
constructor(
|
||||
private readonly pluginBridge: PluginBridge,
|
||||
port: number = 4403
|
||||
) {
|
||||
this.port = port;
|
||||
this.app = express();
|
||||
this.setupMiddleware();
|
||||
this.setupRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up Express middleware for request parsing and static content.
|
||||
*/
|
||||
private setupMiddleware(): void {
|
||||
this.app.use(express.json());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up HTTP routes for the REPL interface and API endpoints.
|
||||
*/
|
||||
private setupRoutes(): void {
|
||||
// serve the main REPL interface
|
||||
this.app.get("/", (req, res) => {
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const htmlPath = path.join(__dirname, "static", "repl.html");
|
||||
res.sendFile(htmlPath);
|
||||
});
|
||||
|
||||
// API endpoint for executing code
|
||||
this.app.post("/execute", async (req, res) => {
|
||||
try {
|
||||
const { code } = req.body;
|
||||
|
||||
if (!code || typeof code !== "string") {
|
||||
return res.status(400).json({
|
||||
error: "Code parameter is required and must be a string",
|
||||
});
|
||||
}
|
||||
|
||||
const task = new ExecuteCodePluginTask({ code });
|
||||
const result = await this.pluginBridge.executePluginTask(task);
|
||||
|
||||
// extract the result member from ExecuteCodeTaskResultData
|
||||
const executeResult = result.data?.result;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: executeResult,
|
||||
log: result.data?.log || "",
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(error, "Failed to execute code in REPL");
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : "Unknown error occurred",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the REPL web server.
|
||||
*
|
||||
* Begins listening on the configured port and logs server startup information.
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this.server = this.app.listen(this.port, () => {
|
||||
this.logger.info(`REPL server started on port ${this.port}`);
|
||||
this.logger.info(
|
||||
`REPL interface URL: http://${this.pluginBridge.mcpServer.serverAddress}:${this.port}`
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the REPL web server.
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (this.server) {
|
||||
return new Promise((resolve) => {
|
||||
this.server.close(() => {
|
||||
this.logger.info("REPL server stopped");
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user