scripts/build-openai-plugin.ts
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bun
/**
* Validate and package the Capgo plugin as a submission ZIP. The plugin lives at the repo root
* (plugin.json, mcp.json, skills/, assets/plugin/) so Open Plugins scanners detect it.
* Limits come from https://developers.openai.com/plugins/deploy/submission.
*
* bun scripts/build-openai-plugin.ts
*/
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
import { dirname, join, relative, resolve, sep } from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
export const PLUGIN_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..')
export const PLUGIN_ASSETS_DIR = 'assets/plugin'
/** Only these entries are packaged; the rest of the repo stays out of the ZIP. */
export const PACKAGE_ENTRIES = ['plugin.json', 'mcp.json', 'skills', PLUGIN_ASSETS_DIR]
const MAX_ASSET_BYTES = 5 * 1024 * 1024
const HEX_COLOR = /^#[0-9a-f]{6}$/i
type Json = Record<string, any>
function checkLength(errors: string[], field: string, value: unknown, max: number, required = true) {
if (value === undefined || value === null || value === '') {
if (required)
errors.push(`${field} is required`)
return
}
if (typeof value !== 'string')
errors.push(`${field} must be a string`)
else if (value.length > max)
errors.push(`${field} is ${value.length} characters, max ${max}`)
}
function isHttpsUrl(value: unknown): boolean {
if (typeof value !== 'string')
return false
try {
const url = new URL(value)
// OpenAI rejects listing URLs with embedded credentials.
return url.protocol === 'https:' && url.hostname.length > 0 && !url.username && !url.password
}
catch {
return false
}
}
function checkUrl(errors: string[], field: string, value: unknown) {
checkLength(errors, field, value, 1024)
if (typeof value === 'string' && !isHttpsUrl(value))
errors.push(`${field} must be an https URL`)
}
function pngSize(file: Uint8Array): { width: number, height: number } | null {
if (file.length < 24 || new TextDecoder().decode(file.subarray(1, 4)) !== 'PNG')
return null
const view = new DataView(file.buffer, file.byteOffset, file.byteLength)
return { width: view.getUint32(16), height: view.getUint32(20) }
}
function checkAsset(errors: string[], dir: string, field: string, value: unknown, square: boolean) {
if (typeof value !== 'string' || !value.startsWith('./')) {
errors.push(`${field} must be a ./-prefixed path`)
return
}
// Resolve first so ./assets/../README.md or hidden files cannot slip past the packaged set.
const path = resolve(dir, value)
const packaged = path.startsWith(resolve(dir, PLUGIN_ASSETS_DIR) + sep) && !relative(dir, path).split(sep).some(part => part.startsWith('.'))
if (!packaged) {
errors.push(`${field} must point to a non-hidden file under ./${PLUGIN_ASSETS_DIR}/ so it is packaged`)
return
}
if (!existsSync(path)) {
errors.push(`${field} points to missing file ${value}`)
return
}
const file = readFileSync(path)
if (file.length > MAX_ASSET_BYTES)
errors.push(`${field} is larger than 5 MiB`)
// OpenAI also accepts JPEG, WebP and SVG; this package sticks to PNG so sizes can be checked here.
const size = pngSize(file)
if (!size) {
errors.push(`${field} must be a valid PNG file`)
return
}
if (square && size.width !== size.height)
errors.push(`${field} must be square, got ${size.width}x${size.height}`)
if (Math.min(size.width, size.height) < 48 || Math.max(size.width, size.height) > 4096)
errors.push(`${field} must be between 48 and 4096 pixels, got ${size.width}x${size.height}`)
}
function checkSkills(errors: string[], dir: string) {
const skillsDir = join(dir, 'skills')
if (!existsSync(skillsDir))
return
for (const name of readdirSync(skillsDir)) {
const file = join(skillsDir, name, 'SKILL.md')
if (!existsSync(file)) {
errors.push(`skills/${name} has no SKILL.md`)
continue
}
const frontmatter = /^---\n([\s\S]*?)\n---\n/.exec(readFileSync(file, 'utf8'))?.[1] ?? ''
const skillName = /^name:\s*(\S.*)$/m.exec(frontmatter)?.[1]?.trim()
if (skillName !== name)
errors.push(`skills/${name}/SKILL.md frontmatter name must be "${name}"`)
if (!/^description:\s*\S/m.test(frontmatter))
errors.push(`skills/${name}/SKILL.md needs a description`)
}
}
export function validateOpenAiPlugin(dir = PLUGIN_DIR): string[] {
const errors: string[] = []
const manifest = JSON.parse(readFileSync(join(dir, 'plugin.json'), 'utf8')) as Json
if (typeof manifest.name !== 'string' || manifest.name.length > 64 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(manifest.name))
errors.push('name must be lowercase letters, numbers and single hyphens, max 64 characters')
if (typeof manifest.version !== 'string' || !/^\d+\.\d+\.\d+(?:-[\w.]+)?$/.test(manifest.version))
errors.push('version must be semver')
checkLength(errors, 'description', manifest.description, 4000)
checkLength(errors, 'author.name', manifest.author?.name, 120)
if (manifest.author?.url !== undefined && !isHttpsUrl(manifest.author.url))
errors.push('author.url must be an https URL')
const ui = manifest.extensions?.['com.openai']?.interface as Json | undefined
if (!ui) {
errors.push('extensions["com.openai"].interface is required')
return errors
}
for (const field of ['displayName', 'shortDescription']) {
checkLength(errors, field, ui[field], 30)
if (typeof ui[field] === 'string' && (!ui[field].trim() || /[\r\n]/.test(ui[field])))
errors.push(`${field} must be a single non-blank line`)
}
checkLength(errors, 'longDescription', ui.longDescription, 4000)
checkLength(errors, 'developerName', ui.developerName, 80)
checkLength(errors, 'category', ui.category, 80)
for (const field of ['websiteURL', 'supportURL', 'privacyPolicyURL', 'termsOfServiceURL'])
checkUrl(errors, field, ui[field])
const capabilities = ui.capabilities ?? []
if (!Array.isArray(capabilities) || capabilities.length > 20 || capabilities.some((entry: unknown) => typeof entry !== 'string' || entry.length > 120))
errors.push('capabilities must be at most 20 strings of 120 characters')
const prompts = typeof ui.defaultPrompt === 'string' ? [ui.defaultPrompt] : ui.defaultPrompt ?? []
if (!Array.isArray(prompts) || prompts.length > 3 || prompts.some((entry: unknown) => typeof entry !== 'string' || entry.length > 128 || entry.includes('@')))
errors.push('defaultPrompt must be at most 3 prompts of 128 characters without @mentions')
for (const field of ['brandColor', 'brandColorDark']) {
if (ui[field] !== undefined && !HEX_COLOR.test(ui[field]))
errors.push(`${field} must be #RRGGBB`)
}
checkAsset(errors, dir, 'logo', ui.logo, true)
checkAsset(errors, dir, 'composerIcon', ui.composerIcon, true)
for (const field of ['logoDark', 'composerIconDark']) {
if (ui[field] !== undefined)
checkAsset(errors, dir, field, ui[field], true)
}
const screenshots = ui.screenshots ?? []
if (Array.isArray(screenshots)) {
for (const [index, screenshot] of screenshots.entries())
checkAsset(errors, dir, `screenshots[${index}]`, screenshot, false)
}
else {
errors.push('screenshots must be an array of paths')
}
const mcp = JSON.parse(readFileSync(join(dir, 'mcp.json'), 'utf8')) as Json
const servers = Object.entries(mcp.mcpServers ?? {}) as Array<[string, Json]>
if (servers.length === 0)
errors.push('mcp.json must declare an MCP server')
for (const [name, server] of servers) {
if (server.type !== 'streamable-http')
errors.push(`mcp.json ${name}: type must be streamable-http`)
if (!isHttpsUrl(server.url))
errors.push(`mcp.json ${name}: url must be an absolute https URL`)
}
checkSkills(errors, dir)
return errors
}
function listFiles(path: string): string[] {
if (!statSync(path).isDirectory())
return [path]
return readdirSync(path).filter(entry => !entry.startsWith('.')).flatMap(entry => listFiles(join(path, entry)))
}
async function main() {
const errors = validateOpenAiPlugin()
if (errors.length > 0) {
console.error(`Plugin package is invalid:\n- ${errors.join('\n- ')}`)
process.exit(1)
}
const { version } = JSON.parse(readFileSync(join(PLUGIN_DIR, 'plugin.json'), 'utf8')) as { version: string }
const outDir = resolve(PLUGIN_DIR, 'dist/openai-plugin')
const outFile = join(outDir, `capgo-${version}.zip`)
mkdirSync(outDir, { recursive: true })
rmSync(outFile, { force: true })
const files = PACKAGE_ENTRIES
.filter(entry => existsSync(join(PLUGIN_DIR, entry)))
.flatMap(entry => listFiles(join(PLUGIN_DIR, entry)))
.map(file => relative(PLUGIN_DIR, file))
const zip = spawnSync('zip', ['-X', '-q', outFile, ...files], { cwd: PLUGIN_DIR, stdio: 'inherit' })
if (zip.status !== 0)
process.exit(zip.status ?? 1)
console.log(`${outFile}\n${files.map(file => ` ${file}`).join('\n')}`)
}
if (import.meta.main)
await main()