Ctrl K
ComunidadGuías…

scripts/build-openai-plugin.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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()