Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s
217 lines
5.1 KiB
JavaScript
217 lines
5.1 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const PLUGIN_ROOT = path.resolve(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
'..',
|
|
'..',
|
|
);
|
|
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 CANONICAL_SKILL_NAMES = [
|
|
'create-app',
|
|
'develop-app',
|
|
'manage-app',
|
|
'publish-app',
|
|
'use-twenty-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);
|
|
|
|
// Generated bundles have their own validation and may be stale during edits.
|
|
if (absolutePath === path.join(PLUGIN_ROOT, 'dist')) {
|
|
continue;
|
|
}
|
|
|
|
if (entry.isDirectory()) {
|
|
files.push(...listFiles(absolutePath));
|
|
} else {
|
|
files.push(absolutePath);
|
|
}
|
|
}
|
|
|
|
// Sorted so validator output is stable regardless of readdir order.
|
|
return files.sort();
|
|
};
|
|
|
|
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 === 'skills.sh' ||
|
|
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;
|
|
};
|
|
|
|
export {
|
|
PLUGIN_ROOT,
|
|
REPO_ROOT,
|
|
PUBLIC_DOCS_MCP_SERVER_NAME,
|
|
PUBLIC_DOCS_MCP_URL,
|
|
CANONICAL_SKILL_NAMES,
|
|
LEGACY_SKILL_NAMES,
|
|
VALID_CAPABILITIES,
|
|
VALID_CATEGORIES,
|
|
SHORT_DESCRIPTION_MAX,
|
|
MIN_LOGO_DIMENSION,
|
|
readText,
|
|
listFiles,
|
|
parseSkillFrontmatter,
|
|
parseQuotedYamlField,
|
|
isAllowedDocumentationHost,
|
|
readPngDimensions,
|
|
createJsonReaders,
|
|
createInterfacePathResolver,
|
|
};
|