@redpanda-data/docs-extensions-and-macros
Version:
Antora extensions and macros developed for Redpanda documentation.
814 lines (693 loc) • 28.8 kB
JavaScript
const fs = require('fs');
const path = require('path');
const { execSync, spawnSync } = require('child_process');
const yaml = require('yaml');
/**
* Normalize a Git tag into a semantic version string.
*
* Trims surrounding whitespace, returns 'dev' unchanged, removes a leading 'v' if present,
* and validates that the result matches MAJOR.MINOR.PATCH with optional pre-release/build metadata.
* Throws if the input is not a non-empty string or does not conform to the expected version format.
*
* @param {string} tag - Git tag (e.g., 'v25.1.1', '25.1.1', or 'dev').
* @returns {string} Normalized version (e.g., '25.1.1' or 'dev').
* @throws {Error} If `tag` is not a non-empty string or does not match the semantic version pattern.
*/
function normalizeTag(tag) {
if (!tag || typeof tag !== 'string') {
throw new Error('Tag must be a non-empty string');
}
// Trim whitespace
tag = tag.trim();
if (!tag) {
throw new Error('Invalid version format: tag cannot be empty');
}
// Handle dev branch
if (tag === 'dev') {
return 'dev';
}
// Remove 'v' prefix if present
const normalized = tag.startsWith('v') ? tag.slice(1) : tag;
// Validate semantic version format
const semverPattern = /^\d+\.\d+\.\d+(-[\w\.-]+)?(\+[\w\.-]+)?$/;
if (!semverPattern.test(normalized) && normalized !== 'dev') {
throw new Error(`Invalid version format: ${tag}. Expected format like v25.1.1 or 25.1.1`);
}
return normalized;
}
/**
* Return the major.minor portion of a semantic version string.
*
* Accepts a semantic version like `25.1.1` and yields `25.1`. The special value
* `'dev'` is returned unchanged.
* @param {string} version - Semantic version (e.g., `'25.1.1'`) or `'dev'`.
* @returns {string} The `major.minor` string (e.g., `'25.1'`) or `'dev'`.
* @throws {Error} If `version` is not a non-empty string, lacks major/minor parts, or if major/minor are not numeric.
*/
function getMajorMinor(version) {
if (!version || typeof version !== 'string') {
throw new Error('Version must be a non-empty string');
}
if (version === 'dev') {
return 'dev';
}
const parts = version.split('.');
if (parts.length < 2) {
throw new Error(`Invalid version format: ${version}. Expected X.Y.Z format`);
}
const major = parseInt(parts[0], 10);
const minor = parseInt(parts[1], 10);
if (isNaN(major) || isNaN(minor)) {
throw new Error(`Major and minor versions must be numbers: ${version}`);
}
return `${major}.${minor}`;
}
/**
* Produce a new value with object keys sorted recursively for deterministic output.
* Non-objects are returned unchanged; arrays are processed element-wise.
* @param {*} obj - Value to normalize; may be an object, array, or any primitive.
* @returns {*} A new value where any objects have their keys sorted lexicographically.
*/
function sortObjectKeys(obj) {
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(sortObjectKeys);
}
const sortedObj = {};
Object.keys(obj)
.sort()
.forEach(key => {
sortedObj[key] = sortObjectKeys(obj[key]);
});
return sortedObj;
}
/**
* Detect available OpenAPI bundler
* @param {boolean} quiet - Suppress output
* @returns {string} Available bundler command
*/
function detectBundler(quiet = false) {
const bundlers = ['swagger-cli', 'redocly'];
for (const bundler of bundlers) {
try {
execSync(`${bundler} --version`, {
stdio: 'ignore',
timeout: 10000
});
if (!quiet) {
console.log(`✅ Using ${bundler} for OpenAPI bundling`);
}
return bundler;
} catch (error) {
// Continue to next bundler
}
}
// Try npx @redocly/cli as fallback
try {
execSync('npx @redocly/cli --version', {
stdio: 'ignore',
timeout: 10000
});
if (!quiet) {
console.log('✅ Using npx @redocly/cli for OpenAPI bundling');
}
return 'npx @redocly/cli';
} catch (error) {
// Try legacy npx redocly
try {
execSync('npx redocly --version', {
stdio: 'ignore',
timeout: 10000
});
if (!quiet) {
console.log('✅ Using npx redocly for OpenAPI bundling');
}
return 'npx redocly';
} catch (error) {
// Final fallback failed
}
}
throw new Error(
'No OpenAPI bundler found. Please install one of:\n' +
' npm install -g swagger-cli\n' +
' npm install -g @redocly/cli\n' +
'For more information, see: https://github.com/APIDevTools/swagger-cli or https://redocly.com/docs/cli/'
);
}
/**
* Collects file paths of OpenAPI fragment files for the specified API surface.
*
* @param {string} tempDir - Path to a temporary repository workspace that contains generated OpenAPI fragments (must exist).
* @param {'admin'|'connect'} apiSurface - API surface to scan; either `'admin'` or `'connect'`.
* @returns {string[]} Array of full paths to discovered fragment files (*.openapi.yaml / *.openapi.yml).
* @throws {Error} If tempDir is missing or does not exist.
* @throws {Error} If apiSurface is not 'admin' or 'connect'.
* @throws {Error} If no OpenAPI fragment files are found.
*/
function createEntrypoint(tempDir, apiSurface) {
// Validate input parameters
if (!tempDir || typeof tempDir !== 'string' || tempDir.trim() === '') {
throw new Error('Invalid temporary directory');
}
// Check if directory exists
if (!fs.existsSync(tempDir)) {
throw new Error('Invalid temporary directory');
}
if (!apiSurface || typeof apiSurface !== 'string' || !['admin', 'connect'].includes(apiSurface)) {
throw new Error('Invalid API surface');
}
let quiet = false; // Default for logging
if (!quiet) {
console.log('🔍 Looking for fragments in:');
console.log(` Admin v2: ${path.join(tempDir, 'vbuild/openapi/proto/redpanda/core/admin/v2')}`);
console.log(` Common: ${path.join(tempDir, 'vbuild/openapi/proto/redpanda/core/common')}`);
}
const fragmentDirs = [];
let fragmentFiles = [];
try {
if (apiSurface === 'admin') {
const adminDir = path.join(tempDir, 'vbuild/openapi/proto/redpanda/core/admin/v2');
const commonDir = path.join(tempDir, 'vbuild/openapi/proto/redpanda/core/common');
fragmentDirs.push(adminDir, commonDir);
} else if (apiSurface === 'connect') {
const connectDir = path.join(tempDir, 'vbuild/openapi/proto/redpanda/connect');
fragmentDirs.push(connectDir);
}
// Log directory existence for debugging
if (!quiet && fs.existsSync(path.join(tempDir, 'vbuild'))) {
console.log('📂 vbuild directory contents:');
try {
const contents = fs.readdirSync(path.join(tempDir, 'vbuild'), { recursive: true });
contents.slice(0, 10).forEach(item => {
console.log(` ${item}`);
});
if (contents.length > 10) {
console.log(` ... and ${contents.length - 10} more items`);
}
} catch (dirErr) {
console.log(` ❌ Error reading directory: ${dirErr.message}`);
}
}
fragmentDirs.forEach(dir => {
if (fs.existsSync(dir)) {
try {
const files = fs.readdirSync(dir)
.filter(file => file.endsWith('.openapi.yaml') || file.endsWith('.openapi.yml'))
.map(file => path.join(dir, file))
.filter(filePath => fs.statSync(filePath).isFile()); // Make sure it's actually a file
fragmentFiles.push(...files);
} catch (readErr) {
throw new Error(`Failed to read fragment directories: ${readErr.message}`);
}
} else {
if (!quiet) {
console.log(`📁 ${path.basename(dir) === 'v2' ? 'Admin v2' : path.basename(dir)} directory not found: ${dir}`);
}
}
});
} catch (err) {
throw new Error(`Failed to scan for OpenAPI fragments: ${err.message}`);
}
if (fragmentFiles.length === 0) {
throw new Error('No OpenAPI fragments found to bundle. Make sure \'buf generate\' has run successfully');
}
// Most bundlers can handle multiple input files or merge operations.
return fragmentFiles;
}
/**
* Bundle one or more OpenAPI fragment files into a single bundled YAML using a selected external bundler.
*
* Merges multiple fragment files into a temporary single entrypoint when required, invokes the specified bundler
* executable (supported values: 'swagger-cli', 'redocly', 'npx redocly', 'npx @redocly/cli'), and writes the bundled
* output to the given outputPath. Ensures the output directory exists and verifies the produced file is non-empty.
*
* @param {string} bundler - The bundler to invoke: 'swagger-cli', 'redocly', 'npx redocly', or 'npx @redocly/cli'.
* @param {string[]|string} fragmentFiles - Array of fragment file paths to merge or a single entrypoint file path.
* @param {string} outputPath - Filesystem path where the bundled OpenAPI YAML will be written.
* @param {string} tempDir - Existing temporary directory used to create a merged entrypoint when multiple fragments are provided.
* @param {boolean} [quiet=false] - If true, suppresses console output from this function and child process stdio.
* @throws {Error} If input validation fails, the bundler process times out or exits with an error, or the output file is missing or empty.
*/
function runBundler(bundler, fragmentFiles, outputPath, tempDir, quiet = false) {
if (!bundler || typeof bundler !== 'string') {
throw new Error('Invalid bundler specified');
}
if (!fragmentFiles || (Array.isArray(fragmentFiles) && fragmentFiles.length === 0)) {
throw new Error('No fragment files provided for bundling');
}
if (!outputPath || typeof outputPath !== 'string') {
throw new Error('Invalid output path specified');
}
if (!tempDir || !fs.existsSync(tempDir)) {
throw new Error('Invalid temporary directory');
}
const stdio = quiet ? 'ignore' : 'inherit';
const timeout = 120000; // 2 minutes timeout
// If we have multiple fragments, we need to merge them first since bundlers
// typically expect a single entrypoint file
let entrypoint;
try {
if (Array.isArray(fragmentFiles) && fragmentFiles.length > 1) {
// Create a merged entrypoint file
entrypoint = path.join(tempDir, 'merged-entrypoint.yaml');
const mergedContent = {
openapi: '3.1.0',
info: {
title: 'Redpanda Admin API',
version: '2.0.0'
},
paths: {},
components: {
schemas: {}
}
};
// Manually merge all fragment files
for (const filePath of fragmentFiles) {
try {
if (!fs.existsSync(filePath)) {
console.warn(`⚠️ Fragment file not found: ${filePath}`);
continue;
}
const fragmentContent = fs.readFileSync(filePath, 'utf8');
const fragmentData = yaml.parse(fragmentContent);
if (!fragmentData || typeof fragmentData !== 'object') {
console.warn(`⚠️ Invalid fragment data in: ${filePath}`);
continue;
}
// Merge paths
if (fragmentData.paths && typeof fragmentData.paths === 'object') {
Object.assign(mergedContent.paths, fragmentData.paths);
}
// Merge components
if (fragmentData.components && typeof fragmentData.components === 'object') {
if (fragmentData.components.schemas) {
Object.assign(mergedContent.components.schemas, fragmentData.components.schemas);
}
// Merge other component types
const componentTypes = ['responses', 'parameters', 'examples', 'requestBodies', 'headers', 'securitySchemes', 'links', 'callbacks'];
for (const componentType of componentTypes) {
if (fragmentData.components[componentType]) {
if (!mergedContent.components[componentType]) {
mergedContent.components[componentType] = {};
}
Object.assign(mergedContent.components[componentType], fragmentData.components[componentType]);
}
}
}
} catch (error) {
console.warn(`⚠️ Failed to parse fragment ${filePath}: ${error.message}`);
}
}
// Validate merged content
if (Object.keys(mergedContent.paths).length === 0) {
throw new Error('No valid paths found in any fragments');
}
fs.writeFileSync(entrypoint, yaml.stringify(mergedContent), 'utf8');
if (!quiet) {
console.log(`📄 Created merged entrypoint with ${Object.keys(mergedContent.paths).length} paths`);
}
} else {
// Single file or string entrypoint
entrypoint = Array.isArray(fragmentFiles) ? fragmentFiles[0] : fragmentFiles;
if (!fs.existsSync(entrypoint)) {
throw new Error(`Entrypoint file not found: ${entrypoint}`);
}
}
// Ensure output directory exists
const outputDir = path.dirname(outputPath);
fs.mkdirSync(outputDir, { recursive: true });
let result;
if (bundler === 'swagger-cli') {
result = spawnSync('swagger-cli', ['bundle', entrypoint, '-o', outputPath, '-t', 'yaml'], {
stdio,
timeout
});
} else if (bundler === 'redocly') {
result = spawnSync('redocly', ['bundle', entrypoint, '--output', outputPath], {
stdio,
timeout
});
} else if (bundler === 'npx redocly') {
result = spawnSync('npx', ['redocly', 'bundle', entrypoint, '--output', outputPath], {
stdio,
timeout
});
} else if (bundler === 'npx @redocly/cli') {
result = spawnSync('npx', ['@redocly/cli', 'bundle', entrypoint, '--output', outputPath], {
stdio,
timeout
});
} else {
throw new Error(`Unknown bundler: ${bundler}`);
}
if (result.error) {
if (result.error.code === 'ETIMEDOUT') {
throw new Error(`Bundler timed out after ${timeout / 1000} seconds`);
}
throw new Error(`Bundler execution failed: ${result.error.message}`);
}
if (result.status !== 0) {
const errorMsg = result.stderr ? result.stderr.toString() : 'Unknown error';
throw new Error(`${bundler} bundle failed with exit code ${result.status}: ${errorMsg}`);
}
// Verify output file was created
if (!fs.existsSync(outputPath)) {
throw new Error(`Bundler completed but output file not found: ${outputPath}`);
}
const stats = fs.statSync(outputPath);
if (stats.size === 0) {
throw new Error(`Bundler created empty output file: ${outputPath}`);
}
if (!quiet) {
console.log(`✅ Bundle created: ${outputPath} (${Math.round(stats.size / 1024)}KB)`);
}
} catch (error) {
// Clean up temporary entrypoint file on error
if (entrypoint && entrypoint !== fragmentFiles && fs.existsSync(entrypoint)) {
try {
fs.unlinkSync(entrypoint);
} catch {
// Ignore cleanup errors
}
}
throw error;
}
}
/**
* Update bundle metadata, enforce a deterministic key order, and rewrite the bundled OpenAPI YAML.
*
* Reads the bundled YAML at `filePath`, validates and augments its `info` object (titles, descriptions,
* version fields and x- metadata) based on `options.surface` and provided version information, sorts
* object keys deterministically, and writes the updated YAML back to `filePath`.
*
* @param {string} filePath - Path to the bundled OpenAPI YAML file to process.
* @param {Object} options - Processing options.
* @param {'admin'|'connect'} options.surface - API surface to target; affects title and description.
* @param {string} [options.tag] - Git tag used for versioning (may be normalized internally).
* @param {string} [options.normalizedTag] - Pre-normalized version string to use instead of `tag`.
* @param {string} [options.majorMinor] - Major.minor version to set in `info.version`.
* @param {string} [options.adminMajor] - Admin API major version to set as `x-admin-api-major`.
* @param {boolean} [options.useAdminMajorVersion] - When true and surface is 'admin', prefer `adminMajor` for `info.version`.
* @param {boolean} [quiet=false] - Suppress console output when true.
* @returns {Object} The processed OpenAPI bundle object with keys sorted deterministically.
* @throws {Error} If inputs are missing/invalid, the file is absent or empty, YAML parsing fails, or processing cannot complete.
*/
function postProcessBundle(filePath, options, quiet = false) {
if (!filePath || typeof filePath !== 'string') {
throw new Error('Bundle file not found');
}
if (!fs.existsSync(filePath)) {
throw new Error('Bundle file not found');
}
if (!options || typeof options !== 'object') {
throw new Error('Missing required options');
}
const { surface, tag, majorMinor, adminMajor, normalizedTag, useAdminMajorVersion } = options;
if (!surface || !['admin', 'connect'].includes(surface)) {
throw new Error('Invalid API surface');
}
// Require at least one version identifier
if (!tag && !normalizedTag && !majorMinor) {
throw new Error('Missing required options');
}
try {
const content = fs.readFileSync(filePath, 'utf8');
if (!content.trim()) {
throw new Error('Bundle file is empty');
}
let bundle;
try {
bundle = yaml.parse(content);
} catch (parseError) {
throw new Error(`Invalid YAML in bundle file: ${parseError.message}`);
}
if (!bundle || typeof bundle !== 'object') {
throw new Error('Bundle file does not contain valid OpenAPI structure');
}
// Normalize the tag and extract version info
const normalizedVersion = normalizedTag || (tag ? normalizeTag(tag) : '1.0.0');
let versionMajorMinor;
if (useAdminMajorVersion && surface === 'admin' && adminMajor) {
// Use admin major version for info.version when flag is set
versionMajorMinor = adminMajor;
} else {
// Use normalized tag version (default behavior)
versionMajorMinor = majorMinor || (normalizedVersion !== '1.0.0' ? getMajorMinor(normalizedVersion) : '1.0');
}
// Update info section with proper metadata
if (!bundle.info) {
bundle.info = {};
}
bundle.info.version = versionMajorMinor;
if (surface === 'admin') {
bundle.info.title = 'Redpanda Admin API';
bundle.info.description = 'Redpanda Admin API specification';
if (adminMajor) {
bundle.info['x-admin-api-major'] = adminMajor;
}
} else if (surface === 'connect') {
bundle.info.title = 'Redpanda Connect RPCs';
bundle.info.description = 'Redpanda Connect API specification';
}
// Additional metadata expected by tests
if (tag || normalizedTag) {
bundle.info['x-redpanda-core-version'] = tag || normalizedTag || normalizedVersion;
}
bundle.info['x-generated-at'] = new Date().toISOString();
bundle.info['x-generator'] = 'redpanda-docs-openapi-bundler';
// Sort keys for deterministic output
const sortedBundle = sortObjectKeys(bundle);
// Write back to file
fs.writeFileSync(filePath, yaml.stringify(sortedBundle, {
lineWidth: 0,
minContentWidth: 0,
indent: 2
}), 'utf8');
if (!quiet) {
console.log(`📝 Updated bundle metadata: version=${normalizedVersion}`);
}
return sortedBundle;
} catch (error) {
throw new Error(`Post-processing failed: ${error.message}`);
}
}
/**
* Bundle OpenAPI fragments for the specified API surface(s) from a repository tag and write the resulting bundled YAML files to disk.
*
* @param {Object} options - Configuration options.
* @param {string} options.tag - Git tag to checkout (e.g., 'v25.1.1').
* @param {'admin'|'connect'|'both'} options.surface - API surface to process.
* @param {string} [options.output] - Standalone output file path; when provided, used for the single output file.
* @param {string} [options.outAdmin] - Output path for the admin API when integrating with doc-tools mode.
* @param {string} [options.outConnect] - Output path for the connect API when integrating with doc-tools mode.
* @param {string} [options.repo] - Repository URL to clone (defaults to https://github.com/redpanda-data/redpanda.git).
* @param {string} [options.adminMajor] - Admin API major version string used for metadata (e.g., 'v2.0.0').
* @param {boolean} [options.useAdminMajorVersion] - When true and processing the admin surface, use `adminMajor` for the bundle info.version.
* @param {boolean} [options.quiet=false] - Suppress logging to stdout/stderr when true.
* @returns {Object|Object[]} An object (for a single surface) or an array of objects (for both surfaces) with fields:
* - surface: processed surface name ('admin' or 'connect'),
* - outputPath: final written file path,
* - fragmentCount: number of OpenAPI fragment files processed,
* - bundler: name or command of the bundler used.
*/
async function bundleOpenAPI(options) {
const { tag, surface, output, outAdmin, outConnect, repo, adminMajor, useAdminMajorVersion, quiet = false } = options;
// Validate required parameters
if (!tag) {
throw new Error('Git tag is required');
}
if (!surface || !['admin', 'connect', 'both'].includes(surface)) {
throw new Error('API surface must be "admin", "connect", or "both"');
}
// Handle different surface options
const surfaces = surface === 'both' ? ['admin', 'connect'] : [surface];
const results = [];
const tempDir = fs.mkdtempSync(path.join(process.cwd(), 'openapi-bundle-'));
// Set up cleanup handlers
const cleanup = () => {
try {
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
} catch (error) {
console.error(`Warning: Failed to cleanup temporary directory: ${error.message}`);
}
};
// Create dedicated handlers that clean up and then terminate
const cleanupAndExit = (signal) => {
return () => {
cleanup();
process.exit(signal === 'SIGTERM' ? 0 : 1);
};
};
const cleanupAndCrash = (error) => {
cleanup();
console.error('Fatal error:', error);
process.exit(1);
};
// Handle graceful shutdown and crashes
const sigintHandler = cleanupAndExit('SIGINT');
const sigtermHandler = cleanupAndExit('SIGTERM');
process.on('SIGINT', sigintHandler);
process.on('SIGTERM', sigtermHandler);
process.on('uncaughtException', cleanupAndCrash);
try {
// Clone repository (only once for all surfaces)
if (!quiet) {
console.log('📥 Cloning redpanda repository...');
}
const repositoryUrl = repo || 'https://github.com/redpanda-data/redpanda.git';
try {
execSync(`git clone --depth 1 --branch ${tag} ${repositoryUrl} redpanda`, {
cwd: tempDir,
stdio: quiet ? 'ignore' : 'inherit',
timeout: 60000 // 1 minute timeout
});
} catch (cloneError) {
throw new Error(`Failed to clone repository: ${cloneError.message}`);
}
const repoDir = path.join(tempDir, 'redpanda');
// Verify repository was cloned
if (!fs.existsSync(repoDir)) {
throw new Error('Repository clone failed - directory not found');
}
// Run buf generate
if (!quiet) {
console.log('🔧 Running buf generate...');
}
try {
execSync('buf generate --template buf.gen.openapi.yaml', {
cwd: repoDir,
stdio: quiet ? 'ignore' : 'inherit',
timeout: 120000 // 2 minutes timeout
});
} catch (bufError) {
throw new Error(`buf generate failed: ${bufError.message}`);
}
// Process each surface
for (const currentSurface of surfaces) {
// Determine output path based on mode (standalone vs doc-tools integration)
let finalOutput;
if (output) {
// Standalone mode with explicit output
finalOutput = output;
} else if (currentSurface === 'admin' && outAdmin) {
// Doc-tools mode with admin output
finalOutput = outAdmin;
} else if (currentSurface === 'connect' && outConnect) {
// Doc-tools mode with connect output
finalOutput = outConnect;
} else {
// Default paths
finalOutput = currentSurface === 'admin'
? 'admin/redpanda-admin-api.yaml'
: 'connect/redpanda-connect-api.yaml';
}
if (!quiet) {
console.log(`🚀 Bundling OpenAPI for ${currentSurface} API (tag: ${tag})`);
console.log(`📁 Output: ${finalOutput}`);
}
// Find OpenAPI fragments
const fragmentFiles = createEntrypoint(repoDir, currentSurface);
if (!quiet) {
console.log(`📋 Found ${fragmentFiles.length} OpenAPI fragments`);
fragmentFiles.forEach(file => {
const relativePath = path.relative(repoDir, file);
console.log(` ${relativePath}`);
});
}
// Detect and use bundler
const bundler = detectBundler(quiet);
// Bundle the OpenAPI fragments
if (!quiet) {
console.log('🔄 Bundling OpenAPI fragments...');
}
const tempOutput = path.join(tempDir, `bundled-${currentSurface}.yaml`);
await runBundler(bundler, fragmentFiles, tempOutput, tempDir, quiet);
// Post-process the bundle
if (!quiet) {
console.log('📝 Post-processing bundle...');
}
const postProcessOptions = {
surface: currentSurface,
tag: tag,
majorMinor: getMajorMinor(normalizeTag(tag)),
adminMajor: adminMajor,
useAdminMajorVersion: useAdminMajorVersion
};
postProcessBundle(tempOutput, postProcessOptions, quiet);
// Move to final output location
const outputDir = path.dirname(finalOutput);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.copyFileSync(tempOutput, finalOutput);
if (!quiet) {
const stats = fs.statSync(finalOutput);
console.log(`✅ Bundle complete: ${finalOutput} (${Math.round(stats.size / 1024)}KB)`);
}
results.push({
surface: currentSurface,
outputPath: finalOutput,
fragmentCount: fragmentFiles.length,
bundler: bundler
});
}
return results.length === 1 ? results[0] : results;
} catch (error) {
if (!quiet) {
console.error(`❌ Bundling failed: ${error.message}`);
}
throw error;
} finally {
// Remove event handlers to restore default behavior
process.removeListener('SIGINT', sigintHandler);
process.removeListener('SIGTERM', sigtermHandler);
process.removeListener('uncaughtException', cleanupAndCrash);
cleanup();
}
}
// Export functions for testing
module.exports = {
bundleOpenAPI,
normalizeTag,
getMajorMinor,
sortObjectKeys,
detectBundler,
createEntrypoint,
postProcessBundle
};
// CLI interface if run directly
if (require.main === module) {
const { program } = require('commander');
program
.name('bundle-openapi')
.description('Bundle OpenAPI fragments from Redpanda repository')
.requiredOption('-t, --tag <tag>', 'Git tag to checkout (e.g., v25.1.1)')
.requiredOption('-s, --surface <surface>', 'API surface', (value) => {
if (!['admin', 'connect', 'both'].includes(value)) {
throw new Error('Invalid API surface. Must be "admin", "connect", or "both"');
}
return value;
})
.option('-o, --output <path>', 'Output file path (defaults: admin/redpanda-admin-api.yaml or connect/redpanda-connect-api.yaml)')
.option('--out-admin <path>', 'Output path for admin API', 'admin/redpanda-admin-api.yaml')
.option('--out-connect <path>', 'Output path for connect API', 'connect/redpanda-connect-api.yaml')
.option('--repo <url>', 'Repository URL', 'https://github.com/redpanda-data/redpanda.git')
.option('--admin-major <string>', 'Admin API major version', 'v2.0.0')
.option('--use-admin-major-version', 'Use admin major version for info.version instead of git tag', false)
.option('-q, --quiet', 'Suppress output', false)
.action(async (options) => {
try {
await bundleOpenAPI(options);
process.exit(0);
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
});
program.parse();
}