From 2bb6cd557eff7b2c457a96a42e43da7d9ee4f8c3 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 2 Oct 2026 14:32:56 +0000 Subject: [PATCH] :sparkles: Add project and file discovery to penpotMgmt Discovery: - Add listProjects and project-scoped listFiles with content:read checks. - Return metadata from existing RPCs in dashboard and workspace contexts. - Reject invalid IDs and preserve backend access errors. Documentation: - Keep usage in API type comments and include regenerated MCP API docs. - Remove README implementation details and consolidate the changelog. - Add usage information to the MCP server's initial instructions Validation: - Pass 8 frontend tests, 81 runtime tests and the runtime build. - Verify both APIs through live MCP calls in dashboard and workspace. - Pass type, lint, formatting and diff checks. AI-assisted-by: gpt-6 Signed-off-by: Dominik Jain --- frontend/src/app/plugins/management.cljs | 48 ++- .../plugins/management_test.cljs | 71 +++++ mcp/README.md | 10 - mcp/packages/server/data/api_types.yml | 273 +++++++++++++++++- .../server/data/initial_instructions.md | 10 +- .../server/src/tools/ExecuteCodeTool.ts | 5 +- plugins/CHANGELOG.md | 3 +- plugins/libs/plugin-types/index.d.ts | 73 ++++- .../libs/plugins-runtime/src/lib/api/index.ts | 8 + .../src/lib/api/plugin-api.spec.ts | 54 ++++ 10 files changed, 532 insertions(+), 23 deletions(-) diff --git a/frontend/src/app/plugins/management.cljs b/frontend/src/app/plugins/management.cljs index bff5d9292d..9dadac543b 100644 --- a/frontend/src/app/plugins/management.cljs +++ b/frontend/src/app/plugins/management.cljs @@ -8,9 +8,11 @@ (:require [app.common.uuid :as uuid] [app.main.data.helpers :as dsh] + [app.main.repo :as rp] [app.main.router :as rt] [app.main.store :as st] - [app.util.object :as obj])) + [app.util.object :as obj] + [beicon.v2.core :as rx])) (defn workspace-context [state] @@ -70,9 +72,51 @@ (finish (js/Error. "File navigation was superseded")))))) (st/emit! (rt/nav :workspace {:team-id team-id :file-id file-id})))))))) +(defn list-projects + [options] + (let [team-id (if-let [id (obj/get options "teamId")] + (uuid/parse* id) + (:current-team-id @st/state))] + (if (nil? team-id) + (js/Promise.reject (js/Error. "Expected a team UUID")) + (js/Promise. + (fn [resolve reject] + (->> (rp/cmd! :get-projects {:team-id team-id}) + (rx/map (fn [projects] + (clj->js + (mapv (fn [project] + {:id (str (:id project)) + :teamId (str (:team-id project)) + :name (:name project) + :isDefault (boolean (:is-default project)) + :fileCount (:count project)}) + (remove :deleted-at projects))))) + (rx/subs! resolve reject))))))) + +(defn list-files + [options] + (let [project-id (uuid/parse* (obj/get options "projectId"))] + (if (nil? project-id) + (js/Promise.reject (js/Error. "Expected a project UUID")) + (js/Promise. + (fn [resolve reject] + (->> (rp/cmd! :get-project-files {:project-id project-id}) + (rx/map (fn [files] + (clj->js + (mapv (fn [file] + {:id (str (:id file)) + :teamId (str (:team-id file)) + :projectId (str (:project-id file)) + :name (:name file) + :modifiedAt (.toISOString ^js (:modified-at file))}) + files)))) + (rx/subs! resolve reject))))))) + (defn create-context [] (obj/reify {:name "PenpotManagementContext"} :workspace {:get (fn [] (clj->js (workspace-context @st/state)))} - :openFile open-file)) + :openFile open-file + :listProjects list-projects + :listFiles list-files)) diff --git a/frontend/test/frontend_tests/plugins/management_test.cljs b/frontend/test/frontend_tests/plugins/management_test.cljs index 1e11a83367..9db0ae80c0 100644 --- a/frontend/test/frontend_tests/plugins/management_test.cljs +++ b/frontend/test/frontend_tests/plugins/management_test.cljs @@ -7,9 +7,11 @@ (ns frontend-tests.plugins.management-test (:require [app.common.uuid :as uuid] + [app.main.repo :as rp] [app.main.store :as st] [app.plugins.events :as events] [app.plugins.management :as management] + [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] [frontend-tests.helpers.async :as a] [frontend-tests.helpers.mock :as mock])) @@ -81,3 +83,72 @@ (let [result (-> (management/open-file "invalid" #js {:teamId (str team-id)}) (.then (fn [] nil) (fn [error] (.-message error))))] (t/is (= "Expected a file UUID and a team UUID" (await result))))) + +(t/deftest ^:async list-projects-returns-active-metadata + (let [project-id (uuid/next) + other-team-id (uuid/next)] + (doseq [[state options expected-team-id] + [[dashboard nil team-id] + [ready #js {:teamId (str other-team-id)} other-team-id]]] + (let [state* (atom state)] + (await + (mock/with-mocks* + {st/state state* + rp/cmd! (mock/stub + (fn [command params] + (t/is (= [:get-projects {:team-id expected-team-id}] + [command params])) + (rx/of [{:id project-id :team-id expected-team-id + :name "Drafts" :is-default true :count 2 :total-count 3} + {:id (uuid/next) :deleted-at (js/Date.)}])))} + (let [projects (await (management/list-projects options))] + (t/is (= [{"id" (str project-id) "teamId" (str expected-team-id) + "name" "Drafts" "isDefault" true "fileCount" 2}] + (js->clj projects))) + (t/is (= state @state*))))))))) + +(t/deftest ^:async list-files-returns-metadata-without-opening-a-file + (let [project-id (uuid/next)] + (doseq [state [dashboard ready]] + (let [state* (atom state)] + (await + (mock/with-mocks* + {st/state state* + rp/cmd! (mock/stub + (fn [command params] + (t/is (= [:get-project-files {:project-id project-id}] + [command params])) + (rx/of [{:id file-id :team-id team-id :project-id project-id + :name "Design" :modified-at (js/Date. "2026-01-02T03:04:05Z") + :revn 10}])))} + (let [files (await (management/list-files #js {:projectId (str project-id)}))] + (t/is (= [{"id" (str file-id) "teamId" (str team-id) + "projectId" (str project-id) "name" "Design" + "modifiedAt" "2026-01-02T03:04:05.000Z"}] + (js->clj files))) + (t/is (= state @state*))))))))) + +(t/deftest ^:async listing-rejects-invalid-identifiers-without-querying + (await + (mock/with-mocks* + {st/state (atom {}) + rp/cmd! (mock/stub (fn [_ _] (t/is false "Invalid IDs must not query the backend")))} + (doseq [[result message] + [[(management/list-projects nil) "Expected a team UUID"] + [(management/list-projects #js {:teamId "invalid"}) "Expected a team UUID"] + [(management/list-files nil) "Expected a project UUID"] + [(management/list-files #js {:projectId "invalid"}) "Expected a project UUID"]]] + (t/is (= message (await (.then result (fn [] nil) #(.-message %))))))))) + +(t/deftest ^:async listing-preserves-empty-results-and-backend-errors + (doseq [list-items [(fn [] (management/list-projects #js {:teamId (str team-id)})) + (fn [] (management/list-files #js {:projectId (str (uuid/next))}))]] + (await + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [_ _] (rx/of [])))} + (t/is (= [] (js->clj (await (list-items))))))) + (let [error (js/Error. "Access denied")] + (await + (mock/with-mocks* + {rp/cmd! (mock/stub (fn [_ _] (rx/throw error)))} + (t/is (identical? error (await (.then (list-items) (fn [] nil) identity))))))))) diff --git a/mcp/README.md b/mcp/README.md index 7018537793..59d60aaf61 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -33,16 +33,6 @@ This repository thus contains not only the MCP server implementation itself but also the supporting Penpot MCP Plugin (see section [Repository Structure](#repository-structure) below). -## Global plugin lifecycle - -- The MCP plugin stays loaded across dashboard and workspace navigation, including when the integration runs with its UI hidden. -- Enabling the integration loads the plugin; connecting remains an explicit action in the current tab. Logout closes it. -- The MCP session ID is a short, 10-character Base32 hash of `penpot.currentUser.sessionId` alone. File metadata changes without replacing the WebSocket connection or execution `storage`. -- `penpotMgmt.workspace` reports `none`, `loading` or `ready`, with file and team metadata. Subscribe through `penpotMgmt.on("workspacechange", callback)`. -- `await penpotMgmt.openFile(fileId, {teamId})` opens a file in the same tab and waits for its page to be ready. The team defaults to the current team; failed navigation rejects the promise. -- The `penpot` facade stays live. File operations require a ready workspace; reacquire file, page and shape references after navigation. -- Global plugins declare `"scope": "global"` in their manifest. Management metadata and navigation currently require `content:read` permission. - ## Demonstration [![Video](https://v32155.1blu.de/penpot/PenpotFest2025_thumbnail.png)](https://v32155.1blu.de/penpot/PenpotFest2025.mp4) diff --git a/mcp/packages/server/data/api_types.yml b/mcp/packages/server/data/api_types.yml index e0ebcde698..c926147938 100644 --- a/mcp/packages/server/data/api_types.yml +++ b/mcp/packages/server/data/api_types.yml @@ -92,7 +92,7 @@ Penpot: Hierarchy - * Omit + * Omit + Penpot members: Properties: @@ -5001,6 +5001,250 @@ CommonLayout: The `remove` method removes the layout. + Returns void +WorkspaceContext: + overview: |- + Interface WorkspaceContext + ========================== + + Current workspace of this browser tab. + + ``` + interface WorkspaceContext { + status: "none" | "loading" | "ready"; + fileId: string | null; + fileName: string | null; + teamId: string | null; + } + ``` + + Referenced by: EventsMap, PenpotMgmt + members: + Properties: + status: |- + ``` + readonly status: "none" | "loading" | "ready" + ``` + fileId: |- + ``` + readonly fileId: string | null + ``` + fileName: |- + ``` + readonly fileName: string | null + ``` + teamId: |- + ``` + readonly teamId: string | null + ``` +ProjectSummary: + overview: |- + Interface ProjectSummary + ======================== + + Metadata snapshot of an accessible, non-deleted project. + + ``` + interface ProjectSummary { + id: string; + teamId: string; + name: string; + isDefault: boolean; + fileCount: number; + } + ``` + + Referenced by: PenpotMgmt + members: + Properties: + id: |- + ``` + readonly id: string + ``` + teamId: |- + ``` + readonly teamId: string + ``` + name: |- + ``` + readonly name: string + ``` + isDefault: |- + ``` + readonly isDefault: boolean + ``` + + Whether this is the team's Drafts project. + fileCount: |- + ``` + readonly fileCount: number + ``` +FileSummary: + overview: |- + Interface FileSummary + ===================== + + Metadata snapshot of an accessible, non-deleted file. + + ``` + interface FileSummary { + id: string; + teamId: string; + projectId: string; + name: string; + modifiedAt: string; + } + ``` + + Referenced by: PenpotMgmt + members: + Properties: + id: |- + ``` + readonly id: string + ``` + teamId: |- + ``` + readonly teamId: string + ``` + projectId: |- + ``` + readonly projectId: string + ``` + name: |- + ``` + readonly name: string + ``` + modifiedAt: |- + ``` + readonly modifiedAt: string + ``` + + Last modification time as an ISO 8601 string in UTC. +PenpotMgmt: + overview: |- + Interface PenpotMgmt + ==================== + + Application management API exposed as `penpotMgmt` to global plugins. + + * Requires `content:read` permission. + * Available in the dashboard and file workspaces. + * Use this API to discover and open files; file operations on `penpot` require a ready workspace. + * Reacquire file, page and shape objects after changing files. + + ``` + interface PenpotMgmt { + workspace: WorkspaceContext; + listProjects(options?: { teamId?: string }): Promise; + listFiles(options: { projectId: string }): Promise; + openFile(fileId: string, options?: { teamId?: string }): Promise; + on( + type: "workspacechange", + callback: (workspace: WorkspaceContext) => void, + ): symbol; + off(listenerId: symbol): void; + } + ``` + + Referenced by: Context + members: + Properties: + workspace: |- + ``` + readonly workspace: WorkspaceContext + ``` + + Current workspace availability and metadata; usable for file operations when status is `ready`. + Methods: + listProjects: |- + ``` + listProjects(options?: { teamId?: string }): Promise + ``` + + Lists accessible, non-deleted projects as metadata snapshots. + + * `teamId` defaults to the current team. + * Queries the backend without opening a file or changing navigation. + * Rejects invalid identifiers and backend access errors. + + Parameters + + * options: { teamId?: string } + + Returns Promise + + Example + ``` + const projects = await penpotMgmt.listProjects(); + ``` + listFiles: |- + ``` + listFiles(options: { projectId: string }): Promise + ``` + + Lists accessible, non-deleted files in the specified project as metadata snapshots. + + * Requires an explicit `projectId`. + * Queries the backend without opening a file or changing navigation. + * Rejects invalid identifiers and backend access errors. + + Parameters + + * options: { projectId: string } + + Returns Promise + + Example + ``` + const files = await penpotMgmt.listFiles({ projectId: project.id }); + ``` + openFile: |- + ``` + openFile(fileId: string, options?: { teamId?: string }): Promise + ``` + + Opens a file in this tab and resolves once its workspace and page are ready. + + * `teamId` defaults to the current team. + * Rejects invalid identifiers, failed or superseded navigation, and waits exceeding 30 seconds. + + Parameters + + * fileId: string + * options: { teamId?: string } + + Returns Promise + + Example + ``` + await penpotMgmt.openFile(file.id, { teamId: file.teamId }); + ``` + on: |- + ``` + on( + type: "workspacechange", + callback: (workspace: WorkspaceContext) => void, + ): symbol + ``` + + Subscribes to workspace availability and metadata changes. + + Parameters + + * type: "workspacechange" + * callback: (workspace: WorkspaceContext) => void + + Returns symbol + off: |- + ``` + off(listenerId: symbol): void + ``` + + Parameters + + * listenerId: symbol + Returns void Context: overview: |- @@ -5011,6 +5255,13 @@ Context: ``` interface Context { + management?: Pick< + PenpotMgmt, + "workspace" + | "openFile" + | "listProjects" + | "listFiles", + >; version: string; root: Shape | null; currentFile: File | null; @@ -5082,6 +5333,16 @@ Context: ``` members: Properties: + management: |- + ``` + readonly management?: Pick< + PenpotMgmt, + "workspace" + | "openFile" + | "listProjects" + | "listFiles", + > + ``` version: |- ``` readonly version: string @@ -7109,6 +7370,8 @@ EventsMap: ``` interface EventsMap { + workspacechange: WorkspaceContext; + logout: void; pagechange: Page; filechange: File; selectionchange: string[]; @@ -7122,6 +7385,14 @@ EventsMap: Referenced by: Context, Penpot members: Properties: + workspacechange: |- + ``` + readonly workspacechange: WorkspaceContext + ``` + logout: |- + ``` + readonly logout: void + ``` pagechange: |- ``` readonly pagechange: Page diff --git a/mcp/packages/server/data/initial_instructions.md b/mcp/packages/server/data/initial_instructions.md index be9177619c..ea5961c605 100644 --- a/mcp/packages/server/data/initial_instructions.md +++ b/mcp/packages/server/data/initial_instructions.md @@ -166,13 +166,15 @@ Boards can have layout systems that automatically control the positioning and sp # The `penpot` and `penpotUtils` Objects, Exploring Designs -A key object to use in your code is the `penpot` object (which is of type `Penpot`): +A key object for navigating the currently active Penpot file is the `penpot` object (which is of type `Penpot`): * `penpot.selection` provides the list of shapes the user has selected in the Penpot UI. If it is unclear which elements to work on, you can ask the user to select them for you. ALWAYS immediately copy the selected shape(s) into `storage`! Do not assume that the selection remains unchanged. * `penpot.root` provides the root shape of the currently active page. * Generation of CSS content for elements via `penpot.generateStyle` * Generation of HTML/SVG content for elements via `penpot.generateMarkup` +Members of `penpot` (e.g. `penpot.currentFile` and `penpot.root`) will be null no Penpot file is currently opened. +You can use `penpotMgmt` to navigate to a file if needed (see below). For example, to generate CSS for the currently selected elements, you can execute this: return penpot.generateStyle(penpot.selection, { type: "css", withChildren: true }); @@ -430,5 +432,11 @@ For many tasks, it can be critical to visually inspect the design. Remember to u * The use of flex layouts is encouraged for cases where elements are arranged in rows or columns with consistent spacing/positioning. Consider converting boards to flex layout when appropriate. +# The `penpotMgmt` Object + +This object (of type `PenpotMgmt`) serves to manage Penpot design projects and files. +It allows you to discover and navigate to different design files the user has access to. +You navigate to different design files only when your task explicitly requires it/you are instructed to do so. + -- You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it again. diff --git a/mcp/packages/server/src/tools/ExecuteCodeTool.ts b/mcp/packages/server/src/tools/ExecuteCodeTool.ts index 7f913a5700..7a8e710e33 100644 --- a/mcp/packages/server/src/tools/ExecuteCodeTool.ts +++ b/mcp/packages/server/src/tools/ExecuteCodeTool.ts @@ -49,10 +49,7 @@ export class ExecuteCodeTool extends Tool { "Executes JavaScript code in the Penpot plugin context.\n" + "IMPORTANT: Before using this tool, make sure you have read the 'Penpot High-Level Overview' and know " + "which Penpot API functionality is necessary and how to use it.\n" + - "You have access to `penpot` (the Penpot API, of type `Penpot`), `penpotMgmt`, `penpotUtils`, and `storage`.\n" + - "`penpotMgmt.workspace.status` is none, loading or ready. Use `await penpotMgmt.openFile(fileId, {teamId})` " + - "to open a file and wait until it is ready; teamId defaults to the current team. " + - "File operations require a ready workspace. Storage survives navigation; reacquire file and shape objects after changing files.\n" + + "You have access to `penpot` (of type `Penpot`), `penpotMgmt` (of type `PenpotMgmt`), `penpotUtils`, and `storage`.\n" + "`storage` is an object in which arbitrary data can be stored, simply by adding a new attribute; " + "stored attributes can be referenced in future calls to this tool, so any intermediate results that " + "could come in handy later should be stored in `storage` instead of just a fleeting variable; " + diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 8ff05e595b..6dabccbb2f 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -2,8 +2,7 @@ ### 🚀 Features -- **plugins-runtime**: Added manifest `scope: "global"` for plugins that survive workspace navigation and close on logout. -- **plugin-types**: Added `penpotMgmt` with workspace readiness, `workspacechange` events and `openFile()`, available to global plugins with `content:read` permission. +- **plugins-runtime, plugin-types**: Added global plugins (`scope: "global"`) that remain active across dashboard and workspace navigation until logout, with `penpotMgmt` for project and file discovery, file navigation and workspace events. - **plugin-types:** Added `paddingType` (`'simple' | 'multiple'`) to flex and grid layouts and `marginType` (`'simple' | 'multiple'`) to layout children, exposing whether the four padding/margin sides are mirrored or honoured independently. - **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished. It rejects when the optional timeout elapses, defaulting to 30 seconds so a wait never hangs. - **plugin-types**: Added `waitForLayoutUpdate` to the `Shape` interface to wait until the pending layout updates of a shape and its children have finished diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index 0ef43fdd4d..d1d145fe96 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -786,10 +786,74 @@ export interface WorkspaceContext { readonly teamId: string | null; } -/** Application management API available to global plugins. */ +/** Metadata snapshot of an accessible, non-deleted project. */ +export interface ProjectSummary { + readonly id: string; + readonly teamId: string; + readonly name: string; + /** Whether this is the team's Drafts project. */ + readonly isDefault: boolean; + readonly fileCount: number; +} + +/** Metadata snapshot of an accessible, non-deleted file. */ +export interface FileSummary { + readonly id: string; + readonly teamId: string; + readonly projectId: string; + readonly name: string; + /** Last modification time as an ISO 8601 string in UTC. */ + readonly modifiedAt: string; +} + +/** + * Application management API exposed as `penpotMgmt` to global plugins. + * + * - Requires `content:read` permission. + * - Available in the dashboard and file workspaces. + * - Use this API to discover and open files; file operations on `penpot` require a ready workspace. + * - Reacquire file, page and shape objects after changing files. + */ export interface PenpotMgmt { + /** Current workspace availability and metadata; usable for file operations when status is `ready`. */ readonly workspace: WorkspaceContext; - /** Opens a file in this tab and resolves once its workspace and page are ready. */ + /** + * Lists accessible, non-deleted projects as metadata snapshots. + * + * - `teamId` defaults to the current team. + * - Queries the backend without opening a file or changing navigation. + * - Rejects invalid identifiers and backend access errors. + * + * @example + * ```ts + * const projects = await penpotMgmt.listProjects(); + * ``` + */ + listProjects(options?: { teamId?: string }): Promise; + /** + * Lists accessible, non-deleted files in the specified project as metadata snapshots. + * + * - Requires an explicit `projectId`. + * - Queries the backend without opening a file or changing navigation. + * - Rejects invalid identifiers and backend access errors. + * + * @example + * ```ts + * const files = await penpotMgmt.listFiles({ projectId: project.id }); + * ``` + */ + listFiles(options: { projectId: string }): Promise; + /** + * Opens a file in this tab and resolves once its workspace and page are ready. + * + * - `teamId` defaults to the current team. + * - Rejects invalid identifiers, failed or superseded navigation, and waits exceeding 30 seconds. + * + * @example + * ```ts + * await penpotMgmt.openFile(file.id, { teamId: file.teamId }); + * ``` + */ openFile(fileId: string, options?: { teamId?: string }): Promise; /** Subscribes to workspace availability and metadata changes. */ on( @@ -803,7 +867,10 @@ export interface PenpotMgmt { * Represents the context of Penpot, providing access to various Penpot functionalities and data. */ export interface Context { - readonly management?: Pick; + readonly management?: Pick< + PenpotMgmt, + 'workspace' | 'openFile' | 'listProjects' | 'listFiles' + >; /** * Returns the current penpot version. */ diff --git a/plugins/libs/plugins-runtime/src/lib/api/index.ts b/plugins/libs/plugins-runtime/src/lib/api/index.ts index a0ef062d3e..ba36b1e12a 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/index.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/index.ts @@ -419,6 +419,14 @@ export function createApi( checkPermission('content:read', false); return plugin.context.management!.workspace; }, + listProjects(options) { + checkPermission('content:read', false); + return plugin.context.management!.listProjects(options); + }, + listFiles(options) { + checkPermission('content:read', false); + return plugin.context.management!.listFiles(options); + }, openFile(fileId, options) { checkPermission('content:read', false); return plugin.context.management!.openFile(fileId, options); diff --git a/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts b/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts index 308fb81245..3513b97acd 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts @@ -54,6 +54,60 @@ describe('Plugin api', () => { vi.clearAllMocks(); }); + describe('management listing', () => { + it.each(['none', 'loading', 'ready'])( + 'lists metadata with workspace status %s', + async (status) => { + const projects = [{ id: 'project-id' }]; + const files = [{ id: 'file-id' }]; + const management = { + workspace: { status }, + listProjects: vi.fn().mockResolvedValue(projects), + listFiles: vi.fn().mockResolvedValue(files), + }; + const { penpotMgmt } = createApi({ + ...pluginManager, + manifest: { ...pluginManager.manifest, scope: 'global' }, + context: { ...pluginManager.context, management }, + } as any); + + await expect(penpotMgmt.listProjects()).resolves.toEqual(projects); + expect(management.listProjects).toHaveBeenLastCalledWith(undefined); + await penpotMgmt.listProjects({ teamId: 'team-id' }); + expect(management.listProjects).toHaveBeenLastCalledWith({ + teamId: 'team-id', + }); + await expect( + penpotMgmt.listFiles({ projectId: 'project-id' }), + ).resolves.toEqual(files); + expect(management.listFiles).toHaveBeenCalledWith({ + projectId: 'project-id', + }); + }, + ); + + it('requires content:read before querying metadata', () => { + const management = { + listProjects: vi.fn(), + listFiles: vi.fn(), + }; + const { penpotMgmt } = createApi({ + ...pluginManager, + manifest: { + ...pluginManager.manifest, + permissions: [], + scope: 'global', + }, + context: { ...pluginManager.context, management }, + } as any); + + expect(() => penpotMgmt.listProjects()).toThrow(); + expect(() => penpotMgmt.listFiles({ projectId: 'project-id' })).toThrow(); + expect(management.listProjects).not.toHaveBeenCalled(); + expect(management.listFiles).not.toHaveBeenCalled(); + }); + }); + describe('ui', () => { describe.concurrent('permissions', () => { const api = createApi({