mirror of
https://github.com/penpot/penpot.git
synced 2026-09-20 02:46:14 +00:00
371 lines
16 KiB
TypeScript
371 lines
16 KiB
TypeScript
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<ImportPenpotFileArgs> {
|
|
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 "<team-id>")\n' +
|
|
' :file-id (parse-uuid "<file-id>")\n' +
|
|
' :page-id (parse-uuid "<page-id>"))))';
|
|
|
|
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<ToolResponse> {
|
|
// 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<string> {
|
|
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<string> {
|
|
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<void> {
|
|
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<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
}
|