|
| 1 | +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' |
| 2 | +import { dirname, join } from 'node:path' |
| 3 | +import { fileURLToPath } from 'node:url' |
| 4 | + |
| 5 | +const scriptFile = fileURLToPath(import.meta.url) |
| 6 | +const scriptDir = dirname(scriptFile) |
| 7 | +const openapiPath = join(scriptDir, '../../../apps/fastify/openapi/openapi.json') |
| 8 | +const outputDir = join(scriptDir, '../src/gen') |
| 9 | +const outputPath = join(outputDir, 'commands.gen.ts') |
| 10 | + |
| 11 | +// Convert string to camelCase; strip {...} from path params for valid keys |
| 12 | +function toCamelCase(str) { |
| 13 | + const cleaned = str.replace(/^\{|\}$/g, '') |
| 14 | + return cleaned.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) |
| 15 | +} |
| 16 | + |
| 17 | +// camelCase to kebab-case for CLI |
| 18 | +function toKebabCase(str) { |
| 19 | + return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() |
| 20 | +} |
| 21 | + |
| 22 | +// Extract action key from operationId (e.g. accountApikeysCreate -> create) |
| 23 | +function toActionKey(operationId) { |
| 24 | + const match = operationId.match(/[A-Z][a-z]+$/) |
| 25 | + return match ? match[0].toLowerCase() : operationId |
| 26 | +} |
| 27 | + |
| 28 | +// Build nested object structure from path segments (same as core generate-wrapper) |
| 29 | +function buildNestedObject(obj, segments, operationId) { |
| 30 | + if (segments.length === 0) return operationId |
| 31 | + |
| 32 | + const [first, ...rest] = segments |
| 33 | + const key = toCamelCase(first) |
| 34 | + |
| 35 | + if (rest.length === 0) { |
| 36 | + const existing = obj[key] |
| 37 | + if (typeof existing === 'string') |
| 38 | + obj[key] = { [toActionKey(existing)]: existing, [toActionKey(operationId)]: operationId } |
| 39 | + else if (existing && typeof existing === 'object' && !Array.isArray(existing)) |
| 40 | + obj[key][toActionKey(operationId)] = operationId |
| 41 | + else obj[key] = operationId |
| 42 | + |
| 43 | + return obj |
| 44 | + } |
| 45 | + |
| 46 | + const existing = obj[key] |
| 47 | + if (typeof existing === 'string') obj[key] = { [toActionKey(existing)]: existing } |
| 48 | + |
| 49 | + if (!obj[key] || typeof obj[key] === 'string') obj[key] = {} |
| 50 | + |
| 51 | + buildNestedObject(obj[key], rest, operationId) |
| 52 | + return obj |
| 53 | +} |
| 54 | + |
| 55 | +// Traverse nested structure and collect { path, operationId } for each leaf |
| 56 | +function collectCommandSpecs(obj, pathPrefix = [], acc = []) { |
| 57 | + for (const [key, value] of Object.entries(obj)) { |
| 58 | + const path = [...pathPrefix, key] |
| 59 | + if (typeof value === 'string') acc.push({ path, operationId: value }) |
| 60 | + else collectCommandSpecs(value, path, acc) |
| 61 | + } |
| 62 | + return acc |
| 63 | +} |
| 64 | + |
| 65 | +// Extract path and body params from OpenAPI operation |
| 66 | +function getParams(operation) { |
| 67 | + const pathParams = [] |
| 68 | + const bodyParams = [] |
| 69 | + const params = operation.parameters ?? [] |
| 70 | + for (const p of params) if (p.in === 'path' && p.name) pathParams.push({ name: p.name }) |
| 71 | + |
| 72 | + const body = operation.requestBody?.content?.['application/json']?.schema |
| 73 | + if (body?.properties) for (const name of Object.keys(body.properties)) bodyParams.push({ name }) |
| 74 | + |
| 75 | + return { pathParams, bodyParams } |
| 76 | +} |
| 77 | + |
| 78 | +// Read OpenAPI spec |
| 79 | +const openapiSpec = JSON.parse(readFileSync(openapiPath, 'utf-8')) |
| 80 | +const paths = openapiSpec.paths || {} |
| 81 | +const nestedStructure = {} |
| 82 | + |
| 83 | +for (const [path, methods] of Object.entries(paths)) { |
| 84 | + if (path.startsWith('/auth')) continue |
| 85 | + if (typeof methods !== 'object' || methods === null) continue |
| 86 | + |
| 87 | + for (const [method, operation] of Object.entries(methods)) { |
| 88 | + if (!['get', 'post', 'put', 'patch', 'delete', 'options', 'head'].includes(method)) continue |
| 89 | + if (typeof operation !== 'object' || operation === null) continue |
| 90 | + |
| 91 | + let operationId = operation.operationId |
| 92 | + if (!operationId) operationId = method.toLowerCase() |
| 93 | + |
| 94 | + const pathSegments = path.split('/').filter(Boolean) |
| 95 | + if (path === '/') continue |
| 96 | + |
| 97 | + if (pathSegments.length === 1) { |
| 98 | + nestedStructure[operationId] = operationId |
| 99 | + continue |
| 100 | + } |
| 101 | + |
| 102 | + buildNestedObject(nestedStructure, pathSegments, operationId) |
| 103 | + } |
| 104 | +} |
| 105 | + |
| 106 | +const rawSpecs = collectCommandSpecs(nestedStructure) |
| 107 | +const operationMeta = {} |
| 108 | +const commandSpecs = [] |
| 109 | + |
| 110 | +for (const { path, operationId } of rawSpecs) { |
| 111 | + let op |
| 112 | + for (const [p, methods] of Object.entries(paths)) { |
| 113 | + if (p.startsWith('/auth')) continue |
| 114 | + for (const [, operation] of Object.entries(methods ?? {})) |
| 115 | + if (operation?.operationId === operationId) { |
| 116 | + op = operation |
| 117 | + break |
| 118 | + } |
| 119 | + } |
| 120 | + const { pathParams, bodyParams } = op ? getParams(op) : { pathParams: [], bodyParams: [] } |
| 121 | + const summary = op?.summary ?? operationId |
| 122 | + const description = op?.description ?? summary |
| 123 | + |
| 124 | + operationMeta[operationId] = { summary, description, pathParams, bodyParams } |
| 125 | + const cliPath = path.map(s => (s.includes('-') ? s : toKebabCase(s))) |
| 126 | + commandSpecs.push({ path: cliPath, operationId }) |
| 127 | +} |
| 128 | + |
| 129 | +const output = `// This file is auto-generated. Do not edit manually. |
| 130 | +
|
| 131 | +export const operationMeta = ${JSON.stringify(operationMeta, null, 2)} as const |
| 132 | +
|
| 133 | +export const commandSpecs = ${JSON.stringify(commandSpecs, null, 2)} as const |
| 134 | +` |
| 135 | + |
| 136 | +mkdirSync(outputDir, { recursive: true }) |
| 137 | +writeFileSync(outputPath, output, 'utf-8') |
0 commit comments