Ctrl K
ComunidadGuías…

app/scripts/api-reference.ts

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

Archivo original ↗Preguntar a KiBot
import { readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { z } from 'zod'
import { apiRoleRank, apiScopeDefinitions } from '../src/lib/api-key-schema'
import { publicApiOperations } from '../src/server/api/operation-contract'
import type { PublicApiOperation } from '../src/server/api/operation-contract'

type Schema = {
  type?: string | string[]
  properties?: Record<string, Schema>
  required?: string[]
  items?: Schema
  anyOf?: Schema[]
  oneOf?: Schema[]
  additionalProperties?: boolean | Schema
  enum?: unknown[]
  const?: unknown
  default?: unknown
  description?: string
  format?: string
  pattern?: string
  minimum?: number
  maximum?: number
  exclusiveMinimum?: number
  minLength?: number
  maxLength?: number
  minItems?: number
  maxItems?: number
}
const groups = [
  {
    slug: 'api-projects',
    title: 'Projects',
    match: /project|environment|variable|domain|alias|github/,
  },
  {
    slug: 'api-deployments',
    title: 'Deployments and logs',
    match: /deploy|rollback|runtime_logs/,
  },
  { slug: 'api-servers', title: 'Servers', match: /server/ },
  { slug: 'api-backups', title: 'Backups and jobs', match: /backup|job/ },
]
function groupFor(operation: PublicApiOperation) {
  return groups.find((group) => group.match.test(operation.id))
}
function title(operation: PublicApiOperation) {
  const text = operation.id.replaceAll('_', ' ').replace('github', 'GitHub')
  return text[0]!.toUpperCase() + text.slice(1)
}
function link(operation: PublicApiOperation) {
  return `/docs/${groupFor(operation)?.slug ?? 'api-operations'}/#${operation.id.replaceAll('_', '-')}`
}
const cell = (value: unknown) =>
  String(value).replaceAll('|', '\\|').replaceAll('\n', ' ')
const code = (value: unknown) => '`' + cell(value) + '`'
function type(schema: Schema): string {
  const variants = schema.anyOf ?? schema.oneOf
  if (variants) return [...new Set(variants.map(type))].join(' or ')
  if (Array.isArray(schema.type)) return schema.type.join(' or ')
  if (schema.type === 'array') return `${type(schema.items ?? {})}[]`
  return schema.type ?? 'value'
}
function notes(schema: Schema): string {
  const parts: string[] = []
  if (schema.description) parts.push(cell(schema.description))
  if ('default' in schema)
    parts.push(`Default: ${code(JSON.stringify(schema.default))}.`)
  if ('const' in schema)
    parts.push(`Exactly ${code(JSON.stringify(schema.const))}.`)
  if (schema.enum)
    parts.push(
      schema.enum.map((value) => code(JSON.stringify(value))).join(', ') + '.',
    )
  if (schema.format)
    parts.push(
      `${schema.format === 'date-time' ? 'UTC ISO 8601 timestamp' : cell(schema.format)}.`,
    )
  if (schema.pattern && !schema.format)
    parts.push(`Pattern: ${code(schema.pattern)}.`)
  for (const [minimum, maximum, unit] of [
    [schema.minLength, schema.maxLength, 'characters'],
    [schema.minItems, schema.maxItems, 'items'],
    [
      schema.minimum,
      schema.maximum === Number.MAX_SAFE_INTEGER ? undefined : schema.maximum,
      '',
    ],
  ] as const) {
    if (minimum !== undefined || maximum !== undefined)
      parts.push(
        `${minimum !== undefined ? `Min ${minimum}` : ''}${minimum !== undefined && maximum !== undefined ? '; ' : ''}${maximum !== undefined ? `max ${maximum}` : ''}${unit ? ` ${unit}` : ''}.`,
      )
  }
  if (schema.exclusiveMinimum !== undefined)
    parts.push(`Greater than ${schema.exclusiveMinimum}.`)
  if (schema.items && !schema.items.properties && !schema.items.oneOf) {
    const itemNotes = notes(schema.items)
    if (itemNotes !== '—') parts.push(`Each item: ${itemNotes}`)
  }
  if (schema.anyOf || schema.oneOf) {
    const alternatives = [
      ...new Set(
        (schema.anyOf ?? schema.oneOf)!
          .map(notes)
          .filter((note) => note !== '—'),
      ),
    ]
    if (alternatives.length) parts.push(alternatives.join(' or '))
  }
  return parts.filter(Boolean).join(' ') || '—'
}
function fields(schema: Schema, prefix = '', condition = ''): string[][] {
  const rows: string[][] = []
  for (const [name, property] of Object.entries(schema.properties ?? {})) {
    const field = prefix ? `${prefix}.${name}` : name
    rows.push([
      code(field),
      code(type(property)),
      (schema.required?.includes(name) ? 'Yes' : 'No') + condition,
      notes(property),
    ])
    rows.push(...fields(property, field, condition))
    if (property.items)
      rows.push(...fields(property.items, `${field}[]`, condition))
    if (
      property.additionalProperties &&
      typeof property.additionalProperties === 'object'
    )
      rows.push([
        code(`${field}.*`),
        code(type(property.additionalProperties)),
        'No',
        notes(property.additionalProperties),
      ])
  }
  for (const variant of schema.oneOf ?? schema.anyOf ?? []) {
    const discriminator = variant.properties?.type?.const
    rows.push(
      ...fields(
        variant,
        prefix,
        discriminator ? ` when type=${code(discriminator)}` : condition,
      ),
    )
  }
  return rows
}
function table(rows: string[][], firstColumn = 'Field') {
  return rows.length
    ? `| ${firstColumn} | Type | Required | Details |\n| --- | --- | --- | --- |\n` +
        rows.map((row) => `| ${row.join(' | ')} |`).join('\n') +
        '\n'
    : 'None.\n'
}
function role(operation: PublicApiOperation) {
  const scopeRole =
    apiScopeDefinitions[operation.scope as keyof typeof apiScopeDefinitions]
      .minimumRole
  return Math.max(apiRoleRank[scopeRole], apiRoleRank[operation.minimumRole]) >=
    apiRoleRank.admin
    ? 'Owner/admin'
    : 'Team member'
}
function endpoint(operation: PublicApiOperation) {
  const input = z.toJSONSchema(operation.input, { io: 'input' }) as Schema
  const pathNames = Object.keys(input.properties ?? {}).filter((name) =>
    operation.path.includes(`{${name}}`),
  )
  const body = {
    ...input,
    properties: Object.fromEntries(
      Object.entries(input.properties ?? {}).filter(
        ([name]) => !pathNames.includes(name) && name !== 'idempotencyKey',
      ),
    ),
  }
  return (
    `## ${title(operation)}\n\n${code(`${operation.method} /api/v1${operation.path}`)}\n\n${operation.description}\n\n**Permission:** ${code(operation.scope)} · ${role(operation)}${operation.requiresWholeTeam ? ' · Workspace-wide access required' : ''}.\n\n**MCP:** ${operation.mcp ? `[${code(operation.id)}](/docs/mcp-tools/#${operation.id})` : 'Not exposed'}.\n\n` +
    (pathNames.length
      ? '**Path parameters**\n\n' +
        table(
          fields({
            ...input,
            properties: Object.fromEntries(
              pathNames.map((name) => [name, input.properties![name]!]),
            ),
          }),
        ) +
        '\n'
      : '') +
    (operation.readOnly ? '**Query parameters**' : '**JSON body**') +
    '\n\n' +
    table(fields(body)) +
    '\n' +
    (!operation.readOnly
      ? `Send ${code('Idempotency-Key')} as a header, not a body field.\n\n`
      : '') +
    `**Response:** HTTP ${operation.status}.\n\n` +
    table(
      fields(z.toJSONSchema(operation.output, { io: 'output' }) as Schema),
    ) +
    '\n'
  )
}
function createApiReference() {
  const documents: Record<string, string> = {}
  let index =
    '---\ndescription: Every REST endpoint, required permission and corresponding MCP tool.\n---\n\n# API reference\n\nBase URL: **`https://app.devpu.sh/api/v1`**. For self-hosting, replace the host with your dashboard URL. Authenticate with a bearer API key; see the [API guide](/docs/api/) for setup, errors, pagination and retries.\n\nSelect an endpoint below for its inputs and response fields. Tables use `[]` for array items and `.*` for record keys. A nested field is required only when its parent object or variant is present. Unknown request fields are rejected.\n\nThe [OpenAPI JSON](https://app.devpu.sh/api/v1/openapi.json) is also available for scripts and code generation.\n\n'
  const tools =
    '---\ndescription: All MCP tool names, arguments, permissions and results.\n---\n\n# MCP tools\n\n[Connect your assistant](/docs/mcp/) and select its permissions first. Each tool below lists its arguments and links to the full input constraints and JSON result.\n\nArguments are one JSON object, combining REST path, query and body fields. Use `idempotencyKey` for writes. Argument tables show required fields, defaults and confirmation values. Deployment, backup and restore calls queue work: follow the returned resource/job ID to check success.\n\n'
  let toolIndex = '## Tool index\n\n'
  let toolDetails = ''
  for (const group of [
    ...groups,
    { slug: 'api-operations', title: 'Identity', match: /^get_identity$/ },
  ]) {
    const operations = publicApiOperations.filter((operation) =>
      group.slug === 'api-operations'
        ? !groupFor(operation)
        : groupFor(operation)?.slug === group.slug,
    )
    index +=
      `## ${group.title}\n\n| Endpoint | Permission | MCP tool |\n| --- | --- | --- |\n` +
      operations
        .map(
          (operation) =>
            `| [${code(`${operation.method} /api/v1${operation.path}`)}](${link(operation)}) | ${code(operation.scope)} | ${operation.mcp ? `[${code(operation.id)}](/docs/mcp-tools/#${operation.id})` : '—'} |`,
        )
        .join('\n') +
      '\n\n'
    if (group.slug !== 'api-operations')
      documents[`${group.slug}.md`] =
        `---\ndescription: ${group.title} endpoints, input fields and response shapes.\n---\n\n# ${group.title} API\n\nSee the [API guide](/docs/api/) for authentication and retries, or the [endpoint index](/docs/api-operations/) for other resources. All paths below are relative to your dashboard host.\n\n` +
        operations.map(endpoint).join('')
    else index += operations.map(endpoint).join('')
    const available = operations.filter((operation) => operation.mcp)
    if (!available.length) continue
    toolIndex +=
      `**${group.title}**\n\n| Tool | Purpose |\n| --- | --- |\n` +
      available
        .map(
          (operation) =>
            `| [${code(operation.id)}](#${operation.id}) | ${cell(operation.description.split('. ')[0]!.replace(/\.$/, ''))} |`,
        )
        .join('\n') +
      '\n\n'
    toolDetails += `## ${group.title}\n\n`
    for (const operation of available) {
      const input =
        operation.destructive && !('confirmation' in operation.input.shape)
          ? operation.input.extend({ confirmation: z.literal(operation.id) })
          : operation.input
      toolDetails +=
        `### ${operation.id}\n\n${operation.description}\n\n**Permission:** ${code(operation.scope)} · ${role(operation)}${operation.requiresWholeTeam ? ' · Workspace-wide access required' : ''}.\n\n` +
        table(
          Object.entries(input.shape).map(([name, schema]) => {
            const json = z.toJSONSchema(schema, { io: 'input' }) as Schema
            const details = {
              ...(json.description ? { description: json.description } : {}),
              ...('default' in json ? { default: json.default } : {}),
              ...('const' in json ? { const: json.const } : {}),
              ...(json.enum ? { enum: json.enum } : {}),
            }
            return [
              code(name),
              code(type(json)),
              schema.isOptional() ? 'No' : 'Yes',
              notes(details),
            ]
          }),
          'Argument',
        ) +
        `\n[Full inputs and result](${link(operation)}).\n\n`
    }
  }
  documents['api-operations.md'] = index
  documents['mcp-tools.md'] = tools + toolIndex + toolDetails
  return Object.fromEntries(
    Object.entries(documents).map(([name, content]) => [
      name,
      content.trimEnd() + '\n',
    ]),
  )
}

if (
  process.argv[1] &&
  resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
  const check = process.argv.includes('--check')
  for (const [name, content] of Object.entries(createApiReference())) {
    const path = new URL(`../../docs/${name}`, import.meta.url)
    if (check) {
      if (readFileSync(path, 'utf8') !== content)
        throw new Error(`Stale reference: ${name}. Run pnpm docs:api.`)
    } else writeFileSync(path, content)
  }
  console.log(
    check
      ? 'API/MCP references match the operation schemas.'
      : 'Generated API/MCP Markdown references.',
  )
}