From 4f852e33bfad1fd89790236f1e47299937adfd88 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 8 Jun 2026 09:59:33 +0200 Subject: [PATCH 1/9] :rewind: Backport mcp package changes from develop --- mcp/.serena/memories/project_overview.md | 73 ++-- mcp/.serena/project.yml | 124 +++--- mcp/README.md | 17 +- mcp/packages/plugin/src/TaskHandler.ts | 26 +- mcp/packages/plugin/src/main.ts | 39 +- mcp/packages/plugin/src/plugin.ts | 15 +- .../server/data/initial_instructions.md | 7 +- mcp/packages/server/package.json | 8 +- ...integration-test-export-image-semaphore.ts | 116 ++++++ mcp/packages/server/src/NreplClient.ts | 217 ++++++++++ mcp/packages/server/src/PenpotMcpServer.ts | 98 ++++- mcp/packages/server/src/PluginBridge.ts | 146 ++++++- mcp/packages/server/src/PluginTask.ts | 117 ++++-- mcp/packages/server/src/RedisBridge.ts | 182 +++++++++ mcp/packages/server/src/RemotePluginTask.ts | 56 +++ .../server/src/tools/CljCheckParentheses.ts | 242 ++++++++++++ .../src/tools/CljsCompilerOutputTool.ts | 52 +++ mcp/packages/server/src/tools/CljsReplTool.ts | 75 ++++ .../server/src/tools/ExportShapeTool.ts | 55 ++- .../server/src/tools/ImportPenpotFileTool.ts | 370 ++++++++++++++++++ .../server/src/tools/ReadTaigaIssueTool.ts | 163 ++++++++ .../server/src/types/nrepl-client.d.ts | 53 +++ mcp/packages/server/src/utils/Semaphore.ts | 69 ++++ mcp/pnpm-lock.yaml | 364 ++++++++++++++++- mcp/scripts/start-mcp-devenv | 6 + 25 files changed, 2497 insertions(+), 193 deletions(-) create mode 100644 mcp/packages/server/scripts/integration-test-export-image-semaphore.ts create mode 100644 mcp/packages/server/src/NreplClient.ts create mode 100644 mcp/packages/server/src/RedisBridge.ts create mode 100644 mcp/packages/server/src/RemotePluginTask.ts create mode 100644 mcp/packages/server/src/tools/CljCheckParentheses.ts create mode 100644 mcp/packages/server/src/tools/CljsCompilerOutputTool.ts create mode 100644 mcp/packages/server/src/tools/CljsReplTool.ts create mode 100644 mcp/packages/server/src/tools/ImportPenpotFileTool.ts create mode 100644 mcp/packages/server/src/tools/ReadTaigaIssueTool.ts create mode 100644 mcp/packages/server/src/types/nrepl-client.d.ts create mode 100644 mcp/packages/server/src/utils/Semaphore.ts create mode 100755 mcp/scripts/start-mcp-devenv diff --git a/mcp/.serena/memories/project_overview.md b/mcp/.serena/memories/project_overview.md index 7e80a5e2d5..64460c0289 100644 --- a/mcp/.serena/memories/project_overview.md +++ b/mcp/.serena/memories/project_overview.md @@ -1,53 +1,69 @@ -# Penpot MCP Project Overview - Updated +# Penpot MCP -## Purpose -This project is a Model Context Protocol (MCP) server for Penpot integration. +This subproject provides an MCP server for Penpot integration. The MCP server communicates with a Penpot plugin via WebSockets, allowing -the MCP server to send tasks to the plugin and receive results, +the MCP server to send tasks to the plugin and receive results, enabling advanced AI-driven features in Penpot. ## 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 +- Language: TypeScript +- Runtime: Node.js +- Framework: MCP SDK (@modelcontextprotocol/sdk) +- Build Tool: TypeScript Compiler (tsc) + esbuild +- Package Manager: pnpm + +## General Principles + +IMPORTANT: 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). + +## Project Structure (Excerpt) + ``` / (project root) ├── packages/common/ # Shared type definitions │ ├── src/ -│ │ ├── index.ts # Exports for shared types +│ │ ├── index.ts # exports for shared types │ │ └── types.ts # PluginTaskResult, request/response interfaces │ └── package.json # @penpot-mcp/common package -├── packages/server/ # Main MCP server implementation +├── packages/server/ # MCP server subproject │ ├── src/ -│ │ ├── index.ts # Main server entry point -│ │ ├── PenpotMcpServer.ts # Enhanced with request/response correlation -│ │ ├── PluginTask.ts # Now supports result promises +│ │ ├── index.ts # entry point +│ │ ├── PenpotMcpServer.ts # MCP server implementation (connection handling, tool registration, etc.) +│ │ ├── Tool.ts # base class for tools +│ │ ├── PluginTask.ts # base class for plugin tasks │ │ ├── tasks/ # PluginTask implementations │ │ └── tools/ # Tool implementations -| ├── data/ # Contains resources, such as API info and prompts -│ └── package.json # Includes @penpot-mcp/common dependency -├── packages/plugin/ # Penpot plugin with response capability +| ├── data/ # contains resources, such as API info and prompts +│ └── package.json +├── packages/plugin/ # Penpot plugin subproject │ ├── src/ -│ │ ├── main.ts # Enhanced WebSocket handling with response forwarding -│ │ └── plugin.ts # Now sends task responses back to server +│ │ ├── main.ts # handles communication +│ │ └── plugin.ts # plugin implementation │ └── package.json # Includes @penpot-mcp/common dependency └── prepare-api-docs # Python project for the generation of API docs ``` -## Key Tasks +## Key Development Tasks -### Adjusting the System Prompt +### Adjusting the Prompts -The system prompt file is located in `packages/server/data/initial_instructions.md`. +The system prompt file (aka Penpot High-Level Overview) is located in +`packages/server/data/initial_instructions.md`. ### Adding a new Tool -1. Implement the tool class in `packages/server/src/tools/` following the `Tool` interface. +1. Implement the tool class in `packages/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`. @@ -62,3 +78,10 @@ Many tools build on `ExecuteCodePluginTask`, as many operations can be reduced t * In the success case, call `task.sendSuccess`. * In the failure case, just throw an exception, which will be handled centrally! 4. Register the task handler in `packages/plugin/src/plugin.ts` in the `taskHandlers` list. + +## Dev Tooling + +From the project root directory, run + +* `pnpm run build` to test the build of all package +* `pnpm run fmt` to apply the auto-formatter diff --git a/mcp/.serena/project.yml b/mcp/.serena/project.yml index e5729836cd..7fac475f69 100644 --- a/mcp/.serena/project.yml +++ b/mcp/.serena/project.yml @@ -1,11 +1,10 @@ -# whether to use the project's gitignore file to ignore files -# Added on 2025-04-07 + +# whether to use project's .gitignore files to ignore files 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 +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. ignored_paths: [] # whether the project is in read-only mode @@ -13,65 +12,15 @@ ignored_paths: [] # 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. +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html 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). - - Always read the "project_overview" memory. - - 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). + CRITICAL: Always read the "project_overview" memory. # the name by which the project can be referenced within Serena project_name: "penpot-mcp" @@ -83,18 +32,24 @@ project_name: "penpot-mcp" # 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. +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. # Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. # This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes default_modes: -# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html 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. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html fixed_tools: [] # the encoding used by text files in the project @@ -102,23 +57,28 @@ fixed_tools: [] 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 +# al angular ansible bash clojure +# cpp cpp_ccls crystal csharp csharp_omnisharp +# dart elixir elm erlang fortran +# fsharp go groovy haskell haxe +# hlsl html java json julia +# kotlin lean4 lua luau markdown +# matlab msl nix ocaml pascal +# perl php php_phpactor powershell python +# python_jedi python_ty r rego ruby +# ruby_solargraph rust scala scss solidity +# svelte swift systemverilog 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 Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) # - For Free Pascal/Lazarus, use pascal # Special requirements: # Some languages require additional setup/installations. @@ -164,3 +124,19 @@ ignored_memory_patterns: [] # Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. # No documentation on options means no options are available. ls_specific_settings: {} + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + +# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries. +# Currently supported for: TypeScript. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +additional_workspace_folders: [] diff --git a/mcp/README.md b/mcp/README.md index c13c1803e2..3efc6255e7 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -264,13 +264,16 @@ The Penpot MCP server can be configured using environment variables. ### Server Configuration -| Environment Variable | Description | Default | -|------------------------------------|----------------------------------------------------------------------------|--------------| -| `PENPOT_MCP_SERVER_HOST` | 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_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` | +| Environment Variable | Description | Default | +|--------------------------------------------------|----------------------------------------------------------------------------|----------------| +| `PENPOT_MCP_SERVER_HOST` | 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_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` | +| `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools. Set to `true` to enable. | `false` | +| `PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS` | Maximum number of parallel export shape requests (multi-user mode only). | `0` (no limit) | +| `PENPOT_MCP_REDIS_URI` | Redis connection URI (e.g. `redis://host:6379`) enabling multi-instance horizontal scaling via Redis pub/sub task routing (multi-user mode only). When unset, the server runs in single-instance mode, requiring the plugin and MCP client to connect to the same instance. | (unset) | ### Logging Configuration diff --git a/mcp/packages/plugin/src/TaskHandler.ts b/mcp/packages/plugin/src/TaskHandler.ts index b09a6c0c17..a8f05a4229 100644 --- a/mcp/packages/plugin/src/TaskHandler.ts +++ b/mcp/packages/plugin/src/TaskHandler.ts @@ -24,8 +24,10 @@ export class Task { return; } + // create response object const response = { type: "task-response", + // NOTE: This inner response schema also constructed in main.ts/sendTaskResponse. response: { id: this.requestId, success: success, @@ -35,7 +37,29 @@ export class Task { }; // Send to main.ts which will forward to MCP server via WebSocket - penpot.ui.sendMessage(response); + try { + // TODO: Remove ts-ignore once Penpot types have been updated + // @ts-ignore + penpot.ui.sendMessage(response, true); + } catch (sendError) { + console.error("Failed to transfer task response for transmission to MCP server", sendError); + if (success) { + console.info("Original response was a success response, sending an error response instead."); + // If we were trying to send a success response, send an error response instead + let cause = sendError instanceof Error ? sendError.message : String(sendError); + this.sendError( + "Unexpected error sending task response: " + + cause + + " If you are an LLM and tried to serialize a regular object (shape, token, etc.), " + + "tell the user to report this error, providing information on the request you made." + ); + } else { + console.info( + "Original response was an error response, abandoning attempt to send alternative response to avoid infinite error loop." + ); + } + return; + } console.log("Sent task response:", response); this.isResponseSent = true; } diff --git a/mcp/packages/plugin/src/main.ts b/mcp/packages/plugin/src/main.ts index 40b5bd7ba8..94210da288 100644 --- a/mcp/packages/plugin/src/main.ts +++ b/mcp/packages/plugin/src/main.ts @@ -1,12 +1,29 @@ import "./style.css"; +/** + * the maximum allowed size for task responses sent back to the MCP server in the integrated remote MCP mode. + * This bounds the JSON response size. + * Note that in the remote MCP case, responses are transferred to LLMs (not the file system) and LLMs have + * size limitations. This serves to bound the size of returned images in particular. + * Too many overly large simultaneous responses can cause OOM issues in the MCP server, so this contributes + * to bounding memory usage in the centrally provided MCP server. + */ +const MAX_TASK_RESPONSE_SIZE_REMOTE_MCP = 15_000_000; + // get the current theme from the URL const searchParams = new URLSearchParams(window.location.hash.split("?")[1]); document.body.dataset.theme = searchParams.get("theme") ?? "light"; -// WebSocket connection management +// WebSocket connection to the MCP server let ws: WebSocket | null = null; +/** + * indicates whether the plugin is running with the Penpot-integrated remote MCP server enabled + * (as opposed to a local server used with the explicitly loaded plugin); + * set via the "mcp-mode" message sent by plugin.ts on initialization + */ +let isIntegratedRemoteMcp = false; + const statusPill = document.getElementById("connection-status") as HTMLElement; const statusText = document.getElementById("status-text") as HTMLElement; const currentTaskEl = document.getElementById("current-task") as HTMLElement; @@ -79,7 +96,22 @@ function updateExecutedCode(code: string | null): void { */ function sendTaskResponse(response: any): void { if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(response)); + let responseString = JSON.stringify(response); + if (isIntegratedRemoteMcp && responseString.length > MAX_TASK_RESPONSE_SIZE_REMOTE_MCP) { + const errorMessage = `Serialised response size (${responseString.length}) exceeds maximum of ${MAX_TASK_RESPONSE_SIZE_REMOTE_MCP}.`; + console.warn( + errorMessage + + " [integrated remote MCP mode restriction]; sending error response instead; original response:", + response + ); + response = { + id: response.id, + success: false, + error: errorMessage, + }; + responseString = JSON.stringify(response); + } + ws.send(responseString); console.log("Sent response to MCP server:", response); } else { console.error("WebSocket not connected, cannot send response"); @@ -176,6 +208,9 @@ disconnectBtn?.addEventListener("click", () => { // Listen plugin.ts messages window.addEventListener("message", (event) => { + if (event.data.type === "mcp-mode") { + isIntegratedRemoteMcp = event.data.integratedRemoteMcp; + } if (event.data.type === "start-server") { connectToMcpServer(event.data.url, event.data.token); } diff --git a/mcp/packages/plugin/src/plugin.ts b/mcp/packages/plugin/src/plugin.ts index 3827db70eb..34e0349e96 100644 --- a/mcp/packages/plugin/src/plugin.ts +++ b/mcp/packages/plugin/src/plugin.ts @@ -1,6 +1,12 @@ import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler"; import { Task, TaskHandler } from "./TaskHandler"; +/** + * indicates whether the plugin is running in an environment with the Penpot-integrated remote MCP server + * enabled (as opposed to a local server used with the explicitly loaded plugin) + */ +const isIntegratedRemoteMcp = !!mcp; + /** * Extracts the major.minor.patch prefix from a version string. * @@ -23,12 +29,17 @@ const taskHandlers: TaskHandler[] = [new ExecuteCodeTaskHandler()]; penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}`, { width: 236, height: 210, - hidden: !!mcp, + hidden: isIntegratedRemoteMcp, } as any); // Register message handlers penpot.ui.onMessage((message) => { if (typeof message === "object" && message.type === "ui-initialized") { + // Inform the UI about the operating mode + penpot.ui.sendMessage({ + type: "mcp-mode", + integratedRemoteMcp: isIntegratedRemoteMcp, + }); // Check Penpot version compatibility const penpotVersionPrefix = penpot.version ? extractVersionPrefix(penpot.version) : "<2.15"; // pre-2.15 versions don't have version info const mcpVersionPrefix = extractVersionPrefix(PENPOT_MCP_VERSION); @@ -42,7 +53,7 @@ penpot.ui.onMessage N parallel MCP clients that each call the export_shape tool. + * + * Expectations (observed manually from the server's console output): + * - "Semaphore 'ExportShapeTool' saturated; request queued (k waiting)" lines appear + * at INFO level (at least M - N of them). + * - All M tool calls return successfully. + * - Total elapsed wall-clock time is approximately ceil(M / N) * SLEEP_MS. + * + * Invoke from packages/server with: + * pnpm run test:integration:export + */ +import * as net from "node:net"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +import { PenpotMcpServer } from "../src/PenpotMcpServer"; +import { ExportShapeTool } from "../src/tools/ExportShapeTool"; +import { TextResponse } from "../src/ToolResponse"; +import { Semaphore } from "../src/utils/Semaphore"; + +// === parameters === + +const N = 3; +const M = 6; +const SLEEP_MS = 5_000; + +// === helpers === + +async function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.unref(); + srv.on("error", reject); + srv.listen(0, "127.0.0.1", () => { + const port = (srv.address() as net.AddressInfo).port; + srv.close(() => resolve(port)); + }); + }); +} + +async function callExportShape(url: URL, idx: number): Promise { + const client = new Client({ name: `integration-test-client-${idx}`, version: "0.0.0" }); + const transport = new StreamableHTTPClientTransport(url); + await client.connect(transport); + try { + return await client.callTool({ + name: "export_shape", + arguments: { shapeId: "selection" }, + }); + } finally { + await client.close(); + } +} + +// === main === + +async function main(): Promise { + // dynamic ports must be set before PenpotMcpServer is constructed + const httpPort = await findFreePort(); + process.env.PENPOT_MCP_SERVER_HOST = "127.0.0.1"; + process.env.PENPOT_MCP_SERVER_PORT = String(httpPort); + process.env.PENPOT_MCP_WEBSOCKET_PORT = String(await findFreePort()); + process.env.PENPOT_MCP_REPL_PORT = String(await findFreePort()); + + // shrink the gate and stub the worker + (ExportShapeTool as any).parallelismSemaphore = new Semaphore("ExportShapeTool", N); + (ExportShapeTool.prototype as any).exportImage = async (): Promise => { + await new Promise((r) => setTimeout(r, SLEEP_MS)); + return new TextResponse("stubbed export"); + }; + + const server = new PenpotMcpServer(/* isMultiUser */ true); + await server.start(); + + console.log(`\n=== integration test: N=${N} permits, M=${M} clients, sleep=${SLEEP_MS}ms ===\n`); + + // fire M parallel tool calls, each via its own MCP session with a distinct userToken + const start = Date.now(); + const results = await Promise.allSettled( + Array.from({ length: M }, (_, i) => + callExportShape(new URL(`http://127.0.0.1:${httpPort}/mcp?userToken=test-token-${i}`), i) + ) + ); + const elapsed = Date.now() - start; + + await server.stop(); + + // report + const failures = results.map((r, i) => ({ r, i })).filter(({ r }) => r.status === "rejected"); + + console.log(`\n=== results ===`); + console.log(` elapsed: ${elapsed}ms (expected ~${Math.ceil(M / N) * SLEEP_MS}ms)`); + console.log(` succeeded: ${M - failures.length}/${M}`); + if (failures.length > 0) { + console.log(` failures:`); + for (const { r, i } of failures) { + console.log(` [${i}] ${(r as PromiseRejectedResult).reason}`); + } + process.exit(1); + } + console.log(`\nAll ${M} tool calls succeeded. Scroll up to verify saturation log lines.\n`); + process.exit(0); +} + +main().catch((err) => { + console.error("Integration test crashed:", err); + process.exit(1); +}); diff --git a/mcp/packages/server/src/NreplClient.ts b/mcp/packages/server/src/NreplClient.ts new file mode 100644 index 0000000000..d550812594 --- /dev/null +++ b/mcp/packages/server/src/NreplClient.ts @@ -0,0 +1,217 @@ +import nreplClient from "nrepl-client"; +import type { NreplConnection, NreplMessage } from "nrepl-client"; +import { createLogger } from "./logger"; + +/** + * Result of evaluating a ClojureScript expression via nREPL. + */ +export interface NreplEvalResult { + /** the returned value(s) as strings */ + values: string[]; + /** captured stdout output */ + out: string; + /** captured stderr output */ + err: string; + /** the namespace after evaluation */ + ns: string; +} + +/** + * A client for communicating with a shadow-cljs nREPL server. + * + * This client maintains a persistent nREPL session, so that definitions, + * requires, and other state are preserved across evaluations — providing + * a full REPL experience. + */ +export class NreplClient { + private static readonly NREPL_PORT = 3447; + private static readonly NREPL_HOST = "localhost"; + private static readonly EVAL_TIMEOUT_MS = 30_000; + + private readonly logger = createLogger("NreplClient"); + + /** the persistent connection to the nREPL server, established lazily */ + private connection: NreplConnection | null = null; + + /** the cloned session ID that persists state across evaluations */ + private sessionId: string | null = null; + + /** + * Evaluates a Clojure expression on the nREPL server within the persistent session. + * + * @param code - the Clojure expression to evaluate + * @returns the evaluation result + */ + async eval(code: string): Promise { + this.logger.debug("Evaluating Clojure expression: %s", code); + const conn = await this.ensureConnection(); + const sessionId = await this.ensureSession(conn); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`nREPL evaluation timed out after ${NreplClient.EVAL_TIMEOUT_MS}ms`)); + }, NreplClient.EVAL_TIMEOUT_MS); + + conn.send({ op: "eval", code, session: sessionId }, (err: Error | null, result: NreplMessage[]) => { + clearTimeout(timeout); + if (err) { + reject(err); + return; + } + try { + resolve(this.parseEvalResult(result)); + } catch (parseErr) { + reject(parseErr); + } + }); + }); + } + + /** + * Evaluates a ClojureScript expression via the shadow-cljs CLJS eval API. + * + * The expression is wrapped in a call to `shadow.cljs.devtools.api/cljs-eval` + * targeting the `:main` build, so it is evaluated in the browser runtime. + * + * @param cljsCode - the ClojureScript expression to evaluate + * @returns the evaluation result + */ + async evalCljs(cljsCode: string): Promise { + // escape the CLJS code for embedding in a Clojure string + const escapedCode = cljsCode.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const wrappedCode = `(shadow.cljs.devtools.api/cljs-eval :main "${escapedCode}" {})`; + this.logger.debug("Evaluating CLJS expression via shadow-cljs: %s", cljsCode); + return this.eval(wrappedCode); + } + + /** + * Closes the persistent connection and session, releasing all resources. + */ + async close(): Promise { + if (this.connection) { + this.logger.info("Closing nREPL connection"); + this.connection.end(); + this.connection = null; + this.sessionId = null; + } + } + + /** + * Ensures a connection to the nREPL server is established, creating one if necessary. + * + * If the existing connection has been closed or errored, a new one is created. + */ + private async ensureConnection(): Promise { + if (this.connection && !this.connection.destroyed) { + return this.connection; + } + + // reset state since the old connection is gone + this.connection = null; + this.sessionId = null; + + this.logger.info("Connecting to nREPL server at %s:%d", NreplClient.NREPL_HOST, NreplClient.NREPL_PORT); + + return new Promise((resolve, reject) => { + const conn = nreplClient.connect({ + port: NreplClient.NREPL_PORT, + host: NreplClient.NREPL_HOST, + }); + + conn.once("connect", () => { + this.connection = conn; + + // handle unexpected disconnects so the next eval reconnects + conn.once("close", () => { + this.logger.warn("nREPL connection closed unexpectedly"); + this.connection = null; + this.sessionId = null; + }); + + conn.once("error", (err: Error) => { + this.logger.error("nREPL connection error: %s", err); + this.connection = null; + this.sessionId = null; + }); + + resolve(conn); + }); + + conn.once("error", (err: Error) => { + reject( + new Error( + `Failed to connect to nREPL server at ${NreplClient.NREPL_HOST}:${NreplClient.NREPL_PORT}: ${err.message}` + ) + ); + }); + }); + } + + /** + * Ensures a persistent nREPL session exists, cloning one from the server if necessary. + * + * A cloned session maintains its own state (namespace bindings, definitions, etc.) + * independently of other sessions. + */ + private async ensureSession(conn: NreplConnection): Promise { + if (this.sessionId) { + return this.sessionId; + } + + this.logger.info("Cloning new nREPL session"); + + return new Promise((resolve, reject) => { + conn.clone((err: Error | null, result: NreplMessage[]) => { + if (err) { + reject(new Error(`Failed to clone nREPL session: ${err.message}`)); + return; + } + + const sessionMsg = result.find((msg) => msg["new-session"] !== undefined) as any; + if (!sessionMsg) { + reject(new Error("nREPL clone response did not contain a new session ID")); + return; + } + + this.sessionId = sessionMsg["new-session"]; + this.logger.info("Cloned nREPL session: %s", this.sessionId); + resolve(this.sessionId!); + }); + }); + } + + /** + * Parses the raw nREPL response messages into a structured result. + */ + private parseEvalResult(messages: NreplMessage[]): NreplEvalResult { + const values: string[] = []; + const outParts: string[] = []; + const errParts: string[] = []; + let ns = "user"; + + for (const msg of messages) { + if (msg.value !== undefined) { + values.push(msg.value); + } + if (msg.out) { + outParts.push(msg.out); + } + if (msg.err) { + errParts.push(msg.err); + } + if (msg.ns) { + ns = msg.ns; + } + if (msg.ex) { + throw new Error(`nREPL evaluation error: ${msg.ex}${msg.err ? "\n" + msg.err : ""}`); + } + } + + return { + values, + out: outParts.join(""), + err: errParts.join(""), + ns, + }; + } +} diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index 6bd3ca33e5..2695acdd65 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -4,6 +4,7 @@ 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 { RedisBridge } from "./RedisBridge"; import { ConfigurationLoader } from "./ConfigurationLoader"; import { createLogger } from "./logger"; import { Tool } from "./Tool"; @@ -11,6 +12,12 @@ import { HighLevelOverviewTool } from "./tools/HighLevelOverviewTool"; import { PenpotApiInfoTool } from "./tools/PenpotApiInfoTool"; import { ExportShapeTool } from "./tools/ExportShapeTool"; import { ImportImageTool } from "./tools/ImportImageTool"; +import { CljsReplTool } from "./tools/CljsReplTool"; +import { ImportPenpotFileTool } from "./tools/ImportPenpotFileTool"; +import { CljsCompilerOutputTool } from "./tools/CljsCompilerOutputTool"; +import { CljCheckParentheses } from "./tools/CljCheckParentheses"; +import { ReadTaigaIssueTool } from "./tools/ReadTaigaIssueTool"; +import { NreplClient } from "./NreplClient"; import { ReplServer } from "./ReplServer"; import { ApiDocs } from "./ApiDocs"; @@ -45,7 +52,7 @@ class ToolInfo { export class PenpotMcpServer { /** - * Timeout, in minutes, for idle Streamable HTTP sessions before they are automatically closed and removed. + * Timeout, in minutes, for idle sessions (Streamable HTTP and SSE) before they are automatically closed and removed. */ private static readonly SESSION_TIMEOUT_MINUTES = 60; @@ -87,7 +94,10 @@ export class PenpotMcpServer { private readonly sessionContext = new AsyncLocalStorage(); private readonly streamableTransports: Record = {}; - private readonly sseTransports: Record = {}; + private readonly sseTransports: Record< + string, + { transport: SSEServerTransport; userToken?: string; lastActiveTime: number } + > = {}; public readonly host: string; public readonly port: number; @@ -95,6 +105,12 @@ export class PenpotMcpServer { public readonly replPort: number; private sessionTimeoutInterval: ReturnType | undefined; + /** + * Optional Redis bridge for multi-instance task routing; present only when running + * in multi-user mode with a configured Redis URI. + */ + private readonly redisBridge?: RedisBridge; + constructor(private isMultiUser: boolean = false) { // read port configuration from environment variables this.host = process.env.PENPOT_MCP_SERVER_HOST ?? "localhost"; @@ -113,7 +129,15 @@ export class PenpotMcpServer { this.tools = this.initTools(); - this.pluginBridge = new PluginBridge(this, this.webSocketPort); + // Enable multi-instance task routing when running in multi-user mode with a + // configured Redis URI. Without it, the server operates in single-instance mode, + // requiring the plugin and the MCP client to connect to the same instance. + const redisUri = process.env.PENPOT_MCP_REDIS_URI; + if (this.isMultiUser && redisUri) { + this.redisBridge = new RedisBridge(redisUri); + } + + this.pluginBridge = new PluginBridge(this, this.webSocketPort, this.redisBridge); this.replServer = new ReplServer(this.pluginBridge, this.replPort, this.host); } @@ -148,6 +172,16 @@ export class PenpotMcpServer { return !this.isRemoteMode(); } + /** + * Indicates whether the server is running in a Penpot development environment. + * + * When enabled (by setting the environment variable PENPOT_MCP_DEVENV to "true"), + * additional developer tools such as ClojureScript expression evaluation are exposed. + */ + public isDevEnv(): boolean { + return process.env.PENPOT_MCP_DEVENV === "true"; + } + /** * Retrieves the high-level overview instructions explaining core Penpot usage. */ @@ -174,6 +208,14 @@ export class PenpotMcpServer { if (this.isFileSystemAccessEnabled()) { toolInstances.push(new ImportImageTool(this)); } + if (this.isDevEnv()) { + const nreplClient = new NreplClient(); + toolInstances.push(new CljsReplTool(this, nreplClient)); + toolInstances.push(new ImportPenpotFileTool(this, nreplClient)); + toolInstances.push(new CljsCompilerOutputTool(this, nreplClient)); + toolInstances.push(new CljCheckParentheses(this)); + toolInstances.push(new ReadTaigaIssueTool(this)); + } return toolInstances.map((instance) => { this.logger.info(`Registering tool: ${instance.getToolName()}`); @@ -201,7 +243,7 @@ export class PenpotMcpServer { } /** - * Starts a periodic timer that closes and removes Streamable HTTP sessions that have been + * Starts a periodic timer that closes and removes Streamable HTTP and SSE sessions that have been * idle for longer than {@link SESSION_TIMEOUT_MINUTES}. */ private startSessionTimeoutChecker(): void { @@ -217,8 +259,18 @@ export class PenpotMcpServer { removed++; } } + for (const [id, session] of Object.entries(this.sseTransports)) { + if (now - session.lastActiveTime > timeoutMs) { + this.logger.info(`Closing stale SSE session ${id}`); + session.transport.close(); + delete this.sseTransports[id]; + removed++; + } + } this.logger.info( - `Removed ${removed} stale session(s); total sessions remaining: ${Object.keys(this.streamableTransports).length}` + `Removed ${removed} stale session(s); total sessions remaining: ${ + Object.keys(this.streamableTransports).length + Object.keys(this.sseTransports).length + }` ); }, checkIntervalMs); } @@ -247,15 +299,21 @@ export class PenpotMcpServer { `Received request for existing session with id=${sessionId}; userTokenFp=${PenpotMcpServer.tokenFingerprint(session.userToken)}` ); } else { - // new session: create a fresh McpServer and transport + // No locally-known session for this request. Either a brand-new session + // (no session ID) or a session that was initialized on another instance + // and routed here by the load balancer (session ID present but unknown + // locally), which we adopt rather than reject. + const isAdoptedSession = sessionId !== undefined; userToken = req.query.userToken as string | undefined; this.logger.info( - `Received new session request; userTokenFp=${PenpotMcpServer.tokenFingerprint(userToken)}` + `${isAdoptedSession ? `Adopting session initialized on another instance with id=${sessionId}` : "Received new session request"}; userTokenFp=${PenpotMcpServer.tokenFingerprint(userToken)}` ); + const { randomUUID } = await import("node:crypto"); const server = this.createMcpServer(); transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), + // For an adopted session, reuse the existing ID; otherwise generate a new one. + sessionIdGenerator: () => (isAdoptedSession ? sessionId! : randomUUID()), onsessioninitialized: (id) => { this.streamableTransports[id] = new StreamableSession(transport, userToken, Date.now()); this.logger.info( @@ -263,6 +321,22 @@ export class PenpotMcpServer { ); }, }); + + if (isAdoptedSession) { + // Pre-initialize the transport so that the SDK's validateSession() accepts + // subsequent (non-initialize) requests for this session ID. The SDK stores + // these on the inner WebStandardStreamableHTTPServerTransport as plain + // (non-#private) properties; validateSession() checks exactly _initialized + // and sessionId. Verified against @modelcontextprotocol/sdk 1.25.3. + // + // Since no initialize request will arrive for an adopted session, the + // onsessioninitialized callback will not fire; register the session here. + const inner = (transport as any)._webStandardTransport; + inner._initialized = true; + inner.sessionId = sessionId; + this.streamableTransports[sessionId!] = new StreamableSession(transport, userToken, Date.now()); + } + transport.onclose = () => { if (transport.sessionId) { this.logger.info( @@ -288,7 +362,7 @@ export class PenpotMcpServer { await this.sessionContext.run({ userToken }, async () => { const transport = new SSEServerTransport("/messages", res); - this.sseTransports[transport.sessionId] = { transport, userToken }; + this.sseTransports[transport.sessionId] = { transport, userToken, lastActiveTime: Date.now() }; const server = this.createMcpServer(); await server.connect(transport); @@ -307,6 +381,7 @@ export class PenpotMcpServer { const session = this.sseTransports[sessionId]; if (session) { + session.lastActiveTime = Date.now(); await this.sessionContext.run({ userToken: session.userToken }, async () => { await session.transport.handlePostMessage(req, res, req.body); }); @@ -326,7 +401,11 @@ export class PenpotMcpServer { return new Promise((resolve) => { this.app.listen(this.port, this.host, async () => { this.logger.info(`Multi-user mode: ${this.isMultiUserMode()}`); + this.logger.info( + `Multi-instance mode with Redis-backed transport: ${this.redisBridge ? "true" : "false"}` + ); this.logger.info(`Remote mode: ${this.isRemoteMode()}`); + this.logger.info(`DevEnv mode: ${this.isDevEnv()}`); this.logger.info(`Modern Streamable HTTP endpoint: http://${this.host}:${this.port}/mcp`); this.logger.info(`Legacy SSE endpoint: http://${this.host}:${this.port}/sse`); this.logger.info(`WebSocket server URL: ws://${this.host}:${this.webSocketPort}`); @@ -348,6 +427,7 @@ export class PenpotMcpServer { public async stop(): Promise { this.logger.info("Stopping Penpot MCP Server..."); clearInterval(this.sessionTimeoutInterval); + await this.redisBridge?.close(); await this.replServer.stop(); this.logger.info("Penpot MCP Server stopped"); } diff --git a/mcp/packages/server/src/PluginBridge.ts b/mcp/packages/server/src/PluginBridge.ts index 35d39aa728..1c24547b8f 100644 --- a/mcp/packages/server/src/PluginBridge.ts +++ b/mcp/packages/server/src/PluginBridge.ts @@ -1,9 +1,11 @@ import { WebSocket, WebSocketServer } from "ws"; import * as http from "http"; -import { PluginTask } from "./PluginTask"; -import { PluginTaskResponse, PluginTaskResult } from "@penpot/mcp-common"; +import { AbstractPluginTask, PluginTask } from "./PluginTask"; +import { RemotePluginTask } from "./RemotePluginTask"; +import { PluginTaskRequest, PluginTaskResponse, PluginTaskResult } from "@penpot/mcp-common"; import { createLogger } from "./logger"; import type { PenpotMcpServer } from "./PenpotMcpServer"; +import type { RedisBridge } from "./RedisBridge"; const KEEP_ALIVE_TIME = 30000; // 30 seconds @@ -22,12 +24,24 @@ export class PluginBridge { private readonly wsServer: WebSocketServer; private readonly connectedClients: Map = new Map(); private readonly clientsByToken: Map = new Map(); - private readonly pendingTasks: Map> = new Map(); + private readonly pendingTasks: Map> = new Map(); private readonly taskTimeouts: Map = new Map(); + /** + * Creates the plugin bridge and starts its WebSocket server. + * + * @param mcpServer - The owning MCP server + * @param port - The port on which to listen for plugin WebSocket connections + * @param redisBridge - Optional Redis bridge enabling multi-instance task routing. + * When provided, tasks handled by this instance are routed to the instance + * holding the relevant plugin's WebSocket connection (which may be this same + * instance) via Redis, rather than dispatched directly over a local socket. + * @param taskTimeoutSecs - Timeout, in seconds, for plugin task execution + */ constructor( public readonly mcpServer: PenpotMcpServer, private port: number, + private readonly redisBridge?: RedisBridge, private taskTimeoutSecs: number = 30 ) { this.wsServer = new WebSocketServer({ port: port }); @@ -77,6 +91,17 @@ export class PluginBridge { } this.clientsByToken.set(userToken, connection); + + // In multi-instance mode, subscribe to this token's Redis request channel so + // that task requests issued by other instances are dispatched to this plugin. + if (this.redisBridge) { + const tokenForSubscription = userToken; + this.redisBridge + .subscribeToTasks(userToken, (request) => + this.dispatchForwardedTask(tokenForSubscription, request) + ) + .catch((error) => this.logger.error(error, "Failed to subscribe to Redis task channel")); + } } ws.on("message", (data: Buffer) => { @@ -121,6 +146,12 @@ export class PluginBridge { this.connectedClients.delete(ws); if (connection.userToken) { this.clientsByToken.delete(connection.userToken); + + if (this.redisBridge) { + this.redisBridge + .unsubscribeFromTasks(connection.userToken) + .catch((error) => this.logger.error(error, "Failed to unsubscribe from Redis task channel")); + } } } @@ -203,10 +234,9 @@ export class PluginBridge { } /** - * 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. + * Executes a plugin task by sending it to the connected Penpot plugin instance, + * either directly via WebSocket or indirectly via Redis (depending on the configuration), + * and awaiting the result. * * @param task - The plugin task to execute * @throws Error if no plugin instances are connected or available @@ -214,28 +244,67 @@ export class PluginBridge { public async executePluginTask>( task: PluginTask ): Promise { - // get the appropriate client connection based on mode - const connection = this.getClientConnection(); + this.sendPluginTask(task, this.redisBridge !== undefined); + return await task.getResultPromise(); + } - // register the task for result correlation - this.pendingTasks.set(task.id, task); + /** + * Registers a task for response correlation, sends its request over the appropriate + * transport, and arms a timeout that rejects the task if no response is received. + * + * The response (whether arriving over the local WebSocket or over Redis) is later + * matched by ID in {@link handlePluginTaskResponse}, which settles the task via its + * `resolveWithResult`/`rejectWithError` methods. The same correlation and timeout + * handling therefore applies regardless of the transport. + * + * @param task - The task to dispatch + * @param useRedis - Whether to route the request via Redis (multi-instance) rather + * than directly over the local WebSocket connection + * @param connection - The connection to use for a local (non-remote) dispatch; when + * omitted, the session's connection is resolved via {@link getClientConnection}. + * Ignored when `useRedis` is true. + * @throws Error if a local dispatch is required but no suitable connection is available + */ + private sendPluginTask(task: AbstractPluginTask, useRedis: boolean, connection?: ClientConnection): void { + let onTimeout: (() => void) | undefined; - // 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.`); + if (useRedis) { + const sessionContext = this.mcpServer.getSessionContext(); + if (!sessionContext?.userToken) { + throw new Error("No userToken found in session context. Multi-user mode requires authentication."); + } + const userToken = sessionContext.userToken; + const redisBridge = this.redisBridge!; + this.logger.debug("Dispatching task %s via Redis", task.id); + + // register the task for result correlation, then publish the request via Redis + this.pendingTasks.set(task.id, task); + void redisBridge.sendTaskRequest(userToken, task.toRequest(), (response) => + this.handlePluginTaskResponse(response) + ); + + // on timeout, release the response-channel subscription, since no response + // will arrive to trigger its self-unsubscribe. + onTimeout = () => void redisBridge.unsubscribeFromResponse(task.id); + } else { + const target = connection ?? this.getClientConnection(); + if (target.socket.readyState !== 1) { + // WebSocket is not open + throw new Error(`Plugin instance is disconnected. Task could not be sent.`); + } + + // register the task for result correlation, then send over the socket + this.pendingTasks.set(task.id, task); + target.socket.send(JSON.stringify(task.toRequest())); } - 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); + onTimeout?.(); pendingTask.rejectWithError( new Error(`Task ${task.id} timed out after ${this.taskTimeoutSecs} seconds`) ); @@ -243,8 +312,43 @@ export class PluginBridge { }, this.taskTimeoutSecs * 1000); this.taskTimeouts.set(task.id, timeoutHandle); - this.logger.info(`Sent task ${task.id} to connected client`); + this.logger.info(`Sent task ${task.id}`); + } - return await task.getResultPromise(); + /** + * Dispatches a task request received over Redis to the locally-connected plugin. + * + * Invoked on the instance subscribed to a user token's request channel when another + * instance (or this one) issues a task request. A {@link RemotePluginTask} is created + * so that, once the plugin responds, the outcome is published back to the issuing + * instance's Redis response channel via the standard response-handling path. + * + * On failure to dispatch (e.g. the plugin is not connected here), an error response + * is published immediately so the requester need not wait for its timeout. + * + * @param userToken - The user token on whose request channel the request arrived; + * identifies the locally-connected plugin to dispatch to + * @param request - The serialized task request, passed through from Redis + */ + private dispatchForwardedTask(userToken: string, request: PluginTaskRequest): void { + if (!this.redisBridge) { + return; + } + + // The response is published on the channel keyed by the original request ID. + const task = new RemotePluginTask(request.task, request.params, this.redisBridge, request.id); + this.logger.debug("Dispatching remote task %s as %s to Penpot via WebSocket", request.id, task.id); + + const connection = this.clientsByToken.get(userToken); + if (!connection) { + task.rejectWithError(new Error("Plugin not connected on the receiving instance")); + return; + } + + try { + this.sendPluginTask(task, false, connection); + } catch (error) { + task.rejectWithError(error instanceof Error ? error : new Error(String(error))); + } } } diff --git a/mcp/packages/server/src/PluginTask.ts b/mcp/packages/server/src/PluginTask.ts index 8600cac22b..0e0d327564 100644 --- a/mcp/packages/server/src/PluginTask.ts +++ b/mcp/packages/server/src/PluginTask.ts @@ -1,24 +1,28 @@ /** - * Base class for plugin tasks that are sent over WebSocket. + * Base classes for plugin tasks that are dispatched to a Penpot plugin instance + * over a WebSocket connection. * - * 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 + * A task defines a specific operation for the plugin to execute along with + * strongly-typed parameters and provides request/response correlation. */ import { PluginTaskRequest, PluginTaskResult } from "@penpot/mcp-common"; import { randomUUID } from "crypto"; /** - * Base class for plugin tasks that are sent over WebSocket. + * Abstract base for plugin tasks, defining the parts that the plugin dispatch and + * response-correlation machinery (`PluginBridge.sendPluginTask` / + * `PluginBridge.handlePluginTaskResponse`) depend upon. * - * Each task defines a specific operation for the plugin to execute - * along with strongly-typed parameters and request/response correlation. + * The dispatch path only needs to serialize a task to a request and, upon receiving + * the plugin's response, settle the task via `resolveWithResult`/`rejectWithError`. + * What "settling" actually means is left to subclasses: a local task resolves an + * in-process promise (see {@link PluginTask}), whereas a remote task forwards the + * outcome elsewhere (see {@link RemotePluginTask}). * * @template TParams - The strongly-typed parameters for this task * @template TResult - The expected result type from task execution */ -export abstract class PluginTask = PluginTaskResult> { +export abstract class AbstractPluginTask = PluginTaskResult> { /** * Unique identifier for request/response correlation. */ @@ -34,6 +38,66 @@ export abstract class PluginTask = PluginTaskResult, +> extends AbstractPluginTask { /** * Promise that resolves when the task execution completes. */ @@ -50,15 +114,13 @@ export abstract class PluginTask void; /** - * Creates a new plugin task instance. + * Creates a new locally-awaited plugin task. * * @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; + super(task, params); this.result = new Promise((resolve, reject) => { this.resolveResult = resolve; this.rejectResult = reject; @@ -77,14 +139,6 @@ export abstract class PluginTask void; + +/** + * Handler invoked for a task response arriving on a subscribed response channel. + */ +export type TaskResponseHandler = (response: PluginTaskResponse) => void; + +/** + * Provides a Redis-backed transport for routing plugin task requests and responses + * between MCP server instances. + * + * The bridge is a pure, stateless transport: it moves already-serialized + * `PluginTaskRequest` and `PluginTaskResponse` objects between instances and does not + * interpret their contents, correlate requests with responses, or impose timeouts. + * Correlation and timeout handling remain the responsibility of the caller (see + * `PluginBridge`, which routes Redis-delivered responses through the same + * pending-task machinery used for direct WebSocket dispatch). + * + * It enables a tool call handled on one instance to be executed against a plugin + * whose WebSocket connection lives on another instance: the request is published on a + * channel keyed by user token (to which the instance holding the plugin connection is + * subscribed), and the response is published on a channel keyed by task ID (to which + * the issuing instance subscribes). + * + * Two Redis connections are used, as ioredis requires a dedicated connection while + * subscribed: one for commands and publishing, and one for subscriptions. + */ +export class RedisBridge { + private readonly logger = createLogger("RedisBridge"); + private readonly publisher: Redis; + private readonly subscriber: Redis; + + /** + * Message handlers keyed by channel name. + * + * ioredis exposes a single, global message event for all subscribed channels, so + * incoming messages are dispatched to the correct handler by channel name. Both + * request-channel and response-channel handlers are stored here. + */ + private readonly handlers = new Map void>(); + + /** + * Creates a Redis bridge connected to the given Redis instance. + * + * @param redisUri - The Redis connection URI (e.g. `redis://host:6379`) + */ + constructor(redisUri: string) { + this.publisher = new Redis(redisUri); + this.subscriber = new Redis(redisUri); + + this.subscriber.on("message", (channel: string, rawMessage: string) => { + const handler = this.handlers.get(channel); + if (handler) { + handler(rawMessage); + } else { + this.logger.warn(`Received message on channel with no registered handler: ${channel}`); + } + }); + } + + /** + * Subscribes to the response channel for the given task ID and publishes the task + * request to the given user token's request channel. + * + * The response subscription is established *before* the request is published, to + * avoid a race in which the response would be published before the subscription is + * in place. The response handler is invoked at most once and the subscription is + * removed automatically upon delivery (response channels are single-use). + * + * @param userToken - The user token identifying the target plugin's request channel + * @param request - The serialized plugin task request, passed through verbatim + * @param onResponse - Handler invoked with the response when it arrives + */ + async sendTaskRequest( + userToken: string, + request: PluginTaskRequest, + onResponse: TaskResponseHandler + ): Promise { + const responseChannel = `${TASK_RESPONSE_CHANNEL_PREFIX}${request.id}`; + const requestChannel = `${TASK_REQUEST_CHANNEL_PREFIX}${userToken}`; + + this.handlers.set(responseChannel, (rawMessage) => { + // a response channel is single-use: remove the handler and unsubscribe on delivery + this.handlers.delete(responseChannel); + void this.subscriber.unsubscribe(responseChannel); + try { + onResponse(JSON.parse(rawMessage) as PluginTaskResponse); + } catch (error) { + this.logger.error(error, "Failed to parse task response message"); + } + }); + + await this.subscriber.subscribe(responseChannel); + // publish only once the response subscription is confirmed + await this.publisher.publish(requestChannel, JSON.stringify(request)); + } + + /** + * Unsubscribes from the response channel for the given task ID. + * + * Used to release a response subscription when no response will be processed (e.g. + * the awaiting task has timed out), since in that case the self-unsubscribe on + * delivery never occurs. + * + * @param taskId - The task ID whose response channel to unsubscribe from + */ + async unsubscribeFromResponse(taskId: string): Promise { + const responseChannel = `${TASK_RESPONSE_CHANNEL_PREFIX}${taskId}`; + this.handlers.delete(responseChannel); + await this.subscriber.unsubscribe(responseChannel); + } + + /** + * Publishes a task response on the response channel for the given task ID. + * + * Used by the instance executing a forwarded task to return its outcome to the + * issuing instance. + * + * @param taskId - The ID of the originally requested task + * @param response - The serialized plugin task response, passed through verbatim + */ + publishTaskResponse(taskId: string, response: PluginTaskResponse): void { + const responseChannel = `${TASK_RESPONSE_CHANNEL_PREFIX}${taskId}`; + void this.publisher.publish(responseChannel, JSON.stringify(response)); + } + + /** + * Subscribes to task requests for the given user token. + * + * The handler is invoked for each request arriving on the token's request channel. + * + * @param userToken - The user token whose request channel to subscribe to + * @param handler - The handler to invoke for incoming requests + */ + async subscribeToTasks(userToken: string, handler: TaskRequestHandler): Promise { + const requestChannel = `${TASK_REQUEST_CHANNEL_PREFIX}${userToken}`; + this.handlers.set(requestChannel, (rawMessage) => { + try { + handler(JSON.parse(rawMessage) as PluginTaskRequest); + } catch (error) { + this.logger.error(error, "Failed to parse task request message"); + } + }); + await this.subscriber.subscribe(requestChannel); + } + + /** + * Unsubscribes from task requests for the given user token. + * + * @param userToken - The user token whose request channel to unsubscribe from + */ + async unsubscribeFromTasks(userToken: string): Promise { + const requestChannel = `${TASK_REQUEST_CHANNEL_PREFIX}${userToken}`; + this.handlers.delete(requestChannel); + await this.subscriber.unsubscribe(requestChannel); + } + + /** + * Closes both Redis connections. Call on server shutdown. + */ + async close(): Promise { + await this.subscriber.quit(); + await this.publisher.quit(); + } +} diff --git a/mcp/packages/server/src/RemotePluginTask.ts b/mcp/packages/server/src/RemotePluginTask.ts new file mode 100644 index 0000000000..368623a5a0 --- /dev/null +++ b/mcp/packages/server/src/RemotePluginTask.ts @@ -0,0 +1,56 @@ +import { AbstractPluginTask } from "./PluginTask"; +import { PluginTaskResult } from "@penpot/mcp-common"; +import type { RedisBridge } from "./RedisBridge"; + +/** + * A plugin task whose outcome is forwarded back to a remote requester via Redis, + * rather than awaited in-process. + * + * This task type is used on the server instance that holds the plugin's WebSocket + * connection when a task request arrives over Redis (published by another instance + * that received the corresponding tool call). It is dispatched to the plugin through + * the ordinary local dispatch path; when the plugin responds, the response-correlation + * machinery settles this task, and the overridden `resolveWithResult`/`rejectWithError` + * publish the outcome back onto the requester's Redis response channel. + * + * Note that this task has its own ID (used to correlate the local WebSocket dispatch), + * distinct from the original requester's task ID, which keys the Redis response channel. + * + * It deliberately carries no result promise: settling the task *is* the side effect + * of publishing to Redis, and nothing awaits it locally. + */ +export class RemotePluginTask extends AbstractPluginTask> { + /** + * Creates a task that forwards its outcome to a Redis response channel. + * + * @param task - The name of the task to execute (from the incoming request) + * @param params - The parameters for task execution (from the incoming request) + * @param redisBridge - The Redis bridge used to publish the outcome + * @param originalTaskId - The ID of the original request, which keys the response + * channel the requesting instance is awaiting + */ + constructor( + task: string, + params: any, + private readonly redisBridge: RedisBridge, + private readonly originalTaskId: string + ) { + super(task, params); + } + + resolveWithResult(result: PluginTaskResult): void { + this.redisBridge.publishTaskResponse(this.originalTaskId, { + id: this.originalTaskId, + success: true, + data: result.data, + }); + } + + rejectWithError(error: Error): void { + this.redisBridge.publishTaskResponse(this.originalTaskId, { + id: this.originalTaskId, + success: false, + error: error.message, + }); + } +} diff --git a/mcp/packages/server/src/tools/CljCheckParentheses.ts b/mcp/packages/server/src/tools/CljCheckParentheses.ts new file mode 100644 index 0000000000..81e2a9628c --- /dev/null +++ b/mcp/packages/server/src/tools/CljCheckParentheses.ts @@ -0,0 +1,242 @@ +import { z } from "zod"; +import { Tool } from "../Tool"; +import "reflect-metadata"; +import type { ToolResponse } from "../ToolResponse"; +import { TextResponse } from "../ToolResponse"; +import type { PenpotMcpServer } from "../PenpotMcpServer"; +import * as fs from "fs"; + +/** + * Arguments for the FindUnclosedParensTool. + */ +export class CljCheckParenthesesArgs { + static schema = { + file: z.string().min(1).describe("Absolute path to a Clojure/ClojureScript source file"), + }; + + file!: string; +} + +interface OpenDelim { + id: number; + line: number; // 0-based + col: number; // 0-based + char: string; + baselineKey: string; // the baseline key this delimiter owns +} + +interface ParenIssue { + line: number; // 1-based + col: number; // 1-based + char: string; + detectedAtLine?: number; // 1-based line where the stack-state mismatch was observed +} + +/** + * Finds unclosed delimiters in Clojure/ClojureScript source files using a + * stack-state invariant derived from cljfmt formatting conventions. + * + * Invariant: in cljfmt-formatted code, every opening delimiter of type T at + * column C must see the same stack depth each time that (T, C) combination + * occurs. A depth mismatch means delimiters opened between the baseline + * occurrence and the current one were never closed. + * + * The parser correctly handles string literals (including multi-line and escape + * sequences), comment lines, character literals, and regex literals. + */ +export class CljCheckParentheses extends Tool { + constructor(mcpServer: PenpotMcpServer) { + super(mcpServer, CljCheckParenthesesArgs.schema); + } + + public getToolName(): string { + return "clj_check_parentheses"; + } + + public getToolDescription(): string { + return "Analyzes a Clojure/ClojureScript source file for unclosed delimiters and reports the area of interest."; + } + + protected async executeCore(args: CljCheckParenthesesArgs): Promise { + const filePath = args.file; + + if (!fs.existsSync(filePath)) { + return new TextResponse(`File not found: ${filePath}`); + } + + const content = fs.readFileSync(filePath, "utf-8"); + const issues = analyzeParens(content); + + if (issues.length === 0) { + return new TextResponse("All delimiters are properly balanced."); + } + + const sourceLines = content.split("\n"); + const parts: string[] = [`Found ${issues.length} unclosed delimiter(s):\n`]; + + for (const issue of issues) { + const srcLine = (sourceLines[issue.line - 1] ?? "").trimEnd(); + const pointer = " ".repeat(String(issue.line).length) + " " + " ".repeat(issue.col - 1) + "^"; + + if (issue.detectedAtLine != null) { + const detectedSrcLine = (sourceLines[issue.detectedAtLine - 1] ?? "").trimEnd(); + parts.push( + ` Unclosed '${issue.char}' at line ${issue.line}, col ${issue.col}:\n` + + ` ${issue.line} | ${srcLine}\n` + + ` ${pointer}\n` + + ` Stack-state mismatch detected at line ${issue.detectedAtLine}:\n` + + ` ${issue.detectedAtLine} | ${detectedSrcLine}\n` + ); + } else { + parts.push( + ` Unclosed '${issue.char}' at line ${issue.line}, col ${issue.col} (still open at end of file):\n` + + ` ${issue.line} | ${srcLine}\n` + + ` ${pointer}\n` + ); + } + } + + return new TextResponse(parts.join("\n")); + } +} + +/** + * Analyses delimiter balance in a Clojure/ClojureScript source string. + * + * Algorithm + * --------- + * Maintain a stack of open delimiters and a map from (delimiter-type, column) + * to the stack depth recorded on the first occurrence of that combination. + * + * Each time an opening delimiter of type T appears at column C: + * 1. Look up the key (T, C) in the map. + * 2. If absent, record the current stack depth as the baseline. + * 3. If present, compare the current depth with the baseline. + * - If deeper: the extra stack entries (from baseline depth to current + * depth) are delimiters that should have been closed. Report them. + * - If shallower: more delimiters were closed than opened between the + * baseline and here (over-closed). Update the baseline downward so + * subsequent occurrences don't cascade. + * 4. Push the delimiter onto the stack. + * + * After the full file is processed, any delimiter still on the stack is + * unclosed. If it was already reported via a mismatch, the report includes + * the detection line; otherwise it is reported as open-at-EOF. + */ +function analyzeParens(content: string): ParenIssue[] { + // Precompute line-start offsets for O(1) column lookup. + const lineStarts: number[] = [0]; + for (let i = 0; i < content.length; i++) { + if (content[i] === "\n") lineStarts.push(i + 1); + } + + let nextId = 0; + const stack: OpenDelim[] = []; + + // (type, column) → baseline stack depth. + // Each baseline is owned by the delimiter that established it (stored + // as baselineKey on the stack entry). When that delimiter is popped, + // its baseline is discarded — it was scoped to that delimiter's lifetime. + const baseline: Map = new Map(); + + let inString = false; + let inComment = false; + let escape = false; + let currentLine = 0; + + for (let i = 0; i < content.length; i++) { + const ch = content[i]; + + // ── Newline ────────────────────────────────────────────────────── + if (ch === "\n") { + inComment = false; + currentLine++; + if (!inString) escape = false; + continue; + } + + // ── Escape: skip next character ────────────────────────────────── + if (escape) { + escape = false; + continue; + } + + // ── Inside comment: skip until newline ─────────────────────────── + if (inComment) continue; + + // ── Inside string literal ──────────────────────────────────────── + if (inString) { + if (ch === "\\") escape = true; + else if (ch === '"') inString = false; + continue; + } + + // ── Outside string / comment ───────────────────────────────────── + if (ch === "\\") { + escape = true; + continue; + } + if (ch === '"') { + inString = true; + continue; + } + if (ch === ";") { + inComment = true; + continue; + } + + // ── Opening delimiter ──────────────────────────────────────────── + if (ch === "(" || ch === "[" || ch === "{") { + const col = i - lineStarts[currentLine]; + const key = `${ch}:${col}`; + const currentDepth = stack.length; + + const recorded = baseline.get(key); + if (recorded !== undefined && currentDepth > recorded) { + // Stack is deeper than expected. The entries from index + // `recorded` to `currentDepth - 1` are unclosed delimiters + // that should have been closed before reaching this + // position. Return immediately — further parsing would be + // against a corrupted stack and only produce cascading noise. + return stack.slice(recorded, currentDepth).map((delim) => ({ + line: delim.line + 1, + col: delim.col + 1, + char: delim.char, + detectedAtLine: currentLine + 1, + })); + } + + // Establish or re-establish the baseline for this key, + // owned by this delimiter. Discarded when it is popped. + baseline.set(key, currentDepth); + + stack.push({ + id: nextId++, + line: currentLine, + col, + char: ch, + baselineKey: key, + }); + } + + // ── Closing delimiter ──────────────────────────────────────────── + else if (ch === ")" || ch === "]" || ch === "}") { + if (stack.length > 0) { + const closed = stack.pop()!; + + // The baseline this delimiter owned is no longer valid — + // the context it was recorded in has closed. + baseline.delete(closed.baselineKey); + } + } + } + + // ── EOF: no mismatch was found, but the stack is not empty ────────── + // This happens when the unclosed delimiter has no second occurrence of + // the same (type, column) to compare against (e.g. last form in file). + return stack.map((delim) => ({ + line: delim.line + 1, + col: delim.col + 1, + char: delim.char, + })); +} diff --git a/mcp/packages/server/src/tools/CljsCompilerOutputTool.ts b/mcp/packages/server/src/tools/CljsCompilerOutputTool.ts new file mode 100644 index 0000000000..11989c6303 --- /dev/null +++ b/mcp/packages/server/src/tools/CljsCompilerOutputTool.ts @@ -0,0 +1,52 @@ +import { Tool, EmptyToolArgs } from "../Tool"; +import "reflect-metadata"; +import type { ToolResponse } from "../ToolResponse"; +import { TextResponse } from "../ToolResponse"; +import { PenpotMcpServer } from "../PenpotMcpServer"; +import { NreplClient } from "../NreplClient"; + +/** + * Reports the compiler status of the shadow-cljs `:main` build. + * + * If the most recent build failed, returns the relevant fields of the failure data + * (tag, message, resource name, line, column, etc.); otherwise returns `:ok`. + */ +export class CljsCompilerOutputTool extends Tool { + private static readonly STATUS_CODE = + "(require (quote [shadow.cljs.devtools.api :as shadow])) " + + "(let [fd (-> (shadow/get-worker :main) :state-ref deref :failure-data)] " + + "(if fd (pr-str fd) :ok))"; + + private readonly nreplClient: NreplClient; + + constructor(mcpServer: PenpotMcpServer, nreplClient: NreplClient) { + super(mcpServer, EmptyToolArgs.schema); + this.nreplClient = nreplClient; + } + + public getToolName(): string { + return "cljs_compiler_output"; + } + + public getToolDescription(): string { + return ( + "Reports the status of the most recent shadow-cljs `:main` build. " + + "Use this to diagnose compilation errors when needed. For syntax errors, " + + "consider using the clj_check_parentheses tool on the relevant source files." + ); + } + + protected async executeCore(_args: EmptyToolArgs): Promise { + const result = await this.nreplClient.eval(CljsCompilerOutputTool.STATUS_CODE); + + // multiple top-level forms produce multiple values; the build status is the last one + const status = result.values[result.values.length - 1] ?? "nil"; + + const parts: string[] = [status]; + if (result.err) { + parts.push(`stderr:\n${result.err}`); + } + + return new TextResponse(parts.join("\n\n")); + } +} diff --git a/mcp/packages/server/src/tools/CljsReplTool.ts b/mcp/packages/server/src/tools/CljsReplTool.ts new file mode 100644 index 0000000000..bd894caef3 --- /dev/null +++ b/mcp/packages/server/src/tools/CljsReplTool.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; +import { Tool } from "../Tool"; +import "reflect-metadata"; +import type { ToolResponse } from "../ToolResponse"; +import { TextResponse } from "../ToolResponse"; +import { PenpotMcpServer } from "../PenpotMcpServer"; +import { NreplClient } from "../NreplClient"; + +/** + * Arguments for the CljsReplTool. + */ +export class CljsReplArgs { + static schema = { + code: z.string().min(1, "Code cannot be empty"), + }; + + /** + * The ClojureScript code to evaluate in the frontend runtime. + */ + code!: string; +} + +/** + * A ClojureScript REPL for the Penpot frontend runtime. + * + * This tool provides a persistent REPL session connected to the shadow-cljs nREPL server. + * Definitions, requires, and other state are preserved across calls, enabling iterative + * exploration and manipulation of the running Penpot application. + */ +export class CljsReplTool extends Tool { + private readonly nreplClient: NreplClient; + + /** + * Creates a new CljsReplTool instance. + * + * @param mcpServer - the MCP server instance + * @param nreplClient - the nREPL client for communicating with shadow-cljs + */ + constructor(mcpServer: PenpotMcpServer, nreplClient: NreplClient) { + super(mcpServer, CljsReplArgs.schema); + this.nreplClient = nreplClient; + } + + public getToolName(): string { + return "cljs_repl"; + } + + public getToolDescription(): string { + return ( + "Persistent ClojureScript REPL in the Penpot frontend runtime (via shadow-cljs nREPL). " + + "Definitions, requires, and state are preserved across calls — use it to build up helpers incrementally. " + + "Multiple top-level expressions per call are supported; each produces a result line." + ); + } + + protected async executeCore(args: CljsReplArgs): Promise { + const result = await this.nreplClient.evalCljs(args.code); + + const parts: string[] = []; + if (result.values.length > 0) { + parts.push(result.values.join("\n")); + } + if (result.out) { + parts.push(`stdout:\n${result.out}`); + } + if (result.err) { + parts.push(`stderr:\n${result.err}`); + } + if (parts.length === 0) { + parts.push("nil"); + } + + return new TextResponse(parts.join("\n\n")); + } +} diff --git a/mcp/packages/server/src/tools/ExportShapeTool.ts b/mcp/packages/server/src/tools/ExportShapeTool.ts index 7b8ceed4a6..0d84df9380 100644 --- a/mcp/packages/server/src/tools/ExportShapeTool.ts +++ b/mcp/packages/server/src/tools/ExportShapeTool.ts @@ -1,10 +1,12 @@ import { z } from "zod"; import { Tool } from "../Tool"; -import { ImageContent, PNGImageContent, PNGResponse, TextContent, TextResponse, ToolResponse } from "../ToolResponse"; +import { ImageContent, PNGResponse, TextContent, TextResponse, ToolResponse } from "../ToolResponse"; import "reflect-metadata"; import { PenpotMcpServer } from "../PenpotMcpServer"; import { ExecuteCodePluginTask } from "../tasks/ExecuteCodePluginTask"; +import { createLogger } from "../logger"; import { FileUtils } from "../utils/FileUtils"; +import { Semaphore } from "../utils/Semaphore"; import sharp from "sharp"; /** @@ -49,6 +51,38 @@ export class ExportShapeArgs { * Tool for executing JavaScript code in the Penpot plugin context */ export class ExportShapeTool extends Tool { + /** + * Maximum number of image-export operations that may run concurrently in multi-user mode. + * Configurable via the PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS environment variable; + * defaults to 0, meaning no limit. + * + * When set to a positive value (and combined with the plugin-side per-response cap + * MAX_TASK_RESPONSE_SIZE_REMOTE_MCP, ~15 MB JSON), this caps the in-flight memory + * footprint of image exports at roughly N x cap on the centrally hosted MCP server. + */ + private static readonly MAX_PARALLEL_EXPORTS = parseInt( + process.env.PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS ?? "0", + 10 + ); + + /** + * Gates concurrent export operations across all tool instances (one per session in + * multi-user mode). Static because instances are per-session, but the bound has to + * apply across the whole process. Permits beyond the maximum queue in FIFO order. + * Undefined when MAX_PARALLEL_EXPORTS is non-positive, indicating no limit. + */ + private static readonly parallelismSemaphore: Semaphore | undefined = + ExportShapeTool.MAX_PARALLEL_EXPORTS > 0 + ? new Semaphore("ExportShapeTool", ExportShapeTool.MAX_PARALLEL_EXPORTS) + : undefined; + + static { + createLogger("ExportShapeTool").info( + "Max parallel exports (multi-user mode): %d (0 = unbounded)", + ExportShapeTool.MAX_PARALLEL_EXPORTS + ); + } + /** * Creates a new ExecuteCode tool instance. * @@ -79,6 +113,25 @@ export class ExportShapeTool extends Tool { } protected async executeCore(args: ExportShapeArgs): Promise { + // bound concurrent exports in multi-user mode to keep peak server memory under control; + // in single-user mode (or when no limit is configured) the gate is irrelevant + // and the export runs directly + if (this.mcpServer.isMultiUserMode() && ExportShapeTool.parallelismSemaphore) { + return ExportShapeTool.parallelismSemaphore.withPermit(() => this.exportImage(args)); + } else { + return this.exportImage(args); + } + } + + /** + * Performs the actual image export: requests the image via the plugin and either + * returns it as a tool response or saves it to the requested file path. The bulk + * of the memory pressure (parsed plugin response, decoded image buffer, optional + * re-encoding via sharp) lives here, which is why executeCore gates the call. + * + * @param args - the validated tool arguments + */ + private async exportImage(args: ExportShapeArgs): Promise { // check arguments if (args.filePath) { FileUtils.checkPathIsAbsolute(args.filePath); diff --git a/mcp/packages/server/src/tools/ImportPenpotFileTool.ts b/mcp/packages/server/src/tools/ImportPenpotFileTool.ts new file mode 100644 index 0000000000..f55ae194f2 --- /dev/null +++ b/mcp/packages/server/src/tools/ImportPenpotFileTool.ts @@ -0,0 +1,370 @@ +import { z } from "zod"; +import { Tool } from "../Tool"; +import { TextResponse, ToolResponse } from "../ToolResponse"; +import "reflect-metadata"; +import { PenpotMcpServer } from "../PenpotMcpServer"; +import { NreplClient } from "../NreplClient"; +import { createLogger } from "../logger"; +import * as crypto from "crypto"; +import * as fs from "fs"; +import * as path from "path"; +import * as https from "https"; +import * as http from "http"; + +/** + * Arguments for ImportPenpotFileTool. + */ +export class ImportPenpotFileArgs { + static schema = { + url: z.url().describe("URL of the .penpot file to import."), + }; + + /** URL of the .penpot file to import */ + url!: string; +} + +/** + * Tool for importing a .penpot file into the running Penpot instance. + * + * Downloads the file from the given URL to a temporary location in the frontend's + * static directory, then triggers the import via the Penpot frontend's web worker + * using the ClojureScript REPL. The temporary file is cleaned up after the import + * completes (or fails). + * + * Only available in devenv mode, as it requires the ClojureScript nREPL connection. + */ +export class ImportPenpotFileTool extends Tool { + private static readonly POLL_INTERVAL_MS = 1_000; + private static readonly IMPORT_TIMEOUT_MS = 120_000; + + // assumes cwd is the server package root (same assumption as ConfigurationLoader) + private static readonly PUBLIC_DIR = path.resolve("../../../frontend/resources/public"); + + private static readonly NAVIGATION_HINT = + "To open an imported file in the workspace, use cljs_repl with:\n" + + "(do (require '[app.main.data.common :as dcm])\n" + + " (app.main.store/emit! (dcm/go-to-workspace\n" + + ' :team-id (parse-uuid "")\n' + + ' :file-id (parse-uuid "")\n' + + ' :page-id (parse-uuid ""))))'; + + private readonly log = createLogger("ImportPenpotFileTool"); + private readonly nreplClient: NreplClient; + + /** + * Creates a new ImportPenpotFileTool instance. + * + * @param mcpServer - the MCP server instance + * @param nreplClient - the nREPL client for communicating with shadow-cljs + */ + constructor(mcpServer: PenpotMcpServer, nreplClient: NreplClient) { + super(mcpServer, ImportPenpotFileArgs.schema); + this.nreplClient = nreplClient; + } + + public getToolName(): string { + return "import_penpot_file"; + } + + public getToolDescription(): string { + return ( + "Imports a .penpot file into the running Penpot instance from a given URL. " + + "The file is imported into the user's Drafts project. " + + "Returns the name(s) of the imported file(s)." + ); + } + + protected async executeCore(args: ImportPenpotFileArgs): Promise { + // generate a random filename for the temporary file + const randomName = `_import_${crypto.randomUUID()}.penpot`; + const tempFilePath = path.join(ImportPenpotFileTool.PUBLIC_DIR, randomName); + const servePath = `/${randomName}`; + + try { + // download the file + this.log.info("Downloading .penpot file from %s", args.url); + await this.downloadFile(args.url, tempFilePath); + const fileSize = fs.statSync(tempFilePath).size; + this.log.info("Downloaded %d bytes to %s", fileSize, tempFilePath); + + // set up the import via CLJS REPL + const atomName = `import-result-${crypto.randomUUID().slice(0, 8)}`; + const setupCode = this.buildImportCode(atomName, servePath); + + this.log.info("Initiating import via CLJS REPL"); + const setupResult = await this.nreplClient.evalCljs(setupCode); + this.log.debug("CLJS setup result: %s", JSON.stringify(setupResult)); + + // check for immediate errors in the setup + if (setupResult.err) { + throw new Error(`CLJS evaluation error: ${setupResult.err}`); + } + + // poll for the import result + const result = await this.pollForResult(atomName); + return new TextResponse(result); + } finally { + // clean up the temporary file + this.cleanupTempFile(tempFilePath); + } + } + + /** + * Builds the ClojureScript code that fetches the file from the static directory, + * creates a blob URL, and triggers the import via the web worker. + * + * @param atomName - unique name for the result atom + * @param servePath - the URL path to fetch the file from (same-origin) + * @returns the ClojureScript code string + */ + private buildImportCode(atomName: string, servePath: string): string { + // escape for embedding in a CLJS string + const escapedPath = servePath.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const escapedAtom = atomName.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + + return ` + (do + (require '[app.main.store :as st]) + (require '[app.main.worker :as mw]) + (require '[app.common.uuid :as uuid]) + (require '[beicon.v2.core :as rx]) + + (def ${escapedAtom} (atom {:status :pending})) + + (let [project-id (->> @st/state :projects vals (filter :is-default) first :id) + file-ids-before (set (keys (:files @st/state)))] + (-> (js/fetch "${escapedPath}") + (.then (fn [resp] + (when-not (.-ok resp) + (reset! ${escapedAtom} {:status :error :error (str "Fetch failed: " (.-status resp))}) + (throw (js/Error. (str "Fetch failed: " (.-status resp))))) + (.blob resp))) + (.then (fn [blob] + (let [uri (js/URL.createObjectURL blob) + file-id (uuid/next) + entries [{:file-id file-id + :name "import" + :type :binfile-v3 + :uri uri}]] + (->> (mw/ask-many! + {:cmd :import-files + :project-id project-id + :files entries + :features (get @st/state :features)}) + (rx/subs! + (fn [msg] + (when (= :finish (:status msg)) + (reset! ${escapedAtom} + {:status :success + :file-ids-before file-ids-before}))) + (fn [err] + (reset! ${escapedAtom} {:status :error :error (str err)})) + (fn [] + (when (= :pending (:status @${escapedAtom})) + (reset! ${escapedAtom} {:status :error :error "Stream completed without success message"})))))))) + (.catch (fn [err] + (when (= :pending (:status @${escapedAtom})) + (reset! ${escapedAtom} {:status :error :error (str err)})))))) + + :initiated) + `; + } + + /** + * Builds the ClojureScript code that resolves the imported file details. + * + * Refreshes the dashboard, diffs the file list against the pre-import snapshot, + * and for each new file fetches the first page-id via the backend API. + * + * @param atomName - the atom holding the import result (including :file-ids-before) + * @param resultAtomName - the atom to store the final file details in + * @returns the ClojureScript code string + */ + private buildResolveCode(atomName: string, resultAtomName: string): string { + return ` + (do + (require '[app.main.store :as st]) + (require '[app.main.repo :as rp]) + (require '[app.main.data.dashboard :as dd]) + (require '[beicon.v2.core :as rx]) + + (def ${resultAtomName} (atom {:status :pending})) + + (let [file-ids-before (:file-ids-before @${atomName}) + team-id (:current-team-id @st/state)] + ;; refresh dashboard files + (st/emit! (dd/fetch-recent-files)) + ;; wait a moment for the state to update, then resolve + (js/setTimeout + (fn [] + (let [all-files (vals (:files @st/state)) + new-files (remove #(contains? file-ids-before (:id %)) all-files) + file-count (count new-files)] + (if (zero? file-count) + (reset! ${resultAtomName} {:status :success :files []}) + ;; fetch page-ids for each new file + (let [remaining (atom file-count) + results (atom [])] + (doseq [f new-files] + (->> (rp/cmd! :get-file {:id (:id f) :features (get @st/state :features)}) + (rx/subs! + (fn [file-data] + (swap! results conj + {:file-id (str (:id f)) + :name (:name f) + :team-id (str team-id) + :page-id (str (first (get-in file-data [:data :pages])))}) + (when (zero? (swap! remaining dec)) + (reset! ${resultAtomName} {:status :success :files @results}))) + (fn [err] + (swap! results conj + {:file-id (str (:id f)) + :name (:name f) + :team-id (str team-id) + :error (str err)}) + (when (zero? (swap! remaining dec)) + (reset! ${resultAtomName} {:status :success :files @results})))))))))) + 500)) + + :initiated) + `; + } + + /** + * Polls the CLJS atom for the import result until it succeeds, fails, or times out. + * On success, resolves the imported file details (server-side IDs, names, page-ids). + * + * @param atomName - the name of the atom to poll + * @returns a JSON string with the imported file details + */ + private async pollForResult(atomName: string): Promise { + const startTime = Date.now(); + + // phase 1: wait for the import to complete + while (Date.now() - startTime < ImportPenpotFileTool.IMPORT_TIMEOUT_MS) { + await this.sleep(ImportPenpotFileTool.POLL_INTERVAL_MS); + + const pollResult = await this.nreplClient.evalCljs(`(pr-str @${atomName})`); + const resultStr = pollResult.values.join(""); + this.log.debug(`Poll result: ${resultStr}`); + + if (resultStr.includes(":success")) { + this.log.info("Import succeeded, resolving file details..."); + return await this.resolveImportedFiles(atomName); + } else if (resultStr.includes(":error")) { + this.log.error(`Import failed: ${resultStr}`); + throw new Error(`Import failed: ${resultStr}`); + } + } + + throw new Error(`Import timed out after ${ImportPenpotFileTool.IMPORT_TIMEOUT_MS / 1000} seconds`); + } + + /** + * After a successful import, resolves the actual server-side file details + * by diffing the dashboard file list and fetching page IDs. + * + * @param atomName - the atom holding the import result with :file-ids-before + * @returns a JSON string with the imported file details + */ + private async resolveImportedFiles(atomName: string): Promise { + const resultAtomName = `import-details-${crypto.randomUUID().slice(0, 8)}`; + const resolveCode = this.buildResolveCode(atomName, resultAtomName); + + await this.nreplClient.evalCljs(resolveCode); + + // poll the result atom + const startTime = Date.now(); + const resolveTimeoutMs = 15_000; + + while (Date.now() - startTime < resolveTimeoutMs) { + await this.sleep(ImportPenpotFileTool.POLL_INTERVAL_MS); + + const pollResult = await this.nreplClient.evalCljs(`(pr-str @${resultAtomName})`); + const resultStr = pollResult.values.join(""); + + if (resultStr.includes(":success")) { + this.log.info("File details resolved"); + return resultStr + "\n\n" + ImportPenpotFileTool.NAVIGATION_HINT; + } + } + + this.log.warn("Timed out resolving file details, returning basic success"); + return "Import succeeded but could not resolve file details."; + } + + /** + * Downloads a file from a URL to a local path. + * + * @param url - the URL to download from + * @param destPath - the local file path to write to + */ + private downloadFile(url: string, destPath: string): Promise { + return new Promise((resolve, reject) => { + const client = url.startsWith("https") ? https : http; + const file = fs.createWriteStream(destPath); + + const request = client.get(url, (response) => { + // handle redirects + if ( + response.statusCode && + response.statusCode >= 300 && + response.statusCode < 400 && + response.headers.location + ) { + file.close(); + fs.unlinkSync(destPath); + this.downloadFile(response.headers.location, destPath).then(resolve, reject); + return; + } + + if (response.statusCode && response.statusCode !== 200) { + file.close(); + fs.unlinkSync(destPath); + reject(new Error(`Download failed with status ${response.statusCode}`)); + return; + } + + response.pipe(file); + file.on("finish", () => { + file.close(); + resolve(); + }); + }); + + request.on("error", (err) => { + file.close(); + if (fs.existsSync(destPath)) { + fs.unlinkSync(destPath); + } + reject(new Error(`Download error: ${err.message}`)); + }); + + file.on("error", (err) => { + file.close(); + if (fs.existsSync(destPath)) { + fs.unlinkSync(destPath); + } + reject(new Error(`File write error: ${err.message}`)); + }); + }); + } + + /** + * Removes the temporary file, logging but not throwing on failure. + */ + private cleanupTempFile(filePath: string): void { + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + this.log.info("Cleaned up temporary file: %s", filePath); + } + } catch (err) { + this.log.warn("Failed to clean up temporary file %s: %s", filePath, err); + } + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/mcp/packages/server/src/tools/ReadTaigaIssueTool.ts b/mcp/packages/server/src/tools/ReadTaigaIssueTool.ts new file mode 100644 index 0000000000..29e8bea460 --- /dev/null +++ b/mcp/packages/server/src/tools/ReadTaigaIssueTool.ts @@ -0,0 +1,163 @@ +import { z } from "zod"; +import { Tool } from "../Tool"; +import "reflect-metadata"; +import type { ToolResponse } from "../ToolResponse"; +import { TextResponse } from "../ToolResponse"; +import { PenpotMcpServer } from "../PenpotMcpServer"; + +/** + * Arguments for the {@link ReadTaigaIssueTool}. + */ +export class ReadTaigaIssueArgs { + static schema = { + issueNumber: z + .number() + .int() + .positive() + .describe( + "The Penpot issue number as it appears in Taiga URLs, " + + "e.g. 14177 for https://tree.taiga.io/project/penpot/issue/14177" + ), + }; + + /** + * The Penpot issue number as it appears in Taiga issue URLs + * (e.g. 14177 for https://tree.taiga.io/project/penpot/issue/14177). + */ + issueNumber!: number; +} + +/** + * Represents a file attachment on a Taiga issue. + */ +interface TaigaAttachment { + filename: string; + size: number; + url: string; +} + +/** + * Represents a comment on a Taiga issue. + */ +interface TaigaComment { + username: string; + comment: string; +} + +/** + * The resolved issue data returned by the tool. + */ +interface TaigaIssueData { + subject: string; + description: string; + status: string; + attachments: TaigaAttachment[]; + comments: TaigaComment[]; +} + +/** + * Tool for reading Penpot issues from the Taiga project tracker. + * + * Resolves a Penpot issue number to its internal Taiga ID and retrieves the issue's + * subject, description, status, attachments, and comments via the Taiga REST API. + */ +export class ReadTaigaIssueTool extends Tool { + private static readonly TAIGA_API_BASE = "https://api.taiga.io/api/v1"; + + constructor(mcpServer: PenpotMcpServer) { + super(mcpServer, ReadTaigaIssueArgs.schema); + } + + public getToolName(): string { + return "read_taiga_issue"; + } + + public getToolDescription(): string { + return "Reads a Penpot issue from the Taiga project tracker, returning its subject, description, status, attachments, and comments."; + } + + protected async executeCore(args: ReadTaigaIssueArgs): Promise { + const { projectId, issueId } = await this.resolveIssue(args.issueNumber); + const issueData = await this.fetchIssueData(projectId, issueId); + return new TextResponse(JSON.stringify(issueData, null, 2)); + } + + /** + * Resolves a Penpot issue number to the internal Taiga project and issue IDs. + */ + private async resolveIssue(issueNumber: number): Promise<{ projectId: number; issueId: number }> { + const url = `${ReadTaigaIssueTool.TAIGA_API_BASE}/resolver?project=penpot&issue=${issueNumber}`; + const data = await this.fetchJson(url); + return { projectId: data.project, issueId: data.issue }; + } + + /** + * Fetches the full issue data including details, attachments, and comments. + */ + private async fetchIssueData(projectId: number, issueId: number): Promise { + // fetch issue details, attachments, and history in parallel + const [details, attachments, comments] = await Promise.all([ + this.fetchIssueDetails(issueId), + this.fetchAttachments(projectId, issueId), + this.fetchComments(issueId), + ]); + + return { + subject: details.subject, + description: details.description ?? "", + status: details.status_extra_info?.name ?? "Unknown", + attachments, + comments, + }; + } + + /** + * Fetches the core issue details from the Taiga API. + */ + private async fetchIssueDetails(issueId: number): Promise { + const url = `${ReadTaigaIssueTool.TAIGA_API_BASE}/issues/${issueId}`; + return this.fetchJson(url); + } + + /** + * Fetches the attachments for an issue. + */ + private async fetchAttachments(projectId: number, issueId: number): Promise { + const url = `${ReadTaigaIssueTool.TAIGA_API_BASE}/issues/attachments?project=${projectId}&object_id=${issueId}`; + const data: any[] = await this.fetchJson(url); + return data.map((a) => ({ + filename: a.name, + size: a.size, + url: a.url, + })); + } + + /** + * Fetches comments from the issue history. + * + * History entries that have a non-empty `comment` field are treated as comments. + */ + private async fetchComments(issueId: number): Promise { + const url = `${ReadTaigaIssueTool.TAIGA_API_BASE}/history/issue/${issueId}`; + const history: any[] = await this.fetchJson(url); + return history + .filter((entry) => entry.comment && entry.comment.trim().length > 0) + .map((entry) => ({ + username: entry.user?.username ?? "unknown", + comment: entry.comment, + })); + } + + /** + * Performs a GET request and returns the parsed JSON response. + * + * @throws Error if the HTTP response status is not OK + */ + private async fetchJson(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Taiga API request failed: ${response.status} ${response.statusText} (${url})`); + } + return response.json(); + } +} diff --git a/mcp/packages/server/src/types/nrepl-client.d.ts b/mcp/packages/server/src/types/nrepl-client.d.ts new file mode 100644 index 0000000000..21ed53fb72 --- /dev/null +++ b/mcp/packages/server/src/types/nrepl-client.d.ts @@ -0,0 +1,53 @@ +declare module "nrepl-client" { + import type { Socket } from "net"; + + interface NreplConnection extends Socket { + /** + * Evaluates the given Clojure expression on the nREPL server. + * + * @param code - the Clojure expression to evaluate + * @param callback - called with an error or array of response messages + */ + eval(code: string, callback: (err: Error | null, result: NreplMessage[]) => void): void; + + /** + * Sends a raw nREPL message to the server. + */ + send(message: Record, callback: (err: Error | null, result: NreplMessage[]) => void): void; + + /** + * Clones the current session, creating a new session that inherits the current state. + */ + clone(callback: (err: Error | null, result: NreplMessage[]) => void): void; + + /** + * Closes the current session. + */ + close(callback: (err: Error | null, result: NreplMessage[]) => void): void; + } + + interface NreplMessage { + id?: string; + session?: string; + "new-session"?: string; + ns?: string; + value?: string; + out?: string; + err?: string; + ex?: string; + status?: string[]; + } + + interface ConnectOptions { + port: number; + host?: string; + } + + /** + * Creates a connection to an nREPL server. + */ + function connect(options: ConnectOptions): NreplConnection; + + export default { connect }; + export type { NreplConnection, NreplMessage, ConnectOptions }; +} diff --git a/mcp/packages/server/src/utils/Semaphore.ts b/mcp/packages/server/src/utils/Semaphore.ts new file mode 100644 index 0000000000..e88a63db36 --- /dev/null +++ b/mcp/packages/server/src/utils/Semaphore.ts @@ -0,0 +1,69 @@ +import { createLogger } from "../logger"; + +/** + * Counting semaphore for bounding the parallelism of asynchronous operations. + * + * Calls in excess of the configured maximum are queued in FIFO order and + * proceed once an earlier holder has released its permit. + */ +export class Semaphore { + private static readonly logger = createLogger("Semaphore"); + + private available: number; + private readonly waiters: Array<() => void> = []; + + /** + * @param name - identifier used in log messages so the source of contention is recognisable + * @param maxConcurrent - the maximum number of permits that may be held simultaneously + */ + constructor( + private readonly name: string, + maxConcurrent: number + ) { + if (maxConcurrent < 1) { + throw new Error(`maxConcurrent must be at least 1; got ${maxConcurrent}`); + } + this.available = maxConcurrent; + } + + /** + * Acquires a permit, runs the given function, and releases the permit when it + * settles - including on rejection. Queues if no permit is currently available. + * + * @param fn - the function to run while holding the permit + * @returns the value returned by fn + */ + public async withPermit(fn: () => Promise): Promise { + await this.acquire(); + try { + return await fn(); + } finally { + this.release(); + } + } + + private acquire(): Promise { + if (this.available > 0) { + this.available--; + return Promise.resolve(); + } + return new Promise((resolve) => { + this.waiters.push(resolve); + Semaphore.logger.info( + "Semaphore '%s' saturated; request queued (%d waiting)", + this.name, + this.waiters.length + ); + }); + } + + private release(): void { + const next = this.waiters.shift(); + if (next !== undefined) { + // pass the permit directly to the next waiter without touching `available` + next(); + } else { + this.available++; + } + } +} diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index c59ac34c3f..16a7487e30 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -38,10 +38,10 @@ importers: version: 5.9.3 vite: specifier: ^7.0.8 - version: 7.3.1(@types/node@20.19.30) + version: 7.3.1(@types/node@20.19.30)(tsx@4.22.3) vite-live-preview: specifier: ^0.3.2 - version: 0.3.2(vite@7.3.1(@types/node@20.19.30)) + version: 0.3.2(vite@7.3.1(@types/node@20.19.30)(tsx@4.22.3)) packages/server: dependencies: @@ -57,9 +57,15 @@ importers: express: specifier: ^5.1.0 version: 5.2.1 + ioredis: + specifier: ^5.6.0 + version: 5.11.0 js-yaml: specifier: ^4.1.1 version: 4.1.1 + nrepl-client: + specifier: ^0.3.0 + version: 0.3.0 penpot-mcp: specifier: file:.. version: packages@file:packages @@ -109,6 +115,9 @@ importers: ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@20.19.30)(typescript@5.9.3) + tsx: + specifier: ^4.22.3 + version: 4.22.3 typescript: specifier: ^5.0.0 version: 5.9.3 @@ -139,6 +148,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -151,6 +166,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -163,6 +184,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -175,6 +202,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -187,6 +220,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -199,6 +238,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -211,6 +256,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -223,6 +274,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -235,6 +292,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -247,6 +310,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -259,6 +328,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -271,6 +346,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -283,6 +364,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -295,6 +382,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -307,6 +400,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -319,6 +418,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -331,6 +436,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -343,6 +454,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -355,6 +472,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -367,6 +490,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -379,6 +508,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} @@ -391,6 +526,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -403,6 +544,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -415,6 +562,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -427,6 +580,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -439,6 +598,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@hono/node-server@1.19.9': resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} engines: {node: '>=18.14.1'} @@ -598,6 +763,9 @@ packages: cpu: [x64] os: [win32] + '@ioredis/commands@1.10.0': + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -881,6 +1049,9 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} + bencode@2.0.3: + resolution: {integrity: sha512-D/vrAD4dLVX23NalHwb8dSvsUsxeRPO8Y7ToKA015JQYq69MLDOMkC0uGZYA/MPpltLO8rt8eqFC2j8DxjTZ/w==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -915,6 +1086,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -978,6 +1153,10 @@ packages: supports-color: optional: true + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -1029,6 +1208,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1149,6 +1333,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ioredis@5.11.0: + resolution: {integrity: sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg==} + engines: {node: '>=12.22.0'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -1221,6 +1409,9 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + nrepl-client@0.3.0: + resolution: {integrity: sha512-EcROXUrzlGHKOdu/E/5WB0OESCI0iGHhdXeYk9cULYtd72eFJrM/Q1umvjTBfKWlT62y76cnyLG/3CmSCqT12w==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1328,6 +1519,14 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + reflect-metadata@0.1.14: resolution: {integrity: sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==} @@ -1420,6 +1619,9 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -1476,6 +1678,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.22.3: + resolution: {integrity: sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==} + engines: {node: '>=18.0.0'} + hasBin: true + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -1618,156 +1825,234 @@ snapshots: '@esbuild/aix-ppc64@0.27.2': optional: true + '@esbuild/aix-ppc64@0.28.0': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true '@esbuild/android-arm64@0.27.2': optional: true + '@esbuild/android-arm64@0.28.0': + optional: true + '@esbuild/android-arm@0.25.12': optional: true '@esbuild/android-arm@0.27.2': optional: true + '@esbuild/android-arm@0.28.0': + optional: true + '@esbuild/android-x64@0.25.12': optional: true '@esbuild/android-x64@0.27.2': optional: true + '@esbuild/android-x64@0.28.0': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true '@esbuild/darwin-arm64@0.27.2': optional: true + '@esbuild/darwin-arm64@0.28.0': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true '@esbuild/darwin-x64@0.27.2': optional: true + '@esbuild/darwin-x64@0.28.0': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true '@esbuild/freebsd-arm64@0.27.2': optional: true + '@esbuild/freebsd-arm64@0.28.0': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true '@esbuild/freebsd-x64@0.27.2': optional: true + '@esbuild/freebsd-x64@0.28.0': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true '@esbuild/linux-arm64@0.27.2': optional: true + '@esbuild/linux-arm64@0.28.0': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true '@esbuild/linux-arm@0.27.2': optional: true + '@esbuild/linux-arm@0.28.0': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true '@esbuild/linux-ia32@0.27.2': optional: true + '@esbuild/linux-ia32@0.28.0': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true '@esbuild/linux-loong64@0.27.2': optional: true + '@esbuild/linux-loong64@0.28.0': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true '@esbuild/linux-mips64el@0.27.2': optional: true + '@esbuild/linux-mips64el@0.28.0': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true '@esbuild/linux-ppc64@0.27.2': optional: true + '@esbuild/linux-ppc64@0.28.0': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true '@esbuild/linux-riscv64@0.27.2': optional: true + '@esbuild/linux-riscv64@0.28.0': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true '@esbuild/linux-s390x@0.27.2': optional: true + '@esbuild/linux-s390x@0.28.0': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true '@esbuild/linux-x64@0.27.2': optional: true + '@esbuild/linux-x64@0.28.0': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true '@esbuild/netbsd-arm64@0.27.2': optional: true + '@esbuild/netbsd-arm64@0.28.0': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true '@esbuild/netbsd-x64@0.27.2': optional: true + '@esbuild/netbsd-x64@0.28.0': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true '@esbuild/openbsd-arm64@0.27.2': optional: true + '@esbuild/openbsd-arm64@0.28.0': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true '@esbuild/openbsd-x64@0.27.2': optional: true + '@esbuild/openbsd-x64@0.28.0': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true '@esbuild/openharmony-arm64@0.27.2': optional: true + '@esbuild/openharmony-arm64@0.28.0': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true '@esbuild/sunos-x64@0.27.2': optional: true + '@esbuild/sunos-x64@0.28.0': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true '@esbuild/win32-arm64@0.27.2': optional: true + '@esbuild/win32-arm64@0.28.0': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true '@esbuild/win32-ia32@0.27.2': optional: true + '@esbuild/win32-ia32@0.28.0': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true '@esbuild/win32-x64@0.27.2': optional: true + '@esbuild/win32-x64@0.28.0': + optional: true + '@hono/node-server@1.19.9(hono@4.11.7)': dependencies: hono: 4.11.7 @@ -1868,6 +2153,8 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@ioredis/commands@1.10.0': {} + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -2092,6 +2379,8 @@ snapshots: atomic-sleep@1.0.0: {} + bencode@2.0.3: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -2139,6 +2428,8 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cluster-key-slot@1.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2189,6 +2480,8 @@ snapshots: dependencies: ms: 2.1.3 + denque@2.1.0: {} + depd@2.0.0: {} detect-libc@2.1.2: {} @@ -2277,6 +2570,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + escalade@3.2.0: {} escape-goat@4.0.0: {} @@ -2408,6 +2730,18 @@ snapshots: inherits@2.0.4: {} + ioredis@5.11.0: + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ipaddr.js@1.9.1: {} is-fullwidth-code-point@3.0.0: {} @@ -2452,6 +2786,11 @@ snapshots: negotiator@1.0.0: {} + nrepl-client@0.3.0: + dependencies: + bencode: 2.0.3 + tree-kill: 1.2.2 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -2564,6 +2903,12 @@ snapshots: real-require@0.2.0: {} + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + reflect-metadata@0.1.14: {} require-directory@2.1.1: {} @@ -2725,6 +3070,8 @@ snapshots: split2@4.2.0: {} + standard-as-callback@2.1.0: {} + statuses@2.0.2: {} string-width@4.2.3: @@ -2780,6 +3127,12 @@ snapshots: tslib@2.8.1: {} + tsx@4.22.3: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -2798,7 +3151,7 @@ snapshots: vary@1.1.2: {} - vite-live-preview@0.3.2(vite@7.3.1(@types/node@20.19.30)): + vite-live-preview@0.3.2(vite@7.3.1(@types/node@20.19.30)(tsx@4.22.3)): dependencies: '@commander-js/extra-typings': 12.1.0(commander@12.1.0) '@types/ansi-html': 0.0.0 @@ -2810,14 +3163,14 @@ snapshots: debug: 4.4.3 escape-goat: 4.0.0 p-defer: 4.0.1 - vite: 7.3.1(@types/node@20.19.30) + vite: 7.3.1(@types/node@20.19.30)(tsx@4.22.3) ws: 8.19.0 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - vite@7.3.1(@types/node@20.19.30): + vite@7.3.1(@types/node@20.19.30)(tsx@4.22.3): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -2828,6 +3181,7 @@ snapshots: optionalDependencies: '@types/node': 20.19.30 fsevents: 2.3.3 + tsx: 4.22.3 which@2.0.2: dependencies: diff --git a/mcp/scripts/start-mcp-devenv b/mcp/scripts/start-mcp-devenv new file mode 100755 index 0000000000..62e957a9eb --- /dev/null +++ b/mcp/scripts/start-mcp-devenv @@ -0,0 +1,6 @@ +#!/bin/sh + +# This starts the MCP server in a configuration for Penpot development +# (assuming devenv) + +PENPOT_MCP_SERVER_HOST=0.0.0.0 PENPOT_MCP_REMOTE_MODE=true PENPOT_MCP_DEVENV=true pnpm run bootstrap From 4d0a3efc5c9d6a62fae5251230144add9d8c9865 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 8 Jun 2026 11:33:36 +0200 Subject: [PATCH 2/9] :bug: Fix plugin API crash when setting text fills (#10051) The `update-text-range` event's `watch` method was returning a bare potok event object (`dwwt/resize-wasm-text-debounce id`) directly inside `rx/concat`, instead of wrapping it in `rx/of`. This caused RxJS to throw "You provided an invalid object where a stream was expected" when a plugin set text fills via the Plugin API. The fix wraps the event in `rx/of` so it becomes a valid Observable, matching the pattern used elsewhere in the codebase (e.g., `clipboard.cljs` lines 1050/1082 and `texts.cljs` line 1232). Signed-off-by: Andrey Antukh --- frontend/src/app/main/data/workspace/texts.cljs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index ae72162316..6d6f1b691b 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -411,7 +411,7 @@ (rx/concat (rx/of (dwsh/update-shapes shape-ids update-fn)) (if (features/active-feature? state "render-wasm/v1") - (dwwt/resize-wasm-text-debounce id) + (rx/of (dwwt/resize-wasm-text-debounce id)) (rx/empty))))))) (defn update-root-attrs From 5426092d684509de1ef4f5117097fcfc799962d6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 8 Jun 2026 12:02:28 +0200 Subject: [PATCH 3/9] :books: Remove the requirement of changelog update --- .github/PULL_REQUEST_TEMPLATE.md | 1 - .opencode/skills/backport-commit/SKILL.md | 7 +------ .serena/memories/workflow/creating-commits.md | 17 ----------------- AGENTS.md | 11 ----------- CONTRIBUTING.md | 5 ++--- 5 files changed, 3 insertions(+), 38 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 58ed9335f4..e18d04e87d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -15,6 +15,5 @@ - [ ] Add or modify existing integration tests in case of bugs or new features, if applicable. - [ ] Refactor any modified SCSS files following the refactor guide. - [ ] Check CI passes successfully. -- [ ] Update the `CHANGES.md` file, referencing the related GitHub issue, if applicable. diff --git a/.opencode/skills/backport-commit/SKILL.md b/.opencode/skills/backport-commit/SKILL.md index c8092402db..79494a3c5d 100644 --- a/.opencode/skills/backport-commit/SKILL.md +++ b/.opencode/skills/backport-commit/SKILL.md @@ -70,12 +70,7 @@ module's `AGENTS.md` for the exact commands). If the formatter auto-fixes indentation, verify the logic is still semantically correct. All checks must pass before moving on. -### 6. Port the changelog entry (if any) - -If the original commit added or modified a `CHANGES.md` entry, port that entry -too — adapting wording and version references for the target branch. - -### 7. Commit +### 6. Commit Ask the `commiter` sub-agent to create a commit. Stage all relevant files (exclude unrelated untracked files) and provide the original commit message as diff --git a/.serena/memories/workflow/creating-commits.md b/.serena/memories/workflow/creating-commits.md index 1ff2916b8e..b58221ae48 100644 --- a/.serena/memories/workflow/creating-commits.md +++ b/.serena/memories/workflow/creating-commits.md @@ -21,20 +21,3 @@ Co-authored-by: ## Commit Type Emojis `:bug:` bug fix · `:sparkles:` enhancement · `:tada:` new feature · `:recycle:` refactor · `:lipstick:` cosmetic · `:ambulance:` critical fix · `:books:` docs · `:construction:` WIP · `:boom:` breaking · `:wrench:` config · `:zap:` perf · `:whale:` docker · `:paperclip:` other · `:arrow_up:` dep upgrade · `:arrow_down:` dep downgrade · `:fire:` removal · `:globe_with_meridians:` translations · `:rocket:` epic/highlight - - -## Changelogs - -**IMPORTANT:** do not modify the changelog unless it explicitly asked. - -For user-facing or notable changes, update the relevant changelog under the unreleased section: -- Main app/modules (`backend`, `frontend`, `common`, `render-wasm`, `exporter`, `mcp`): root `CHANGES.md`. -- Plugin subproject changes: `plugins/CHANGELOG.md`. - -Entry format uses the matching category (`:sparkles:`, `:bug:`, etc.) and references the GitHub issue: - -``` -- Short description of change [#NNNN](https://github.com/penpot/penpot/issues/NNNN) -``` - -Plugin API changelog prefixes: type/signature -> `**plugin-types:**`; runtime behavior -> `**plugin-runtime:**` in `plugins/CHANGELOG.md`. diff --git a/AGENTS.md b/AGENTS.md index dac88e8261..842cd15022 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,17 +32,6 @@ precision while maintaining a strong focus on maintainability and performance. 5. When searching code, prefer `ripgrep` (`rg`) over `grep` — it respects `.gitignore` by default. -## Changelogs - -The project has two changelogs: - -- **Main project changelog**: `CHANGES.md` (root of the repository). Tracks changes for the core Penpot application (backend, frontend, common, render-wasm, exporter, mcp). -- **Plugins changelog**: `plugins/CHANGELOG.md`. Tracks changes for the plugins subproject only. - -When making changes, add a changelog entry to the appropriate file under the -`## (Unreleased)` section in the correct category -(`:sparkles: New features & Enhancements` or `:bug: Bugs fixed`). - ## GitHub Operations To obtain the list of repository members/collaborators: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 532413194d..1251e2c80f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -237,9 +237,8 @@ setting this up. ## Changelog -When your change is user-facing or otherwise notable, add an entry to -[CHANGES.md](CHANGES.md) following the same commit-type conventions. Reference -the relevant GitHub issue or Taiga user story. +The changelog is updated automatically as part of the release process. Contributors +should **not** modify `CHANGES.md` manually in their pull requests. ## Code of Conduct From 6808390827bd19ebde193e04588708e8855f9cf9 Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Mon, 8 Jun 2026 13:25:45 +0200 Subject: [PATCH 4/9] :bug: Fix problem with color picker error (#10056) --- common/src/app/common/math.cljc | 2 +- .../src/app/main/data/workspace/colors.cljs | 3 +-- .../colorpicker/slider_selector.cljs | 6 ++++-- .../ui/workspace/viewport/pixel_overlay.cljs | 19 ++++++++++--------- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/common/src/app/common/math.cljc b/common/src/app/common/math.cljc index 13fa601d69..043559e257 100644 --- a/common/src/app/common/math.cljc +++ b/common/src/app/common/math.cljc @@ -183,7 +183,7 @@ :clj (Math/log10 x))) (defn clamp [num from to] - (if (< num from) + (if (or (nan? num) (< num from)) from (if (> num to) to num))) diff --git a/frontend/src/app/main/data/workspace/colors.cljs b/frontend/src/app/main/data/workspace/colors.cljs index ea6cbd4d36..df61a14e5f 100644 --- a/frontend/src/app/main/data/workspace/colors.cljs +++ b/frontend/src/app/main/data/workspace/colors.cljs @@ -738,7 +738,7 @@ [h s v] (clr/hex->hsv value)] (merge data {:hex (or value "000000") - :alpha (or opacity 1) + :alpha (if (d/nan? opacity) 1 (or opacity 1)) :r r :g g :b b :h h :s s :v v}))) @@ -815,7 +815,6 @@ (rx/filter (ptk/type? ::update-colorpicker-add-stop) stream) (rx/filter (ptk/type? ::update-colorpicker-add-auto) stream) (rx/filter (ptk/type? ::remove-gradient-stop) stream)) - (rx/debounce 40) (rx/map (constantly (colorpicker-onchange-runner on-change))) (rx/take-until stopper)))) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs b/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs index 11a73a449d..14c4e1b820 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs @@ -42,9 +42,11 @@ (when on-change (let [{:keys [left right top bottom]} (-> ev dom/get-target dom/get-bounding-rect) {:keys [x y]} (-> ev dom/get-client-position) + h-size (- right left) + v-size (- bottom top) unit-value (if is-vertical - (mth/clamp (/ (- bottom y) (- bottom top)) 0 1) - (mth/clamp (/ (- x left) (- right left)) 0 1)) + (if (pos? v-size) (mth/clamp (/ (- bottom y) v-size) 0 1) 0) + (if (pos? h-size) (mth/clamp (/ (- x left) h-size) 0 1) 0)) value (+ min-value (* unit-value (- max-value min-value)))] (on-change value))))] diff --git a/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.cljs b/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.cljs index d27cd2d018..92d2d43040 100644 --- a/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.cljs @@ -7,6 +7,7 @@ (ns app.main.ui.workspace.viewport.pixel-overlay (:require-macros [app.main.style :as stl]) (:require + [app.common.data :as d] [app.common.data.macros :as dm] [app.common.math :as mth] [app.config :as cfg] @@ -89,10 +90,10 @@ (when (and (>= x 0) (< x img-width) (>= y 0) (< y img-height)) (let [offset (* (+ (* y img-width) x) 4) rgba (unchecked-get image-data "data") - r (obj/get rgba (+ 0 offset)) - g (obj/get rgba (+ 1 offset)) - b (obj/get rgba (+ 2 offset)) - a (obj/get rgba (+ 3 offset)) + r (d/check-num (obj/get rgba (+ 0 offset)) 255) + g (d/check-num (obj/get rgba (+ 1 offset)) 255) + b (d/check-num (obj/get rgba (+ 2 offset)) 255) + a (d/check-num (obj/get rgba (+ 3 offset)) 255) color [r g b a]] ;; Store latest color synchronously so the click handler always reads ;; the correct pixel even before the rAF fires (fixes race condition) @@ -293,13 +294,13 @@ ;; Only pick color when cursor is within canvas bounds to avoid garbage pixels (when (and (>= canvas-x 0) (< canvas-x img-width) (>= canvas-y 0) (< canvas-y img-height)) (let [;; image-data pixels start from the bottom-left corner; invert y accordingly - inverted-y (- img-height canvas-y) + inverted-y (- img-height canvas-y 1) offset (* (+ (* inverted-y img-width) canvas-x) 4) rgba (.-data image-data) - r (obj/get rgba (+ 0 offset)) - g (obj/get rgba (+ 1 offset)) - b (obj/get rgba (+ 2 offset)) - a (obj/get rgba (+ 3 offset)) + r (d/check-num (obj/get rgba (+ 0 offset)) 255) + g (d/check-num (obj/get rgba (+ 1 offset)) 255) + b (d/check-num (obj/get rgba (+ 2 offset)) 255) + a (d/check-num (obj/get rgba (+ 3 offset)) 255) color [r g b a]] ;; Store latest color synchronously so the click handler always reads ;; the correct pixel even before the rAF fires (fixes race condition) From a326cc416e9e605c34e69002fcd7e7ffe352af5c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 8 Jun 2026 14:26:45 +0200 Subject: [PATCH 5/9] :rewind: Backport github issue templates from develop --- .github/ISSUE_TEMPLATE/bug-report.yml | 5 ++- .github/ISSUE_TEMPLATE/config.yml | 1 + .github/ISSUE_TEMPLATE/feature-request.yml | 5 ++- .../ISSUE_TEMPLATE/new-render-bug-report.md | 38 ------------------- 4 files changed, 9 insertions(+), 40 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/ISSUE_TEMPLATE/new-render-bug-report.md diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 67ba1d4fe1..794fec01c2 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -1,7 +1,10 @@ description: Create a report to help us improve -labels: ["bug"] name: Bug report title: "bug: " +type: Bug +labels: ["triage"] +projects: ["penpot/8"] + body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..0086358db1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: true diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index a49d6a57c9..0fe9c3757d 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -1,7 +1,10 @@ description: Suggest an idea for this project. -labels: ["needs triage", "enhancement"] +labels: ["needs triage"] name: "Feature request" title: "feature: " +type: Enhancement +projects: ["penpot/8"] + body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/new-render-bug-report.md b/.github/ISSUE_TEMPLATE/new-render-bug-report.md deleted file mode 100644 index b93b98b444..0000000000 --- a/.github/ISSUE_TEMPLATE/new-render-bug-report.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -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. From c2f2e0e34bcdb54156870436bac3bb74f7b954b5 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 8 Jun 2026 14:27:07 +0200 Subject: [PATCH 6/9] :paperclip: Add opencode issue-title skill --- .opencode/skills/issue-title/SKILL.md | 123 ++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 .opencode/skills/issue-title/SKILL.md diff --git a/.opencode/skills/issue-title/SKILL.md b/.opencode/skills/issue-title/SKILL.md new file mode 100644 index 0000000000..6cd8c14fc5 --- /dev/null +++ b/.opencode/skills/issue-title/SKILL.md @@ -0,0 +1,123 @@ +--- +name: issue-title +description: Derive a clear, well-formatted title for a GitHub issue from its description body, using descriptive present-tense for bugs and imperative mood for features, always including the "where" (location in the UI/module). +--- + +# Skill: issue-title + +Derive a concise, descriptive title for a GitHub issue based on its body +content. Use **descriptive present tense for bugs** (e.g. "Plugin API +crashes when setting text fills") and **imperative mood for features** (e.g. +"Add customizable dash and gap controls"). No emoji or type prefixes +(`feat:`, `bug:`, `feature:`, etc.). + +Can be used both when **creating a new issue** and when **updating an +existing one** that has a vague or outdated title. + +## When to Use + +- Creating a new issue and need a well-formatted title from the draft body +- An existing issue has a vague, outdated, or auto-generated title (e.g. + `[PENPOT FEEDBACK]: ...`, `feature: ...`) +- The current title doesn't reflect the actual content of the description +- The title is missing the "where" (which part of the UI/module is affected) + +## Prerequisites + +- `gh` CLI authenticated (`gh auth status`) + +## Workflow + +### 1. Get the issue body + +For an **existing issue**, fetch it: + +```bash +gh issue view --repo penpot/penpot --json title,body +``` + +For a **new issue**, read the draft body from wherever it was provided +(Taiga link, user report, discussion, etc.). + +### 2. Read the body and derive a title + +Extract the core problem or request from the description. Distinguish between +bug reports and feature requests: + +**Bug titles (descriptive, present tense):** +Describe the symptom as it appears to the user. Format: +`[Where] [present-tense verb] when [condition]` + +- *"Plugin API crashes when setting text fills"* +- *"Canvas renders glitches when zooming quickly"* +- *"French Canada locale falls back to French (fr) translations"* +- *"Text layer content is not deleted when WebGL render is enabled"* + +Do **not** start bug titles with "Fix" or any imperative verb. The title +should state what's broken, not command a fix. + +**Feature / Enhancement titles (imperative mood):** +Command what should be built. Format: +`[Imperative verb] [what] in/on [where]` + +- *"Add customizable dash and gap length controls to dashed strokes in the sidebar"* +- *"Show user, timestamp, and hash in the workspace history panel like git commits"* +- *"Validate shape on add-object to catch malformed inputs early"* + +**Universal rules (both types):** +- **Include the "where"** — specify the UI location or module (e.g. + "in the sidebar", "in the workspace history panel", "on the stroke + options") +- **No prefixes** — strip `bug:`, `feature:`, `feat:`, `:bug:`, `:sparkles:`, + `[PENPOT FEEDBACK]`, etc. +- **No emoji** — plain text only +- **Be specific** — prefer concrete detail over generality. If the + description mentions two related problems, capture both. + +**Examples:** + +| Original / draft title | Type | New title | +|---|---|---| +| `[PENPOT FEEDBACK]: WebGL` | Bug | `Canvas renders glitches when zooming quickly — text appears distorted and nodes have background-colored rectangles` | +| `bug: flatten-nested-tokens-json uses $type instead of $value as the DTCG token/group discriminator` | Bug | `Token import fails when group-level type inheritance is used — parser misidentifies groups as tokens` | +| `feature: Dashed stroke customization` | Feature | `Add customizable dash and gap length controls to dashed strokes in the sidebar` | +| `feature: Add more detail to history of actions` | Feature | `Show user, timestamp, and hash in the workspace history panel like git commits` | + +### 3. Apply the title + +**If updating an existing issue:** + +```bash +gh issue edit --repo penpot/penpot --title "" +``` + +**If creating a new issue:** + +```bash +gh issue create --repo penpot/penpot --title "" --body "" +``` + +### 4. Confirm + +For updates, the command returns the issue URL. Verify by optionally fetching +again: + +```bash +gh issue view --repo penpot/penpot --json title +``` + +## Key Principles + +- **Bug titles describe the symptom** — present tense, 3rd person: + "crashes", "fails", "shows", "is cut off", "does not load". Do not + start with "Fix" or "Bug:". +- **Feature titles use imperative mood** — command form: "Add", "Show", + "Use", "Validate", "Support", "Toggle". +- **Always include the "where"** — a title like "Crashes when zooming" + is too vague; "Canvas crashes when zooming quickly" is clear. +- **No prefixes, no emoji** — strip all type labels and decorative + characters from the title. +- **Derive from the body, not the current title** — the body contains + the real detail; the current title may be auto-generated or stale. +- **Two problems → cover both** — if the description has two distinct + but related issues, capture both in the title joined by "and". From eff533374dcff479d3f9d953cb41881157948d48 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 8 Jun 2026 10:05:09 +0000 Subject: [PATCH 7/9] :bug: Ignore Safari browser extension errors in error handler Add detection for Safari's webkit-masked-url:// extension URLs and filter the "Attempting to change value of a readonly property" TypeError to prevent Safari browser extension errors from being surfaced to users. Signed-off-by: Andrey Antukh --- frontend/src/app/main/errors.cljs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 7891c166f0..c79358a0b8 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -436,7 +436,10 @@ (let [stack (.-stack cause)] (and (string? stack) (or (str/includes? stack "chrome-extension://") - (str/includes? stack "moz-extension://"))))) + (str/includes? stack "moz-extension://") + ;; Safari/WebKit masks extension and Web Inspector URLs + ;; with this internal scheme. + (str/includes? stack "webkit-masked-url://"))))) (defn- from-posthog? "True when the error stack trace originates from PostHog analytics." @@ -471,6 +474,14 @@ ;; TypeError. This is a known Zone.js / browser-extension ;; incompatibility and is NOT a Penpot bug. (str/starts-with? message "Cannot assign to read only property 'toString'") + ;; Safari TypeError: "Attempting to change value of a readonly + ;; property". Raised when browser extensions or Web Inspector + ;; devtools (e.g., jsonPrune) try to mutate ClojureScript's + ;; immutable data structures via Object.defineProperty. + ;; ClojureScript defines getter-only properties on its maps + ;; and records, making them readonly. This is NOT a Penpot bug. + (and (= (.-name ^js cause) "TypeError") + (= message "Attempting to change value of a readonly property")) ;; NotFoundError DOMException: "Failed to execute ;; 'removeChild' on 'Node'" — Thrown by React's commit ;; phase when the DOM tree has been modified externally From 0e16db66b8ee1c5876d6630e35603ab3941ebd37 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 8 Jun 2026 14:34:31 +0200 Subject: [PATCH 8/9] :rewind: Backport from develop AGENTS.md changes --- AGENTS.md | 140 ++++++++++++++++++++++++------------------------------ 1 file changed, 63 insertions(+), 77 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 842cd15022..08103521e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,93 +1,79 @@ -# AI Agent Guide +# AI AGENT GUIDE -This document provides the core context and operating guidelines for AI agents -working in this repository. +## CRITICAL: Read module memories BEFORE writing any code -## Before You Start +Do this **before planning, before coding, before touching any file**: -Before responding to any user request, you must: +1. Read `critical-info` (use `serena_read_memory critical-info` or read `.serena/memories/critical-info.md`). + It describes the project structure and tells you which modules exist. +2. From `critical-info`, identify which modules your task affects. +3. Read each affected module's **core memory** — the name is `/core` + (e.g. `frontend/core`, `backend/core`, `common/core`). +4. If the core memory references deeper `mem:` memories relevant to your task, read those too. -1. Read this file completely. -2. Identify which modules are affected by the task. -3. Load the `AGENTS.md` file **only** for each affected module (see the - architecture table below). Not all modules have an `AGENTS.md` — verify the - file exists before attempting to read it. -4. Do **not** load `AGENTS.md` files for unrelated modules. +**STOP: Do not proceed until you have read the core memory of every affected module.** +Skipping this step is the #1 cause of incorrect or incomplete work. -## Role: Senior Software Engineer +--- + +# Memory system + +Memories are the **primary project guidance** — not docs or readme files. +They are dense, agent-oriented notes: terse bullets, invariants, no prose. + +## Entry point + +Start at `critical-info` (the graph root). It describes the project structure, +module dependency graph, and references section-level core memories. + +## Progressive discovery model + +Memories form a **reference graph**, not a flat list: + +``` +critical-info ← read first (graph root) + └─
/core ← top-level memory per section (e.g. frontend/core, backend/core) + └─ ← focused memories (e.g. frontend/handling-errors-and-debugging) + └─ ... ← deeper memories as needed +``` + +When working on a task: +1. Read `critical-info` to identify which sections are affected. +2. Read the affected section's `core` memory for an overview. +3. Follow `mem:` references in the core memory to focused memories relevant to your task. +4. Continue following references deeper as needed. + +## Accessing memories + +- **If `serena_read_memory` / `serena_list_memories` tools are available**: use them. + `serena_read_memory` takes a memory name (e.g. `critical-info`, `frontend/core`). +- **If tools are NOT available**: read the filesystem directly. + Memory name `mem:foo/bar` maps to file `.serena/memories/foo/bar.md`. + +## Cross-reference convention + +Memories reference other memories with `mem:
/` inside backticks. +Example: `mem:common/changes-architecture`. +When you encounter a `mem:` reference relevant to your task, read that memory next. + +## Topic/folder organization + +Memories are grouped into folders that mirror project modules or topics: +`backend/`, `common/`, `frontend/`, `render-wasm/`, `exporter/`, `workflow/`, etc. +Each folder's top-level memory is `/core`. + +--- + +# Role: Senior Software Engineer You are a high-autonomy Senior Full-Stack Software Engineer. You have full permission to navigate the codebase, modify files, and execute commands to fulfill your tasks. Your goal is to solve complex technical tasks with high precision while maintaining a strong focus on maintainability and performance. -### Operational Guidelines +## Operational Guidelines 1. Before writing code, describe your plan. If the task is complex, break it down into atomic steps. 2. Be concise and autonomous. 3. Do **not** touch unrelated modules unless the task explicitly requires it. -4. Commit only when explicitly asked. Follow the commit format rules in - `CONTRIBUTING.md`. -5. When searching code, prefer `ripgrep` (`rg`) over `grep` — it respects - `.gitignore` by default. - -## GitHub Operations - -To obtain the list of repository members/collaborators: - -```bash -gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login' -``` - -To obtain the list of open PRs authored by members: - -```bash -MEMBERS=$(gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login' | tr '\n' '|' | sed 's/|$//') -gh pr list --state open --limit 200 --json author,title,number | jq -r --arg members "$MEMBERS" ' - ($members | split("|")) as $m | - .[] | select(.author.login as $a | $m | index($a)) | - "\(.number)\t\(.author.login)\t\(.title)" -' -``` - -To obtain the list of open PRs from external contributors (non-members): - -```bash -MEMBERS=$(gh api repos/:owner/:repo/collaborators --paginate --jq '.[].login' | tr '\n' '|' | sed 's/|$//') -gh pr list --state open --limit 200 --json author,title,number | jq -r --arg members "$MEMBERS" ' - ($members | split("|")) as $m | - .[] | select(.author.login as $a | $m | index($a) | not) | - "\(.number)\t\(.author.login)\t\(.title)" -' -``` - -## Architecture Overview - -Penpot is an open-source design tool composed of several modules: - -| Directory | Language | Purpose | Has `AGENTS.md` | -|-----------|----------|---------|:----------------:| -| `frontend/` | ClojureScript + SCSS | Single-page React app (design editor) | Yes | -| `backend/` | Clojure (JVM) | HTTP/RPC server, PostgreSQL, Redis | Yes | -| `common/` | Cljc (shared Clojure/ClojureScript) | Data types, geometry, schemas, utilities | Yes | -| `render-wasm/` | Rust -> WebAssembly | High-performance canvas renderer (Skia) | Yes | -| `exporter/` | ClojureScript (Node.js) | Headless Playwright-based export (SVG/PDF) | No | -| `mcp/` | TypeScript | Model Context Protocol integration | No | -| `plugins/` | TypeScript | Plugin runtime and example plugins | No | - -Some submodules use `pnpm` workspaces. The root `package.json` and -`pnpm-lock.yaml` manage shared dependencies. Helper scripts live in `scripts/`. - -### Module Dependency Graph - -``` -frontend ──> common -backend ──> common -exporter ──> common -frontend ──> render-wasm (loads compiled WASM) -``` - -`common` is referenced as a local dependency (`{:local/root "../common"}`) by -both `frontend` and `backend`. Changes to `common` can therefore affect multiple -modules — test across consumers when modifying shared code. From 51a9eed02e69f5f069581832654ff471a8da9bff Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 8 Jun 2026 14:35:19 +0200 Subject: [PATCH 9/9] :rewind: Backport from develop AGENTS.md changes --- backend/AGENTS.md | 262 ----------------------------- common/AGENTS.md | 70 -------- frontend/AGENTS.md | 371 ------------------------------------------ render-wasm/AGENTS.md | 62 ------- 4 files changed, 765 deletions(-) delete mode 100644 backend/AGENTS.md delete mode 100644 common/AGENTS.md delete mode 100644 frontend/AGENTS.md delete mode 100644 render-wasm/AGENTS.md diff --git a/backend/AGENTS.md b/backend/AGENTS.md deleted file mode 100644 index a260a74c13..0000000000 --- a/backend/AGENTS.md +++ /dev/null @@ -1,262 +0,0 @@ -# Penpot Backend – Agent Instructions - -Clojure backend (RPC) service running on the JVM. - -Uses Integrant for dependency injection, PostgreSQL for storage, and -Redis for messaging/caching. - -## General Guidelines - -To ensure consistency across the Penpot JVM stack, all contributions must adhere -to these criteria. - -IMPORTANT: all CLI commands should be executed under backend/ -subdirectory for make them work correctly. - -### 1. Testing & Validation - -* **Coverage:** If code is added or modified in `src/`, corresponding - tests in `test/backend_tests/` must be added or updated. - -* **Execution:** - * **Isolated:** Run `clojure -M:dev:test --focus backend-tests.my-ns-test` for the specific test namespace. - * **Regression:** Run `clojure -M:dev:test` to ensure the suite passes without regressions in related functional areas. - -### 2. Code Quality & Formatting - -* **Linting:** All code must pass linter checks (run `pnpm run lint:clj` or `pnpm run lint` on the repository root) -* **Formatting:** All the code must pass the formatting check (run `pnpm run - check-fmt`). Use `pnpm run fmt` to fix formatting issues. Avoid "dirty" - diffs caused by unrelated whitespace changes. -* **Type Hinting:** Use explicit JVM type hints (e.g., `^String`, `^long`) in - performance-critical paths to avoid reflection overhead. - -## Code Conventions - -### Namespace Overview - -The source is located under `src` directory and this is a general overview of -namespaces structure: - -- `app.rpc.commands.*` – RPC command implementations (`auth`, `files`, `teams`, etc.) -- `app.http.*` – HTTP routes and middleware -- `app.db.*` – Database layer -- `app.tasks.*` – Background job tasks -- `app.main` – Integrant system setup and entrypoint -- `app.loggers` – Internal loggers (auditlog, mattermost, etc.) (not to be confused with `app.common.logging`) - -### RPC - -The RPC methods are implemented using a multimethod-like structure via the -`app.util.services` namespace. The main RPC methods are collected under -`app.rpc.commands` namespace and exposed under `/api/rpc/command/`. - -The RPC method accepts POST and GET requests indistinctly and uses the `Accept` -header to negotiate the response encoding (which can be Transit — the default — -or plain JSON). It also accepts Transit (default) or JSON as input, which should -be indicated using the `Content-Type` header. - -The main convention is: use `get-` prefix on RPC name when we want READ -operation. - -Example of RPC method definition: - -```clojure -(sv/defmethod ::my-command - {::rpc/auth true ;; requires auth - ::doc/added "1.18" - ::sm/params [:map ...] ;; malli input schema - ::sm/result [:map ...]} ;; malli output schema - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}] - ;; return a plain map or throw - {:id (uuid/next)}) -``` - -Look under `src/app/rpc/commands/*.clj` to see more examples. - -### Tests - -Test namespaces match `.*-test$` under `test/`. Config is in `tests.edn`. - - -### Integrant System - -The `src/app/main.clj` declares the system map. Each key is a component; values -are config maps with `::ig/ref` for dependencies. Components implement -`ig/init-key` / `ig/halt-key!`. - - -### Connecting to the Database - -Two PostgreSQL databases are used in this environment: - -| Database | Purpose | Connection string | -|---------------|--------------------|----------------------------------------------------| -| `penpot` | Development / app | `postgresql://penpot:penpot@postgres/penpot` | -| `penpot_test` | Test suite | `postgresql://penpot:penpot@postgres/penpot_test` | - -**Interactive psql session:** - -```bash -# development DB -psql "postgresql://penpot:penpot@postgres/penpot" - -# test DB -psql "postgresql://penpot:penpot@postgres/penpot_test" -``` - -**One-shot query (non-interactive):** - -```bash -psql "postgresql://penpot:penpot@postgres/penpot" -c "SELECT id, name FROM team LIMIT 5;" -``` - -**Useful psql meta-commands:** - -``` -\dt -- list all tables -\d -- describe a table (columns, types, constraints) -\di -- list indexes -\q -- quit -``` - -> **Migrations table:** Applied migrations are tracked in the `migrations` table -> with columns `module`, `step`, and `created_at`. When renaming a migration -> logical name, update this table in both databases to match the new name; -> otherwise the runner will attempt to re-apply the migration on next startup. - -```bash -# Example: fix a renamed migration entry in the test DB -psql "postgresql://penpot:penpot@postgres/penpot_test" \ - -c "UPDATE migrations SET step = 'new-name' WHERE step = 'old-name';" -``` - -### Database Access (Clojure) - -`app.db` wraps next.jdbc. Queries use a SQL builder that auto-converts kebab-case ↔ snake_case. - -```clojure -;; Query helpers -(db/get cfg-or-pool :table {:id id}) ; fetch one row (throws if missing) -(db/get* cfg-or-pool :table {:id id}) ; fetch one row (returns nil) -(db/query cfg-or-pool :table {:team-id team-id}) ; fetch multiple rows -(db/insert! cfg-or-pool :table {:name "x" :team-id id}) ; insert -(db/update! cfg-or-pool :table {:name "y"} {:id id}) ; update -(db/delete! cfg-or-pool :table {:id id}) ; delete - -;; Run multiple statements/queries on single connection -(db/run! cfg (fn [{:keys [::db/conn]}] - (db/insert! conn :table row1) - (db/insert! conn :table row2)) - - -;; Transactions -(db/tx-run! cfg (fn [{:keys [::db/conn]}] - (db/insert! conn :table row))) -``` - -Almost all methods in the `app.db` namespace accept `pool`, `conn`, or -`cfg` as params. - -Migrations live in `src/app/migrations/` as numbered SQL files. They run automatically on startup. - - -### Error Handling - -The exception helpers are defined on Common module, and are available under -`app.common.exceptions` namespace. - -Example of raising an exception: - -```clojure -(ex/raise :type :not-found - :code :object-not-found - :hint "File does not exist" - :file-id id) -``` - -Common types: `:not-found`, `:validation`, `:authorization`, `:conflict`, `:internal`. - - -### Performance Macros (`app.common.data.macros`) - -Always prefer these macros over their `clojure.core` equivalents — they provide -optimized implementations: - -```clojure -(dm/select-keys m [:a :b]) ;; faster than core/select-keys -(dm/get-in obj [:a :b :c]) ;; faster than core/get-in -(dm/str "a" "b" "c") ;; string concatenation -``` - -### Configuration - -`src/app/config.clj` reads `PENPOT_*` environment variables, validated with -Malli. Access anywhere via `(cf/get :smtp-host)`. Feature flags: `(cf/flags -:enable-smtp)`. - - -### Background Tasks - -Background tasks live in `src/app/tasks/`. Each task is an Integrant component -that exposes a `::handler` key and follows this three-method pattern: - -```clojure -(defmethod ig/assert-key ::handler ;; validate config at startup - [_ params] - (assert (db/pool? (::db/pool params)) "expected a valid database pool")) - -(defmethod ig/expand-key ::handler ;; inject defaults before init - [k v] - {k (assoc v ::my-option default-value)}) - -(defmethod ig/init-key ::handler ;; return the task fn - [_ cfg] - (fn [_task] ;; receives the task row from the worker - (db/tx-run! cfg (fn [{:keys [::db/conn]}] - ;; … do work … - )))) -``` - -**Wiring a new task** requires two changes in `src/app/main.clj`: - -1. **Handler config** – add an entry in `system-config` with the dependencies: - -```clojure -:app.tasks.my-task/handler -{::db/pool (ig/ref ::db/pool)} -``` - -2. **Registry + cron** – register the handler name and schedule it: - -```clojure -;; in ::wrk/registry ::wrk/tasks map: -:my-task (ig/ref :app.tasks.my-task/handler) - -;; in worker-config ::wrk/cron ::wrk/entries vector: -{:cron #penpot/cron "0 0 0 * * ?" ;; daily at midnight - :task :my-task} -``` - -**Useful cron patterns** (Quartz format — six fields: s m h dom mon dow): - -| Expression | Meaning | -|------------------------------|--------------------| -| `"0 0 0 * * ?"` | Daily at midnight | -| `"0 0 */6 * * ?"` | Every 6 hours | -| `"0 */5 * * * ?"` | Every 5 minutes | - -**Time helpers** (`app.common.time`): - -```clojure -(ct/now) ;; current instant -(ct/duration {:hours 1}) ;; java.time.Duration -(ct/minus (ct/now) some-duration) ;; subtract duration from instant -``` - -`db/interval` converts a `Duration` (or millis / string) to a PostgreSQL -interval object suitable for use in SQL queries: - -```clojure -(db/interval (ct/duration {:hours 1})) ;; → PGInterval "3600.0 seconds" -``` diff --git a/common/AGENTS.md b/common/AGENTS.md deleted file mode 100644 index 2659b83939..0000000000 --- a/common/AGENTS.md +++ /dev/null @@ -1,70 +0,0 @@ -# Penpot Common – Agent Instructions - -A shared module with code written in Clojure, ClojureScript, and -JavaScript. Contains multiplatform code that can be used and executed -from the frontend, backend, or exporter modules. It uses Clojure reader -conditionals to specify platform-specific implementations. - -## General Guidelines - -To ensure consistency across the Penpot stack, all contributions must adhere to -these criteria: - -### 1. Testing & Validation - -If code is added or modified in `src/`, corresponding tests in -`test/common_tests/` must be added or updated. - - * **Environment:** Tests should run in both JS (Node.js) and JVM environments. -* **Location:** Place tests in the `test/common_tests/` directory, following the - namespace structure of the source code (e.g., `app.common.colors` -> - `common-tests.colors-test`). -* **Execution:** Tests should be executed on both JS (Node.js) and JVM environments: - * **Isolated:** - * JS: To run a focused ClojureScript unit test: edit the - `test/common_tests/runner.cljs` to narrow the test suite, then - `pnpm run test:js`. - * JVM: `pnpm run test:jvm --focus common-tests.my-ns-test` - * **Regression:** - * JS: Run `pnpm run test:js` without modifications on the runner (preferred) - * JVM: Run `pnpm run test:jvm` - -### 2. Code Quality & Formatting - -* **Linting:** All code changes must pass linter checks: - * Run `pnpm run lint:clj` for CLJ/CLJS/CLJC -* **Formatting:** All code changes must pass the formatting check - * Run `pnpm run check-fmt:clj` for CLJ/CLJS/CLJC - * Run `pnpm run check-fmt:js` for JS - * Use `pnpm run fmt` to fix all formatting issues (`pnpm run - fmt:clj` or `pnpm run fmt:js` for isolated formatting fix). - -## Code Conventions - -### Namespace Overview - -The source is located under `src` directory and this is a general overview of -namespaces structure: - -- `app.common.types.*` – Shared data types for shapes, files, pages using Malli schemas -- `app.common.schema` – Malli abstraction layer, exposes the most used functions from malli -- `app.common.geom.*` – Geometry and shape transformation helpers -- `app.common.data` – Generic helpers used across the entire application -- `app.common.math` – Generic math helpers used across the entire application -- `app.common.json` – Generic JSON encoding/decoding helpers -- `app.common.data.macros` – Performance macros used everywhere - - -### Reader Conditionals - -We use reader conditionals to differentiate implementations depending on the -target platform where the code runs: - -```clojure -#?(:clj (import java.util.UUID) - :cljs (:require [cljs.core :as core])) -``` - -Both frontend and backend depend on `common` as a local library (`penpot/common -{:local/root "../common"}`). - diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md deleted file mode 100644 index 681528b4f3..0000000000 --- a/frontend/AGENTS.md +++ /dev/null @@ -1,371 +0,0 @@ -# Penpot Frontend – Agent Instructions - -ClojureScript-based frontend application that uses React and RxJS as its main -architectural pieces. - -## General Guidelines - -### 1. Testing & Validation - -#### Unit Tests - -If code is added or modified in `src/`, corresponding tests in -`test/frontend_tests/` must be added or updated. - -* **Environment:** Tests should run in a Node.js or browser-isolated - environment without requiring the full application state or a - running backend. Test are developed using cljs.test. -* **Mocks & Stubs:** * Use proper mocks for any side-effecting - functions (e.g., API calls, storage access). - * Avoid testing through the UI (DOM); we have e2e tests for that. - * Use `with-redefs` or similar ClojureScript mocking utilities to isolate the logic under test. -* **No Flakiness:** Tests must be deterministic. Do not use `setTimeout` or real - network calls. Use synchronous mocks for asynchronous workflows where - possible. -* **Location:** Place tests in the `test/frontend_tests/` directory, following the - namespace structure of the source code (e.g., `app.utils.timers` -> - `frontend-tests.util-timers-test`). -* **Execution:** - * **Isolated:** To run a focused ClojureScript unit test: edit the - `test/frontend_tests/runner.cljs` to narrow the test suite, then `pnpm run - test`. - * **Regression:** To run `pnpm run test` without modifications on the runner (preferred) - - -#### Integration Tests (Playwright) - -Integration tests are developed under `frontend/playwright` directory, we use -mocks for remote communication with the backend. - -You should not add, modify or run the integration tests unless explicitly asked. - - -``` -pnpm run test:e2e # Playwright e2e tests -pnpm run test:e2e --grep "pattern" # Single e2e test by pattern -``` - -Ensure everything is installed before executing tests with the `./scripts/setup` script. - - -### 2. Code Quality & Formatting - -* **Linting:** All code changes must pass linter checks: - * Run `pnpm run lint:clj` for CLJ/CLJS/CLJC - * Run `pnpm run lint:js` for JS - * Run `pnpm run lint:scss` for SCSS -* **Formatting:** All code changes must pass the formatting check - * Run `pnpm run check-fmt:clj` for CLJ/CLJS/CLJC - * Run `pnpm run check-fmt:js` for JS - * Run `pnpm run check-fmt:scss` for SCSS - * Use the `pnpm run fmt` fix all the formatting issues (`pnpm run fmt:clj`, - `pnpm run fmt:js` or `pnpm run fmt:scss` for isolated formatting fix) - -### 3. Implementation Rules - -* **Logic vs. View:** If logic is embedded in a UI component, extract it into a - function in the same namespace if it is only used locally, or look for a helper - namespace to make it unit-testable. - -### 4. Stack Trace Analysis - -When analyzing production stack traces (minified code), you can generate a -production bundle locally to map the minified code back to the source. - -**To build the production bundle:** - -Run: `pnpm run build:app` - -The compiled files and their corresponding source maps will be generated in -`resources/public/js`. - -**Analysis Tips:** - -- **Source Maps:** Use the `.map` files generated in `resources/public/js` with - tools like `source-map-lookup` or browser dev tools to resolve minified - locations. -- **Bundle Inspection:** If the issue is related to bundle size or unexpected - code inclusion, inspect the generated modules in `resources/public/js`. -- **Shadow-CLJS Reports:** For more detailed analysis of what is included in the - bundle, you can run shadow-cljs build reports (consult `shadow-cljs.edn` for - build IDs like `main` or `worker`). - - -## Code Conventions - -### Namespace Overview - -The source is located under `src` directory and this is a general overview of -namespaces structure: - -- `app.main.ui.*` – React UI components (`workspace`, `dashboard`, `viewer`) -- `app.main.data.*` – Potok event handlers (state mutations + side effects) -- `app.main.refs` – Reactive subscriptions (okulary lenses) -- `app.main.store` – Potok event store -- `app.util.*` – Utilities (DOM, HTTP, i18n, keyboard shortcuts) - - -### State Management (Potok) - -State is a single atom managed by a Potok store. Events implement protocols -(funcool/potok library): - -```clojure -(defn my-event - "doc string" - [data] - (ptk/reify ::my-event - ptk/UpdateEvent - (update [_ state] ;; synchronous state transition - (assoc state :key data)) - - ptk/WatchEvent - (watch [_ state stream] ;; async: returns an observable - (->> (rp/cmd! :some-rpc-command params) - (rx/map success-event) - (rx/catch error-handler))) - - ptk/EffectEvent - (effect [_ state _] ;; pure side effects (DOM, logging) - (dom/focus (dom/get-element "id"))))) -``` - -The state is located under `app.main.store` namespace where we have -the `emit!` function responsible for emitting events. - -Example: - -```cljs -(ns some.ns - (:require - [app.main.data.my-events :refer [my-event]] - [app.main.store :as st])) - -(defn on-click - [event] - (st/emit! (my-event))) -``` - -On `app.main.refs` we have reactive references which look up the main state -for inner data or precalculated data. These references are very useful but -should be used with care because, for example, if we have a complex operation, -this operation will be executed on each state change. Sometimes it is better to -have simple references and use React `use-memo` for more granular memoization. - -Prefer helpers from `app.util.dom` instead of using direct DOM calls. If no -helper is available, prefer adding a new helper and then using it. - -### UI Components (React & Rumext: mf/defc) - -The codebase contains various component patterns. When creating or refactoring -components, follow the Modern Syntax rules outlined below. - -#### 1. The * Suffix Convention - -The most recent syntax uses a * suffix in the component name (e.g., -my-component*). This suffix signals the mf/defc macro to apply specific rules -for props handling and destructuring and optimization. - -#### 2. Component Definition - -Modern components should use the following structure: - -```clj -(mf/defc my-component* - {::mf/wrap [mf/memo]} ;; Equivalent to React.memo - [{:keys [name on-click]}] ;; Destructured props - [:div {:class (stl/css :root) - :on-click on-click} - name]) -``` - -#### 3. Hooks - -Use the mf namespace for hooks to maintain consistency with the macro's -lifecycle management. These are analogous to standard React hooks: - -```clj -(mf/use-state) ;; analogous to React.useState adapted to cljs semantics -(mf/use-effect) ;; analogous to React.useEffect -(mf/use-memo) ;; analogous to React.useMemo -(mf/use-fn) ;; analogous to React.useCallback -``` - -The `mf/use-state` in difference with React.useState, returns an atom-like -object, where you can use `swap!` or `reset!` to perform an update and -`deref` to get the current value. - -You also have the `mf/deref` hook (which does not follow the `use-` naming -pattern) and its purpose is to watch (subscribe to changes on) an atom or -derived atom (from okulary) and get the current value. It is mainly used to -subscribe to lenses defined in `app.main.refs` or private lenses defined in -namespaces. - -Rumext also comes with improved syntax macros as alternative to `mf/use-effect` -and `mf/use-memo` functions. Examples: - - -Example for `mf/with-effect` macro: - -```clj -;; Using functions -(mf/use-effect - (mf/deps team-id) - (fn [] - (st/emit! (dd/initialize team-id)) - (fn [] - (st/emit! (dd/finalize team-id))))) - -;; The same effect but using mf/with-effect -(mf/with-effect [team-id] - (st/emit! (dd/initialize team-id)) - (fn [] - (st/emit! (dd/finalize team-id)))) -``` - -Example for `mf/with-memo` macro: - -``` -;; Using functions -(mf/use-memo - (mf/deps projects team-id) - (fn [] - (->> (vals projects) - (filterv #(= team-id (:team-id %)))))) - -;; Using the macro -(mf/with-memo [projects team-id] - (->> (vals projects) - (filterv #(= team-id (:team-id %))))) -``` - -Prefer using the macros for their syntax simplicity. - - -#### 4. Component Usage (Hiccup Syntax) - -When invoking a component within Hiccup, always use the [:> component* props] -pattern. - -Requirements for props: - -- Must be a map literal or a symbol pointing to a JavaScript props object. -- To create a JS props object, use the `#js` literal or the `mf/spread-object` helper macro. - -Examples: - -```clj -;; Using object literal (no need of #js because macro already interprets it) -[:> my-component* {:data-foo "bar"}] - -;; Using object literal (no need of #js because macro already interprets it) -(let [props #js {:data-foo "bar" - :className "myclass"}] - [:> my-component* props]) - -;; Using the spread helper -(let [props (mf/spread-object base-props {:extra "data"})] - [:> my-component* props]) -``` - -#### 5. Styles - -##### Styles on component code -Styles are co-located with components. Each `.cljs` file has a corresponding -`.scss` file. - -Example of clojurescript code for reference classes defined on styles (we use -CSS modules pattern): - -```clojure -;; In the component namespace: -(require '[app.main.style :as stl]) - -;; In the render function: -[:div {:class (stl/css :container :active)}] - -;; Conditional: -[:div {:class (stl/css-case :some-class true :selected (= drawtool :rect))}] - -;; When you need concat an existing class: -[:div {:class [existing-class (stl/css-case :some-class true :selected (= drawtool :rect))]}] -``` - -##### General rules for styling - -- Prefer CSS custom properties ( `margin: var(--sp-xs);`) instead of scss - variables and get the already defined properties from `_sizes.scss`. The SCSS - variables are allowed and still used, just prefer properties if they are - already defined. -- If a value isn't in the DS, use the `px2rem(n)` mixin: `@use "ds/_utils.scss" - as *; padding: px2rem(23);`. -- Do **not** create new SCSS variables for one-off values. -- Use physical directions with logical ones to support RTL/LTR naturally: - - Avoid: `margin-left`, `padding-right`, `left`, `right`. - - Prefer: `margin-inline-start`, `padding-inline-end`, `inset-inline-start`. -- Always use the `use-typography` mixin from `ds/typography.scss`: - - Example: `@include t.use-typography("title-small");` -- Use `$br-*` for radius and `$b-*` for thickness from `ds/_borders.scss`. -- Use only tokens from `ds/colors.scss`. Do **NOT** use `design-tokens.scss` or - legacy color variables. -- Use mixins only from `ds/mixins.scss`. Avoid legacy mixins like - `@include flexCenter;`. Write standard CSS (flex/grid) instead. -- Use the `@use` instead of `@import`. If you go to refactor existing SCSS file, - try to replace all `@import` with `@use`. Example: `@use "ds/_sizes.scss" as - *;` (Use `as *` to expose variables directly). -- Avoid deep selector nesting or high-specificity (IDs). Flatten selectors: - - Avoid: `.card { .title { ... } }` - - Prefer: `.card-title { ... }` -- Leverage component-level CSS variables for state changes (hover/focus) instead - of rewriting properties. - -##### Checklist - -- [ ] No references to `common/refactor/` -- [ ] All `@import` converted to `@use` (only if refactoring) -- [ ] Physical properties (left/right) using logical properties (inline-start/end). -- [ ] Typography implemented via `use-typography()` mixin. -- [ ] Hardcoded pixel values wrapped in `px2rem()`. -- [ ] Selectors are flat (no deep nesting). - - -### Translations (`tr`) and Memoization - -`(tr "some.key")` resolves the translation string from the **currently active -locale at call time**. This has two consequences: - -- **Never call `(tr ...)` at namespace level** (inside a `def` or `defonce`). - Doing so would freeze the label to the locale active at module load time and - break runtime language switching. -- **Always call `(tr ...)` at render time** — either directly in the component - body or inside a `mf/with-memo` / `mf/use-memo` block. - -When a component renders a **static list of options** whose labels come from -`(tr ...)` (e.g. radio button options, select options), wrap the vector in -`mf/with-memo []` with no dependencies. This ensures the vector and its -`(tr ...)` calls are evaluated once per component mount instead of on every -render, while still respecting the render-time requirement: - -```clojure -(let [options (mf/with-memo [] - [{:value "top" :label (tr "some.key.top")} - {:value "center" :label (tr "some.key.center")} - {:value "bottom" :label (tr "some.key.bottom")}])] - ...) -``` - -### Performance Macros (`app.common.data.macros`) - -Always prefer these macros over their `clojure.core` equivalents — they compile to faster JavaScript: - -```clojure -(dm/select-keys m [:a :b]) ;; ~6x faster than core/select-keys -(dm/get-in obj [:a :b :c]) ;; faster than core/get-in -(dm/str "a" "b" "c") ;; string concatenation -``` - -### Configuration - -`src/app/config.clj` reads globally defined variables and exposes precomputed -configuration values ready to be used from other parts of the application. - diff --git a/render-wasm/AGENTS.md b/render-wasm/AGENTS.md deleted file mode 100644 index 511b7da0c9..0000000000 --- a/render-wasm/AGENTS.md +++ /dev/null @@ -1,62 +0,0 @@ -# render-wasm – Agent Instructions - -This component compiles Rust to WebAssembly using Emscripten + -Skia. It is consumed by the frontend as a canvas renderer. - -## Commands - -```bash -./build # Compile Rust → WASM (requires Emscripten environment) -./watch # Incremental rebuild on file change -./test # Run Rust unit tests (cargo test) -./lint # clippy -D warnings -cargo fmt --check -``` - -Run a single test: -```bash -cargo test my_test_name # by test function name -cargo test shapes:: # by module prefix -``` - -Build output lands in `../frontend/resources/public/js/` (consumed directly by the frontend dev server). - -## Build Environment - -The `_build_env` script sets required env vars (Emscripten paths, -`EMCC_CFLAGS`). `./build` sources it automatically. The WASM heap is -configured to 256 MB initial with geometric growth. - -## Architecture - -**Global state** — a single `unsafe static mut State` accessed -exclusively through `with_state!` / `with_state_mut!` macros. Never -access it directly. - -**Tile-based rendering** — only 512×512 tiles within the viewport -(plus a pre-render buffer) are drawn each frame. Tiles outside the -range are skipped. - -**Two-phase updates** — shape data is written via exported setter -functions (called from ClojureScript), then a single `render_frame()` -triggers the actual Skia draw calls. - -**Shape hierarchy** — shapes live in a flat pool indexed by UUID; -parent/child relationships are tracked separately. - -## Key Source Modules - -| Path | Role | -|------|------| -| `src/lib.rs` | WASM exports — all functions callable from JS | -| `src/state.rs` | Global `State` struct definition | -| `src/render/` | Tile rendering pipeline, Skia surface management | -| `src/shapes/` | Shape types and Skia draw logic per shape | -| `src/wasm/` | JS interop helpers (memory, string encoding) | - -## Frontend Integration - -The WASM module is loaded by `app.render-wasm.*` namespaces in the -frontend. ClojureScript calls exported Rust functions to push shape -data, then calls `render_frame`. Do not change export function -signatures without updating the corresponding ClojureScript bridge.