diff --git a/frontend/src/app/plugins/management.cljs b/frontend/src/app/plugins/management.cljs index 9dadac543b..0337da56ba 100644 --- a/frontend/src/app/plugins/management.cljs +++ b/frontend/src/app/plugins/management.cljs @@ -6,13 +6,34 @@ (ns app.plugins.management (:require + [app.common.features :as cfeat] [app.common.uuid :as uuid] + [app.main.data.dashboard :as dd] [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] - [beicon.v2.core :as rx])) + [beicon.v2.core :as rx] + [clojure.set :as set] + [cuerdas.core :as str] + [potok.v2.core :as ptk])) + +(defn- project-summary + [project] + #js {:id (str (:id project)) + :teamId (str (:team-id project)) + :name (:name project) + :isDefault (boolean (:is-default project)) + :fileCount (or (:count project) 0)}) + +(defn- file-summary + [file] + #js {:id (str (:id file)) + :teamId (str (:team-id file)) + :projectId (str (:project-id file)) + :name (:name file) + :modifiedAt (.toISOString ^js (:modified-at file))}) (defn workspace-context [state] @@ -83,14 +104,7 @@ (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))))) + (into-array (map project-summary (remove :deleted-at projects))))) (rx/subs! resolve reject))))))) (defn list-files @@ -102,16 +116,68 @@ (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)))) + (into-array (map file-summary files)))) (rx/subs! resolve reject))))))) +(defn- creation-name + [options] + (let [name (obj/get options "name")] + (when (string? name) + (let [name (str/trim name)] + (when (<= 1 (count name) 250) + name))))) + +(defn create-project + [options] + (let [team-id (if-let [id (obj/get options "teamId")] + (uuid/parse* id) + (:current-team-id @st/state)) + name (creation-name options)] + (cond + (nil? team-id) + (js/Promise.reject (js/Error. "Expected a team UUID")) + + (nil? name) + (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")) + + :else + (js/Promise. + (fn [resolve reject] + (->> (rp/cmd! :create-project {:team-id team-id :name name}) + (rx/tap (fn [project] + (st/emit! + (fn [state] + (if (= (:team-id project) (:current-team-id state)) + (assoc-in state [:projects (:id project)] (assoc project :count 0)) + state))))) + (rx/map project-summary) + (rx/subs! resolve reject))))))) + +(defn create-file + [options] + (let [project-id (uuid/parse* (obj/get options "projectId")) + name (creation-name options)] + (cond + (nil? project-id) + (js/Promise.reject (js/Error. "Expected a project UUID")) + + (nil? name) + (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")) + + :else + (js/Promise. + (fn [resolve reject] + (let [features (set/difference (:features @st/state #{}) cfeat/frontend-only-features)] + (->> (rp/cmd! :create-file {:project-id project-id :name name :features features}) + (rx/tap (fn [file] + (st/emit! + (fn [state] + (if (= (:team-id file) (:current-team-id state)) + (ptk/update (dd/file-created file) state) + state))))) + (rx/map file-summary) + (rx/subs! resolve reject)))))))) + (defn create-context [] (obj/reify {:name "PenpotManagementContext"} @@ -119,4 +185,6 @@ {:get (fn [] (clj->js (workspace-context @st/state)))} :openFile open-file :listProjects list-projects - :listFiles list-files)) + :listFiles list-files + :createProject create-project + :createFile create-file)) diff --git a/frontend/test/frontend_tests/plugins/management_test.cljs b/frontend/test/frontend_tests/plugins/management_test.cljs index 9db0ae80c0..eb74e0b09d 100644 --- a/frontend/test/frontend_tests/plugins/management_test.cljs +++ b/frontend/test/frontend_tests/plugins/management_test.cljs @@ -152,3 +152,129 @@ (mock/with-mocks* {rp/cmd! (mock/stub (fn [_ _] (rx/throw error)))} (t/is (identical? error (await (.then (list-items) (fn [] nil) identity))))))))) + +(t/deftest ^:async create-project-returns-metadata-and-updates-only-the-current-team + (let [project-id (uuid/next) + other-team-id (uuid/next)] + (doseq [[initial options target-team-id] + [[dashboard #js {:name " Buttons "} team-id] + [ready #js {:name " Buttons " :teamId (str other-team-id)} other-team-id]]] + (let [state (atom initial) + requests (atom []) + project {:id project-id :team-id target-team-id :name "Buttons" + :is-default false :is-pinned false}] + (await + (mock/with-mocks* + {st/state state + st/emit! (mock/stub (fn [event] (swap! state event))) + rp/cmd! (mock/stub + (fn [command params] + (swap! requests conj [command params]) + (->> (rx/of project) (rx/observe-on :async))))} + (let [context (management/create-context) + result (await (.createProject context options))] + (t/is (= {"id" (str project-id) "teamId" (str target-team-id) + "name" "Buttons" "isDefault" false "fileCount" 0} + (js->clj result))) + (t/is (= [[:create-project {:team-id target-team-id :name "Buttons"}]] @requests)) + (t/is (= (if (= team-id target-team-id) + (assoc-in initial [:projects project-id] (assoc project :count 0)) + initial) + @state))))))))) + +(t/deftest ^:async create-file-returns-metadata-and-updates-the-dashboard-without-navigation + (let [project-id (uuid/next) + created-id (uuid/next) + modified-at (js/Date. "2026-01-02T03:04:05Z")] + (doseq [initial [dashboard ready]] + (let [initial (assoc initial + :features #{"components/v2" "styles/v2" "plugins/runtime"} + :projects {project-id {:id project-id :team-id team-id :count 2}}) + state (atom initial) + requests (atom []) + file {:id created-id :team-id team-id :project-id project-id + :name "Login" :modified-at modified-at :data {:pages []}}] + (await + (mock/with-mocks* + {st/state state + st/emit! (mock/stub (fn [event] (swap! state event))) + rp/cmd! (mock/stub + (fn [command params] + (swap! requests conj [command params]) + (->> (rx/of file) (rx/observe-on :async))))} + (let [context (management/create-context) + result (await (.createFile context #js {:projectId (str project-id) :name " Login " + :id "ignored" :isShared true}))] + (t/is (= {"id" (str created-id) "teamId" (str team-id) + "projectId" (str project-id) "name" "Login" + "modifiedAt" "2026-01-02T03:04:05.000Z"} + (js->clj result))) + (t/is (= [[:create-file {:project-id project-id :name "Login" :features #{"components/v2"}}]] + @requests)) + (t/is (= (-> initial + (assoc-in [:files created-id] (dissoc file :data)) + (assoc-in [:recent-files created-id] (dissoc file :data)) + (update-in [:projects project-id :count] inc)) + @state))))))))) + +(t/deftest ^:async creation-rejects-invalid-input-before-querying + (let [project-id (uuid/next) + requests (atom [])] + (await + (mock/with-mocks* + {st/state (atom dashboard) + rp/cmd! (mock/stub (fn [command params] + (swap! requests conj [command params]) + (->> (rx/of nil) (rx/observe-on :async))))} + (let [context (management/create-context)] + (doseq [options [nil #js {} #js {:name nil} #js {:name 1} + #js {:name ""} #js {:name " \n "} + #js {:name (apply str (repeat 251 "a"))}]] + (t/is (= "Expected a name with 1 to 250 characters" + (await (.then (.createProject context options) (fn [] nil) #(.-message %))))) + (let [options (js/Object.assign #js {:projectId (str project-id)} options)] + (t/is (= "Expected a name with 1 to 250 characters" + (await (.then (.createFile context options) (fn [] nil) #(.-message %))))))) + (doseq [[create options message] + [[#(.createProject context %) #js {:name "Buttons" :teamId "invalid"} "Expected a team UUID"] + [#(.createFile context %) #js {:name "Login"} "Expected a project UUID"] + [#(.createFile context %) #js {:name "Login" :projectId "invalid"} "Expected a project UUID"]]] + (t/is (= message (await (.then (create options) (fn [] nil) #(.-message %))))))) + (await (a/settle)) + (t/is (empty? @requests)))))) + +(t/deftest ^:async creation-preserves-backend-errors-and-state + (doseq [method ["createProject" "createFile"]] + (let [state (atom dashboard) + error (js/Error. "Access denied")] + (await + (mock/with-mocks* + {st/state state + st/emit! (mock/stub (fn [event] (swap! state event))) + rp/cmd! (mock/stub (fn [_ _] (->> (rx/throw error) (rx/observe-on :async))))} + (let [context (management/create-context) + options #js {:name "Design" :projectId (str (uuid/next))} + result (.call (aget context method) context options)] + (t/is (identical? error (await (.then result (fn [] nil) identity)))) + (t/is (= dashboard @state)))))))) + +(t/deftest ^:async creation-does-not-add-results-to-a-different-team-after-navigation + (doseq [method ["createProject" "createFile"]] + (let [state (atom dashboard) + response (rx/subject) + other (assoc dashboard :current-team-id (uuid/next)) + item-id (uuid/next) + item {:id item-id :team-id team-id :project-id (uuid/next) + :name "Design" :modified-at (js/Date.)}] + (await + (mock/with-mocks* + {st/state state + st/emit! (mock/stub (fn [event] (swap! state event))) + rp/cmd! (mock/stub (fn [_ _] (->> response (rx/observe-on :async))))} + (let [context (management/create-context) + result (.call (aget context method) context + #js {:name "Design" :projectId (str (:project-id item))})] + (reset! state other) + (rx/push! response item) + (t/is (= (str item-id) (.-id (await result)))) + (t/is (= other @state)))))))) diff --git a/mcp/packages/server/data/api_types.yml b/mcp/packages/server/data/api_types.yml index c926147938..1fbccd549f 100644 --- a/mcp/packages/server/data/api_types.yml +++ b/mcp/packages/server/data/api_types.yml @@ -5128,9 +5128,9 @@ PenpotMgmt: Application management API exposed as `penpotMgmt` to global plugins. - * Requires `content:read` permission. + * Discovery and navigation require `content:read`; creation requires `content:write`. * Available in the dashboard and file workspaces. - * Use this API to discover and open files; file operations on `penpot` require a ready workspace. + * Use this API to discover, create and open files; file operations on `penpot` require a ready workspace. * Reacquire file, page and shape objects after changing files. ``` @@ -5138,6 +5138,8 @@ PenpotMgmt: workspace: WorkspaceContext; listProjects(options?: { teamId?: string }): Promise; listFiles(options: { projectId: string }): Promise; + createProject(options: { name: string; teamId?: string }): Promise; + createFile(options: { name: string; projectId: string }): Promise; openFile(fileId: string, options?: { teamId?: string }): Promise; on( type: "workspacechange", @@ -5199,6 +5201,51 @@ PenpotMgmt: ``` const files = await penpotMgmt.listFiles({ projectId: project.id }); ``` + createProject: |- + ``` + createProject(options: { name: string; teamId?: string }): Promise + ``` + + Creates a project and returns its metadata snapshot. Requires `content:write`. + + * `teamId` defaults to the current team. + * Trims `name` and requires 1 to 250 characters. + * Does not change navigation or open a file. + * Rejects invalid input, backend access errors and quota errors. + + Parameters + + * options: { name: string; teamId?: string } + + Returns Promise + + Example + ``` + const project = await penpotMgmt.createProject({ name: 'Design system' }); + ``` + createFile: |- + ``` + createFile(options: { name: string; projectId: string }): Promise + ``` + + Creates a file with an initial page and returns its metadata snapshot. Requires `content:write`. + + * Requires an explicit `projectId`. + * Trims `name` and requires 1 to 250 characters. + * Does not open the file or change navigation. Use `openFile` to edit it. + * Rejects invalid input, backend access errors and quota errors. + + Parameters + + * options: { name: string; projectId: string } + + Returns Promise + + Example + ``` + const file = await penpotMgmt.createFile({ projectId: project.id, name: 'Login' }); + await penpotMgmt.openFile(file.id, { teamId: file.teamId }); + ``` openFile: |- ``` openFile(fileId: string, options?: { teamId?: string }): Promise @@ -5260,7 +5307,9 @@ Context: "workspace" | "openFile" | "listProjects" - | "listFiles", + | "listFiles" + | "createProject" + | "createFile", >; version: string; root: Shape | null; @@ -5340,7 +5389,9 @@ Context: "workspace" | "openFile" | "listProjects" - | "listFiles", + | "listFiles" + | "createProject" + | "createFile", > ``` version: |- diff --git a/mcp/packages/server/data/initial_instructions.md b/mcp/packages/server/data/initial_instructions.md index ea5961c605..6fd27c2e81 100644 --- a/mcp/packages/server/data/initial_instructions.md +++ b/mcp/packages/server/data/initial_instructions.md @@ -435,7 +435,11 @@ For many tasks, it can be critical to visually inspect the design. Remember to u # 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. +It allows you to discover, create and navigate to design projects and files the user has access to. +Create a project with `await penpotMgmt.createProject({ name: "Design system" })` (optionally specify `teamId`). +Create a file with `await penpotMgmt.createFile({ projectId: project.id, name: "Login" })`. +Both creation methods require `content:write` and return metadata without changing the current view. +To edit the new file, call `await penpotMgmt.openFile(file.id, { teamId: file.teamId })` and reacquire file, page and shape objects. You navigate to different design files only when your task explicitly requires it/you are instructed to do so. -- diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 433f43e8c2..ab324f891c 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -2,6 +2,7 @@ ### 🚀 Features +- **plugin-types, plugins-runtime:** Added `penpotMgmt.createProject` and `penpotMgmt.createFile` to create projects and files from the dashboard or workspace with `content:write` permission. - **plugins-runtime, plugin-types**: Added global plugins (`scope: "global"`) that remain active across dashboard and workspace navigation until closed or logged out, 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. diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index d1d145fe96..f375f42cd3 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -809,9 +809,9 @@ export interface FileSummary { /** * Application management API exposed as `penpotMgmt` to global plugins. * - * - Requires `content:read` permission. + * - Discovery and navigation require `content:read`; creation requires `content:write`. * - Available in the dashboard and file workspaces. - * - Use this API to discover and open files; file operations on `penpot` require a ready workspace. + * - Use this API to discover, create and open files; file operations on `penpot` require a ready workspace. * - Reacquire file, page and shape objects after changing files. */ export interface PenpotMgmt { @@ -843,6 +843,41 @@ export interface PenpotMgmt { * ``` */ listFiles(options: { projectId: string }): Promise; + /** + * Creates a project and returns its metadata snapshot. Requires `content:write`. + * + * - `teamId` defaults to the current team. + * - Trims `name` and requires 1 to 250 characters. + * - Does not change navigation or open a file. + * - Rejects invalid input, backend access errors and quota errors. + * + * @example + * ```ts + * const project = await penpotMgmt.createProject({ name: 'Design system' }); + * ``` + */ + createProject(options: { + name: string; + teamId?: string; + }): Promise; + /** + * Creates a file with an initial page and returns its metadata snapshot. Requires `content:write`. + * + * - Requires an explicit `projectId`. + * - Trims `name` and requires 1 to 250 characters. + * - Does not open the file or change navigation. Use `openFile` to edit it. + * - Rejects invalid input, backend access errors and quota errors. + * + * @example + * ```ts + * const file = await penpotMgmt.createFile({ projectId: project.id, name: 'Login' }); + * await penpotMgmt.openFile(file.id, { teamId: file.teamId }); + * ``` + */ + createFile(options: { + name: string; + projectId: string; + }): Promise; /** * Opens a file in this tab and resolves once its workspace and page are ready. * @@ -869,7 +904,12 @@ export interface PenpotMgmt { export interface Context { readonly management?: Pick< PenpotMgmt, - 'workspace' | 'openFile' | 'listProjects' | 'listFiles' + | 'workspace' + | 'openFile' + | 'listProjects' + | 'listFiles' + | 'createProject' + | 'createFile' >; /** * 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 ba36b1e12a..44f5645cc5 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/index.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/index.ts @@ -427,6 +427,14 @@ export function createApi( checkPermission('content:read', false); return plugin.context.management!.listFiles(options); }, + createProject(options) { + checkPermission('content:write', false); + return plugin.context.management!.createProject(options); + }, + createFile(options) { + checkPermission('content:write', false); + return plugin.context.management!.createFile(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 3513b97acd..e53eddacc3 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 @@ -108,6 +108,94 @@ describe('Plugin api', () => { }); }); + describe('management creation', () => { + it.each(['none', 'loading', 'ready'])( + 'creates projects and files with workspace status %s', + async (status) => { + const project = { id: 'project-id', name: 'Buttons' }; + const file = { id: 'file-id', name: 'Login' }; + const management = { + workspace: { status }, + createProject: vi.fn().mockResolvedValue(project), + createFile: vi.fn().mockResolvedValue(file), + }; + const { penpotMgmt } = createApi({ + ...pluginManager, + manifest: { + ...pluginManager.manifest, + permissions: ['content:write'], + scope: 'global', + }, + context: { ...pluginManager.context, management }, + } as any); + + await expect( + penpotMgmt.createProject({ name: 'Buttons' }), + ).resolves.toEqual(project); + await expect( + penpotMgmt.createProject({ name: 'Buttons', teamId: 'team-id' }), + ).resolves.toEqual(project); + expect(management.createProject).toHaveBeenLastCalledWith({ + name: 'Buttons', + teamId: 'team-id', + }); + await expect( + penpotMgmt.createFile({ name: 'Login', projectId: 'project-id' }), + ).resolves.toEqual(file); + expect(management.createFile).toHaveBeenCalledWith({ + name: 'Login', + projectId: 'project-id', + }); + }, + ); + + it.each([{ permissions: [] }, { permissions: ['content:read'] }])( + 'requires content:write before creating with permissions $permissions', + ({ permissions }) => { + const management = { + createProject: vi.fn(), + createFile: vi.fn(), + }; + const { penpotMgmt } = createApi({ + ...pluginManager, + manifest: { ...pluginManager.manifest, permissions, scope: 'global' }, + context: { ...pluginManager.context, management }, + } as any); + + expect(() => penpotMgmt.createProject({ name: 'Buttons' })).toThrow( + 'Permission content:write is not granted', + ); + expect(() => + penpotMgmt.createFile({ name: 'Login', projectId: 'project-id' }), + ).toThrow('Permission content:write is not granted'); + expect(management.createProject).not.toHaveBeenCalled(); + expect(management.createFile).not.toHaveBeenCalled(); + }, + ); + + it('preserves backend creation errors', async () => { + const error = new Error('Access denied'); + const { penpotMgmt } = createApi({ + ...pluginManager, + manifest: { ...pluginManager.manifest, scope: 'global' }, + context: { + ...pluginManager.context, + management: { + createProject: vi.fn().mockRejectedValue(error), + createFile: vi.fn().mockRejectedValue(error), + }, + }, + } as any); + + await expect(penpotMgmt.createProject({ name: 'Buttons' })).rejects.toBe( + error, + ); + await expect( + penpotMgmt.createFile({ name: 'Login', projectId: 'project-id' }), + ).rejects.toBe(error); + }); + }); + describe('ui', () => { describe.concurrent('permissions', () => { const api = createApi({