mirror of
https://github.com/twentyhq/twenty.git
synced 2026-06-11 01:16:55 -04:00
@martmull v2.0 ;) --------- Co-authored-by: martmull <martmull@hotmail.fr> Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
179 lines
4.6 KiB
JavaScript
179 lines
4.6 KiB
JavaScript
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const PLUGIN_ROOT = path.resolve(__dirname, '..', '..');
|
|
const REPO_ROOT = path.resolve(PLUGIN_ROOT, '..', '..');
|
|
|
|
const PUBLIC_DOCS_MCP_SERVER_NAME = 'twenty-docs';
|
|
const PUBLIC_DOCS_MCP_URL = 'https://docs.twenty.com/mcp';
|
|
const LEGACY_SKILL_NAMES = [
|
|
'app-readme-and-visuals',
|
|
'build-app-features',
|
|
'create-an-app',
|
|
'design-front-components',
|
|
'retrieve-and-present-data',
|
|
'setup-mcp',
|
|
];
|
|
|
|
const VALID_CAPABILITIES = new Set(['Interactive', 'Read', 'Write']);
|
|
const VALID_CATEGORIES = new Set(['Coding', 'Productivity', 'Communication', 'Data', 'Design', 'Marketing', 'Sales']);
|
|
const SHORT_DESCRIPTION_MAX = 64;
|
|
const MIN_LOGO_DIMENSION = 256;
|
|
|
|
const readText = (filePath) => fs.readFileSync(filePath, 'utf8');
|
|
|
|
const listFiles = (directory) => {
|
|
const entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
const files = [];
|
|
|
|
for (const entry of entries) {
|
|
const absolutePath = path.join(directory, entry.name);
|
|
|
|
if (entry.isDirectory()) {
|
|
files.push(...listFiles(absolutePath));
|
|
} else {
|
|
files.push(absolutePath);
|
|
}
|
|
}
|
|
|
|
return files;
|
|
};
|
|
|
|
const parseSkillFrontmatter = (skillPath) => {
|
|
const contents = readText(skillPath);
|
|
const match = contents.match(/^---\n([\s\S]*?)\n---\n/);
|
|
|
|
if (!match) {
|
|
return undefined;
|
|
}
|
|
|
|
const frontmatter = {};
|
|
|
|
for (const line of match[1].split('\n')) {
|
|
const fieldMatch = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/);
|
|
|
|
if (fieldMatch) {
|
|
frontmatter[fieldMatch[1]] = fieldMatch[2].replace(/^["']|["']$/g, '');
|
|
}
|
|
}
|
|
|
|
return frontmatter;
|
|
};
|
|
|
|
const parseQuotedYamlField = (contents, fieldName) => {
|
|
const match = contents.match(new RegExp(`^\\s+${fieldName}:\\s+"([^"]+)"\\s*$`, 'm'));
|
|
|
|
return match?.[1];
|
|
};
|
|
|
|
const isAllowedDocumentationHost = (hostname) => {
|
|
const host = hostname.toLowerCase();
|
|
|
|
return (
|
|
host === 'localhost' ||
|
|
host.endsWith('.localhost') ||
|
|
host.startsWith('127.') ||
|
|
host === '[::1]' ||
|
|
host === 'example.com' ||
|
|
host.endsWith('.example.com') ||
|
|
host === 'example.twenty.com' ||
|
|
host === 'myworkspace.twenty.com' ||
|
|
host === 'myworkspace.customdomain.com' ||
|
|
host === 'your-twenty-server.com' ||
|
|
host === 'app.twenty.com' ||
|
|
host === 'twenty.com' ||
|
|
host === 'docs.twenty.com' ||
|
|
host === 'www.docker.com' ||
|
|
host === 'github.com' ||
|
|
host === 'www.w3.org' ||
|
|
host === 'developers.openai.com' ||
|
|
host === 'keepachangelog.com' ||
|
|
host === 'semver.org' ||
|
|
host.endsWith('.example')
|
|
);
|
|
};
|
|
|
|
const readPngDimensions = (filePath) => {
|
|
const buffer = fs.readFileSync(filePath);
|
|
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
|
|
if (buffer.length < 24 || !buffer.subarray(0, 8).equals(signature)) {
|
|
return undefined;
|
|
}
|
|
|
|
if (buffer.subarray(12, 16).toString('ascii') !== 'IHDR') {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
width: buffer.readUInt32BE(16),
|
|
height: buffer.readUInt32BE(20),
|
|
};
|
|
};
|
|
|
|
const createJsonReaders = (fail) => {
|
|
const readJson = (relativePath) => {
|
|
const absolutePath = path.join(REPO_ROOT, relativePath);
|
|
|
|
try {
|
|
return JSON.parse(readText(absolutePath));
|
|
} catch (error) {
|
|
fail(`${relativePath} is not valid JSON: ${error.message}`);
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
const readOptionalJson = (relativePath) => {
|
|
const absolutePath = path.join(REPO_ROOT, relativePath);
|
|
|
|
if (!fs.existsSync(absolutePath)) {
|
|
return undefined;
|
|
}
|
|
|
|
return readJson(relativePath);
|
|
};
|
|
|
|
return { readJson, readOptionalJson };
|
|
};
|
|
|
|
const createInterfacePathResolver = (fail) => (relativePath) => {
|
|
if (typeof relativePath !== 'string' || relativePath.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
if (!relativePath.startsWith('./')) {
|
|
fail(`interface path must start with ./ (got: ${relativePath})`);
|
|
return undefined;
|
|
}
|
|
|
|
const resolvedPath = path.resolve(PLUGIN_ROOT, relativePath.slice(2));
|
|
|
|
// Reject ../ traversal that escapes the plugin directory after normalization
|
|
if (resolvedPath !== PLUGIN_ROOT && !resolvedPath.startsWith(PLUGIN_ROOT + path.sep)) {
|
|
fail(`interface path must stay within the plugin directory (got: ${relativePath})`);
|
|
return undefined;
|
|
}
|
|
|
|
return resolvedPath;
|
|
};
|
|
|
|
module.exports = {
|
|
PLUGIN_ROOT,
|
|
REPO_ROOT,
|
|
PUBLIC_DOCS_MCP_SERVER_NAME,
|
|
PUBLIC_DOCS_MCP_URL,
|
|
LEGACY_SKILL_NAMES,
|
|
VALID_CAPABILITIES,
|
|
VALID_CATEGORIES,
|
|
SHORT_DESCRIPTION_MAX,
|
|
MIN_LOGO_DIMENSION,
|
|
readText,
|
|
listFiles,
|
|
parseSkillFrontmatter,
|
|
parseQuotedYamlField,
|
|
isAllowedDocumentationHost,
|
|
readPngDimensions,
|
|
createJsonReaders,
|
|
createInterfacePathResolver,
|
|
};
|