@redpanda-data/docs-extensions-and-macros 4.9.1 → 4.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,814 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { execSync, spawnSync } = require('child_process');
6
+ const yaml = require('yaml');
7
+
8
+ /**
9
+ * Normalize a Git tag into a semantic version string.
10
+ *
11
+ * Trims surrounding whitespace, returns 'dev' unchanged, removes a leading 'v' if present,
12
+ * and validates that the result matches MAJOR.MINOR.PATCH with optional pre-release/build metadata.
13
+ * Throws if the input is not a non-empty string or does not conform to the expected version format.
14
+ *
15
+ * @param {string} tag - Git tag (e.g., 'v25.1.1', '25.1.1', or 'dev').
16
+ * @returns {string} Normalized version (e.g., '25.1.1' or 'dev').
17
+ * @throws {Error} If `tag` is not a non-empty string or does not match the semantic version pattern.
18
+ */
19
+ function normalizeTag(tag) {
20
+ if (!tag || typeof tag !== 'string') {
21
+ throw new Error('Tag must be a non-empty string');
22
+ }
23
+
24
+ // Trim whitespace
25
+ tag = tag.trim();
26
+
27
+ if (!tag) {
28
+ throw new Error('Invalid version format: tag cannot be empty');
29
+ }
30
+
31
+ // Handle dev branch
32
+ if (tag === 'dev') {
33
+ return 'dev';
34
+ }
35
+
36
+ // Remove 'v' prefix if present
37
+ const normalized = tag.startsWith('v') ? tag.slice(1) : tag;
38
+
39
+ // Validate semantic version format
40
+ const semverPattern = /^\d+\.\d+\.\d+(-[\w\.-]+)?(\+[\w\.-]+)?$/;
41
+ if (!semverPattern.test(normalized) && normalized !== 'dev') {
42
+ throw new Error(`Invalid version format: ${tag}. Expected format like v25.1.1 or 25.1.1`);
43
+ }
44
+
45
+ return normalized;
46
+ }
47
+
48
+ /**
49
+ * Return the major.minor portion of a semantic version string.
50
+ *
51
+ * Accepts a semantic version like `25.1.1` and yields `25.1`. The special value
52
+ * `'dev'` is returned unchanged.
53
+ * @param {string} version - Semantic version (e.g., `'25.1.1'`) or `'dev'`.
54
+ * @returns {string} The `major.minor` string (e.g., `'25.1'`) or `'dev'`.
55
+ * @throws {Error} If `version` is not a non-empty string, lacks major/minor parts, or if major/minor are not numeric.
56
+ */
57
+ function getMajorMinor(version) {
58
+ if (!version || typeof version !== 'string') {
59
+ throw new Error('Version must be a non-empty string');
60
+ }
61
+
62
+ if (version === 'dev') {
63
+ return 'dev';
64
+ }
65
+
66
+ const parts = version.split('.');
67
+ if (parts.length < 2) {
68
+ throw new Error(`Invalid version format: ${version}. Expected X.Y.Z format`);
69
+ }
70
+
71
+ const major = parseInt(parts[0], 10);
72
+ const minor = parseInt(parts[1], 10);
73
+
74
+ if (isNaN(major) || isNaN(minor)) {
75
+ throw new Error(`Major and minor versions must be numbers: ${version}`);
76
+ }
77
+
78
+ return `${major}.${minor}`;
79
+ }
80
+
81
+ /**
82
+ * Produce a new value with object keys sorted recursively for deterministic output.
83
+ * Non-objects are returned unchanged; arrays are processed element-wise.
84
+ * @param {*} obj - Value to normalize; may be an object, array, or any primitive.
85
+ * @returns {*} A new value where any objects have their keys sorted lexicographically.
86
+ */
87
+ function sortObjectKeys(obj) {
88
+ if (obj === null || typeof obj !== 'object') {
89
+ return obj;
90
+ }
91
+
92
+ if (Array.isArray(obj)) {
93
+ return obj.map(sortObjectKeys);
94
+ }
95
+
96
+ const sortedObj = {};
97
+ Object.keys(obj)
98
+ .sort()
99
+ .forEach(key => {
100
+ sortedObj[key] = sortObjectKeys(obj[key]);
101
+ });
102
+
103
+ return sortedObj;
104
+ }
105
+
106
+ /**
107
+ * Detect available OpenAPI bundler
108
+ * @param {boolean} quiet - Suppress output
109
+ * @returns {string} Available bundler command
110
+ */
111
+ function detectBundler(quiet = false) {
112
+ const bundlers = ['swagger-cli', 'redocly'];
113
+
114
+ for (const bundler of bundlers) {
115
+ try {
116
+ execSync(`${bundler} --version`, {
117
+ stdio: 'ignore',
118
+ timeout: 10000
119
+ });
120
+
121
+ if (!quiet) {
122
+ console.log(`✅ Using ${bundler} for OpenAPI bundling`);
123
+ }
124
+ return bundler;
125
+ } catch (error) {
126
+ // Continue to next bundler
127
+ }
128
+ }
129
+
130
+ // Try npx @redocly/cli as fallback
131
+ try {
132
+ execSync('npx @redocly/cli --version', {
133
+ stdio: 'ignore',
134
+ timeout: 10000
135
+ });
136
+
137
+ if (!quiet) {
138
+ console.log('✅ Using npx @redocly/cli for OpenAPI bundling');
139
+ }
140
+ return 'npx @redocly/cli';
141
+ } catch (error) {
142
+ // Try legacy npx redocly
143
+ try {
144
+ execSync('npx redocly --version', {
145
+ stdio: 'ignore',
146
+ timeout: 10000
147
+ });
148
+
149
+ if (!quiet) {
150
+ console.log('✅ Using npx redocly for OpenAPI bundling');
151
+ }
152
+ return 'npx redocly';
153
+ } catch (error) {
154
+ // Final fallback failed
155
+ }
156
+ }
157
+
158
+ throw new Error(
159
+ 'No OpenAPI bundler found. Please install one of:\n' +
160
+ ' npm install -g swagger-cli\n' +
161
+ ' npm install -g @redocly/cli\n' +
162
+ 'For more information, see: https://github.com/APIDevTools/swagger-cli or https://redocly.com/docs/cli/'
163
+ );
164
+ }
165
+
166
+ /**
167
+ * Collects file paths of OpenAPI fragment files for the specified API surface.
168
+ *
169
+ * @param {string} tempDir - Path to a temporary repository workspace that contains generated OpenAPI fragments (must exist).
170
+ * @param {'admin'|'connect'} apiSurface - API surface to scan; either `'admin'` or `'connect'`.
171
+ * @returns {string[]} Array of full paths to discovered fragment files (*.openapi.yaml / *.openapi.yml).
172
+ * @throws {Error} If tempDir is missing or does not exist.
173
+ * @throws {Error} If apiSurface is not 'admin' or 'connect'.
174
+ * @throws {Error} If no OpenAPI fragment files are found.
175
+ */
176
+ function createEntrypoint(tempDir, apiSurface) {
177
+ // Validate input parameters
178
+ if (!tempDir || typeof tempDir !== 'string' || tempDir.trim() === '') {
179
+ throw new Error('Invalid temporary directory');
180
+ }
181
+
182
+ // Check if directory exists
183
+ if (!fs.existsSync(tempDir)) {
184
+ throw new Error('Invalid temporary directory');
185
+ }
186
+
187
+ if (!apiSurface || typeof apiSurface !== 'string' || !['admin', 'connect'].includes(apiSurface)) {
188
+ throw new Error('Invalid API surface');
189
+ }
190
+
191
+ let quiet = false; // Default for logging
192
+ if (!quiet) {
193
+ console.log('🔍 Looking for fragments in:');
194
+ console.log(` Admin v2: ${path.join(tempDir, 'vbuild/openapi/proto/redpanda/core/admin/v2')}`);
195
+ console.log(` Common: ${path.join(tempDir, 'vbuild/openapi/proto/redpanda/core/common')}`);
196
+ }
197
+
198
+ const fragmentDirs = [];
199
+ let fragmentFiles = [];
200
+
201
+ try {
202
+ if (apiSurface === 'admin') {
203
+ const adminDir = path.join(tempDir, 'vbuild/openapi/proto/redpanda/core/admin/v2');
204
+ const commonDir = path.join(tempDir, 'vbuild/openapi/proto/redpanda/core/common');
205
+
206
+ fragmentDirs.push(adminDir, commonDir);
207
+ } else if (apiSurface === 'connect') {
208
+ const connectDir = path.join(tempDir, 'vbuild/openapi/proto/redpanda/connect');
209
+ fragmentDirs.push(connectDir);
210
+ }
211
+
212
+ // Log directory existence for debugging
213
+ if (!quiet && fs.existsSync(path.join(tempDir, 'vbuild'))) {
214
+ console.log('📂 vbuild directory contents:');
215
+ try {
216
+ const contents = fs.readdirSync(path.join(tempDir, 'vbuild'), { recursive: true });
217
+ contents.slice(0, 10).forEach(item => {
218
+ console.log(` ${item}`);
219
+ });
220
+ if (contents.length > 10) {
221
+ console.log(` ... and ${contents.length - 10} more items`);
222
+ }
223
+ } catch (dirErr) {
224
+ console.log(` ❌ Error reading directory: ${dirErr.message}`);
225
+ }
226
+ }
227
+
228
+ fragmentDirs.forEach(dir => {
229
+ if (fs.existsSync(dir)) {
230
+ try {
231
+ const files = fs.readdirSync(dir)
232
+ .filter(file => file.endsWith('.openapi.yaml') || file.endsWith('.openapi.yml'))
233
+ .map(file => path.join(dir, file))
234
+ .filter(filePath => fs.statSync(filePath).isFile()); // Make sure it's actually a file
235
+
236
+ fragmentFiles.push(...files);
237
+ } catch (readErr) {
238
+ throw new Error(`Failed to read fragment directories: ${readErr.message}`);
239
+ }
240
+ } else {
241
+ if (!quiet) {
242
+ console.log(`📁 ${path.basename(dir) === 'v2' ? 'Admin v2' : path.basename(dir)} directory not found: ${dir}`);
243
+ }
244
+ }
245
+ });
246
+
247
+ } catch (err) {
248
+ throw new Error(`Failed to scan for OpenAPI fragments: ${err.message}`);
249
+ }
250
+
251
+ if (fragmentFiles.length === 0) {
252
+ throw new Error('No OpenAPI fragments found to bundle. Make sure \'buf generate\' has run successfully');
253
+ }
254
+
255
+ // Most bundlers can handle multiple input files or merge operations.
256
+ return fragmentFiles;
257
+ }
258
+
259
+ /**
260
+ * Bundle one or more OpenAPI fragment files into a single bundled YAML using a selected external bundler.
261
+ *
262
+ * Merges multiple fragment files into a temporary single entrypoint when required, invokes the specified bundler
263
+ * executable (supported values: 'swagger-cli', 'redocly', 'npx redocly', 'npx @redocly/cli'), and writes the bundled
264
+ * output to the given outputPath. Ensures the output directory exists and verifies the produced file is non-empty.
265
+ *
266
+ * @param {string} bundler - The bundler to invoke: 'swagger-cli', 'redocly', 'npx redocly', or 'npx @redocly/cli'.
267
+ * @param {string[]|string} fragmentFiles - Array of fragment file paths to merge or a single entrypoint file path.
268
+ * @param {string} outputPath - Filesystem path where the bundled OpenAPI YAML will be written.
269
+ * @param {string} tempDir - Existing temporary directory used to create a merged entrypoint when multiple fragments are provided.
270
+ * @param {boolean} [quiet=false] - If true, suppresses console output from this function and child process stdio.
271
+ * @throws {Error} If input validation fails, the bundler process times out or exits with an error, or the output file is missing or empty.
272
+ */
273
+ function runBundler(bundler, fragmentFiles, outputPath, tempDir, quiet = false) {
274
+ if (!bundler || typeof bundler !== 'string') {
275
+ throw new Error('Invalid bundler specified');
276
+ }
277
+
278
+ if (!fragmentFiles || (Array.isArray(fragmentFiles) && fragmentFiles.length === 0)) {
279
+ throw new Error('No fragment files provided for bundling');
280
+ }
281
+
282
+ if (!outputPath || typeof outputPath !== 'string') {
283
+ throw new Error('Invalid output path specified');
284
+ }
285
+
286
+ if (!tempDir || !fs.existsSync(tempDir)) {
287
+ throw new Error('Invalid temporary directory');
288
+ }
289
+
290
+ const stdio = quiet ? 'ignore' : 'inherit';
291
+ const timeout = 120000; // 2 minutes timeout
292
+
293
+ // If we have multiple fragments, we need to merge them first since bundlers
294
+ // typically expect a single entrypoint file
295
+ let entrypoint;
296
+
297
+ try {
298
+ if (Array.isArray(fragmentFiles) && fragmentFiles.length > 1) {
299
+ // Create a merged entrypoint file
300
+ entrypoint = path.join(tempDir, 'merged-entrypoint.yaml');
301
+
302
+ const mergedContent = {
303
+ openapi: '3.1.0',
304
+ info: {
305
+ title: 'Redpanda Admin API',
306
+ version: '2.0.0'
307
+ },
308
+ paths: {},
309
+ components: {
310
+ schemas: {}
311
+ }
312
+ };
313
+
314
+ // Manually merge all fragment files
315
+ for (const filePath of fragmentFiles) {
316
+ try {
317
+ if (!fs.existsSync(filePath)) {
318
+ console.warn(`⚠️ Fragment file not found: ${filePath}`);
319
+ continue;
320
+ }
321
+
322
+ const fragmentContent = fs.readFileSync(filePath, 'utf8');
323
+ const fragmentData = yaml.parse(fragmentContent);
324
+
325
+ if (!fragmentData || typeof fragmentData !== 'object') {
326
+ console.warn(`⚠️ Invalid fragment data in: ${filePath}`);
327
+ continue;
328
+ }
329
+
330
+ // Merge paths
331
+ if (fragmentData.paths && typeof fragmentData.paths === 'object') {
332
+ Object.assign(mergedContent.paths, fragmentData.paths);
333
+ }
334
+
335
+ // Merge components
336
+ if (fragmentData.components && typeof fragmentData.components === 'object') {
337
+ if (fragmentData.components.schemas) {
338
+ Object.assign(mergedContent.components.schemas, fragmentData.components.schemas);
339
+ }
340
+ // Merge other component types
341
+ const componentTypes = ['responses', 'parameters', 'examples', 'requestBodies', 'headers', 'securitySchemes', 'links', 'callbacks'];
342
+ for (const componentType of componentTypes) {
343
+ if (fragmentData.components[componentType]) {
344
+ if (!mergedContent.components[componentType]) {
345
+ mergedContent.components[componentType] = {};
346
+ }
347
+ Object.assign(mergedContent.components[componentType], fragmentData.components[componentType]);
348
+ }
349
+ }
350
+ }
351
+ } catch (error) {
352
+ console.warn(`⚠️ Failed to parse fragment ${filePath}: ${error.message}`);
353
+ }
354
+ }
355
+
356
+ // Validate merged content
357
+ if (Object.keys(mergedContent.paths).length === 0) {
358
+ throw new Error('No valid paths found in any fragments');
359
+ }
360
+
361
+ fs.writeFileSync(entrypoint, yaml.stringify(mergedContent), 'utf8');
362
+
363
+ if (!quiet) {
364
+ console.log(`📄 Created merged entrypoint with ${Object.keys(mergedContent.paths).length} paths`);
365
+ }
366
+ } else {
367
+ // Single file or string entrypoint
368
+ entrypoint = Array.isArray(fragmentFiles) ? fragmentFiles[0] : fragmentFiles;
369
+
370
+ if (!fs.existsSync(entrypoint)) {
371
+ throw new Error(`Entrypoint file not found: ${entrypoint}`);
372
+ }
373
+ }
374
+
375
+ // Ensure output directory exists
376
+ const outputDir = path.dirname(outputPath);
377
+ fs.mkdirSync(outputDir, { recursive: true });
378
+
379
+ let result;
380
+ if (bundler === 'swagger-cli') {
381
+ result = spawnSync('swagger-cli', ['bundle', entrypoint, '-o', outputPath, '-t', 'yaml'], {
382
+ stdio,
383
+ timeout
384
+ });
385
+ } else if (bundler === 'redocly') {
386
+ result = spawnSync('redocly', ['bundle', entrypoint, '--output', outputPath], {
387
+ stdio,
388
+ timeout
389
+ });
390
+ } else if (bundler === 'npx redocly') {
391
+ result = spawnSync('npx', ['redocly', 'bundle', entrypoint, '--output', outputPath], {
392
+ stdio,
393
+ timeout
394
+ });
395
+ } else if (bundler === 'npx @redocly/cli') {
396
+ result = spawnSync('npx', ['@redocly/cli', 'bundle', entrypoint, '--output', outputPath], {
397
+ stdio,
398
+ timeout
399
+ });
400
+ } else {
401
+ throw new Error(`Unknown bundler: ${bundler}`);
402
+ }
403
+
404
+ if (result.error) {
405
+ if (result.error.code === 'ETIMEDOUT') {
406
+ throw new Error(`Bundler timed out after ${timeout / 1000} seconds`);
407
+ }
408
+ throw new Error(`Bundler execution failed: ${result.error.message}`);
409
+ }
410
+
411
+ if (result.status !== 0) {
412
+ const errorMsg = result.stderr ? result.stderr.toString() : 'Unknown error';
413
+ throw new Error(`${bundler} bundle failed with exit code ${result.status}: ${errorMsg}`);
414
+ }
415
+
416
+ // Verify output file was created
417
+ if (!fs.existsSync(outputPath)) {
418
+ throw new Error(`Bundler completed but output file not found: ${outputPath}`);
419
+ }
420
+
421
+ const stats = fs.statSync(outputPath);
422
+ if (stats.size === 0) {
423
+ throw new Error(`Bundler created empty output file: ${outputPath}`);
424
+ }
425
+
426
+ if (!quiet) {
427
+ console.log(`✅ Bundle created: ${outputPath} (${Math.round(stats.size / 1024)}KB)`);
428
+ }
429
+
430
+ } catch (error) {
431
+ // Clean up temporary entrypoint file on error
432
+ if (entrypoint && entrypoint !== fragmentFiles && fs.existsSync(entrypoint)) {
433
+ try {
434
+ fs.unlinkSync(entrypoint);
435
+ } catch {
436
+ // Ignore cleanup errors
437
+ }
438
+ }
439
+ throw error;
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Update bundle metadata, enforce a deterministic key order, and rewrite the bundled OpenAPI YAML.
445
+ *
446
+ * Reads the bundled YAML at `filePath`, validates and augments its `info` object (titles, descriptions,
447
+ * version fields and x- metadata) based on `options.surface` and provided version information, sorts
448
+ * object keys deterministically, and writes the updated YAML back to `filePath`.
449
+ *
450
+ * @param {string} filePath - Path to the bundled OpenAPI YAML file to process.
451
+ * @param {Object} options - Processing options.
452
+ * @param {'admin'|'connect'} options.surface - API surface to target; affects title and description.
453
+ * @param {string} [options.tag] - Git tag used for versioning (may be normalized internally).
454
+ * @param {string} [options.normalizedTag] - Pre-normalized version string to use instead of `tag`.
455
+ * @param {string} [options.majorMinor] - Major.minor version to set in `info.version`.
456
+ * @param {string} [options.adminMajor] - Admin API major version to set as `x-admin-api-major`.
457
+ * @param {boolean} [options.useAdminMajorVersion] - When true and surface is 'admin', prefer `adminMajor` for `info.version`.
458
+ * @param {boolean} [quiet=false] - Suppress console output when true.
459
+ * @returns {Object} The processed OpenAPI bundle object with keys sorted deterministically.
460
+ * @throws {Error} If inputs are missing/invalid, the file is absent or empty, YAML parsing fails, or processing cannot complete.
461
+ */
462
+ function postProcessBundle(filePath, options, quiet = false) {
463
+ if (!filePath || typeof filePath !== 'string') {
464
+ throw new Error('Bundle file not found');
465
+ }
466
+
467
+ if (!fs.existsSync(filePath)) {
468
+ throw new Error('Bundle file not found');
469
+ }
470
+
471
+ if (!options || typeof options !== 'object') {
472
+ throw new Error('Missing required options');
473
+ }
474
+
475
+ const { surface, tag, majorMinor, adminMajor, normalizedTag, useAdminMajorVersion } = options;
476
+
477
+ if (!surface || !['admin', 'connect'].includes(surface)) {
478
+ throw new Error('Invalid API surface');
479
+ }
480
+
481
+ // Require at least one version identifier
482
+ if (!tag && !normalizedTag && !majorMinor) {
483
+ throw new Error('Missing required options');
484
+ }
485
+
486
+ try {
487
+ const content = fs.readFileSync(filePath, 'utf8');
488
+ if (!content.trim()) {
489
+ throw new Error('Bundle file is empty');
490
+ }
491
+
492
+ let bundle;
493
+ try {
494
+ bundle = yaml.parse(content);
495
+ } catch (parseError) {
496
+ throw new Error(`Invalid YAML in bundle file: ${parseError.message}`);
497
+ }
498
+
499
+ if (!bundle || typeof bundle !== 'object') {
500
+ throw new Error('Bundle file does not contain valid OpenAPI structure');
501
+ }
502
+
503
+ // Normalize the tag and extract version info
504
+ const normalizedVersion = normalizedTag || (tag ? normalizeTag(tag) : '1.0.0');
505
+ let versionMajorMinor;
506
+
507
+ if (useAdminMajorVersion && surface === 'admin' && adminMajor) {
508
+ // Use admin major version for info.version when flag is set
509
+ versionMajorMinor = adminMajor;
510
+ } else {
511
+ // Use normalized tag version (default behavior)
512
+ versionMajorMinor = majorMinor || (normalizedVersion !== '1.0.0' ? getMajorMinor(normalizedVersion) : '1.0');
513
+ }
514
+
515
+ // Update info section with proper metadata
516
+ if (!bundle.info) {
517
+ bundle.info = {};
518
+ }
519
+
520
+ bundle.info.version = versionMajorMinor;
521
+
522
+ if (surface === 'admin') {
523
+ bundle.info.title = 'Redpanda Admin API';
524
+ bundle.info.description = 'Redpanda Admin API specification';
525
+ if (adminMajor) {
526
+ bundle.info['x-admin-api-major'] = adminMajor;
527
+ }
528
+ } else if (surface === 'connect') {
529
+ bundle.info.title = 'Redpanda Connect RPCs';
530
+ bundle.info.description = 'Redpanda Connect API specification';
531
+ }
532
+
533
+ // Additional metadata expected by tests
534
+ if (tag || normalizedTag) {
535
+ bundle.info['x-redpanda-core-version'] = tag || normalizedTag || normalizedVersion;
536
+ }
537
+ bundle.info['x-generated-at'] = new Date().toISOString();
538
+ bundle.info['x-generator'] = 'redpanda-docs-openapi-bundler';
539
+
540
+ // Sort keys for deterministic output
541
+ const sortedBundle = sortObjectKeys(bundle);
542
+
543
+ // Write back to file
544
+ fs.writeFileSync(filePath, yaml.stringify(sortedBundle, {
545
+ lineWidth: 0,
546
+ minContentWidth: 0,
547
+ indent: 2
548
+ }), 'utf8');
549
+
550
+ if (!quiet) {
551
+ console.log(`📝 Updated bundle metadata: version=${normalizedVersion}`);
552
+ }
553
+
554
+ return sortedBundle;
555
+
556
+ } catch (error) {
557
+ throw new Error(`Post-processing failed: ${error.message}`);
558
+ }
559
+ }
560
+
561
+ /**
562
+ * Bundle OpenAPI fragments for the specified API surface(s) from a repository tag and write the resulting bundled YAML files to disk.
563
+ *
564
+ * @param {Object} options - Configuration options.
565
+ * @param {string} options.tag - Git tag to checkout (e.g., 'v25.1.1').
566
+ * @param {'admin'|'connect'|'both'} options.surface - API surface to process.
567
+ * @param {string} [options.output] - Standalone output file path; when provided, used for the single output file.
568
+ * @param {string} [options.outAdmin] - Output path for the admin API when integrating with doc-tools mode.
569
+ * @param {string} [options.outConnect] - Output path for the connect API when integrating with doc-tools mode.
570
+ * @param {string} [options.repo] - Repository URL to clone (defaults to https://github.com/redpanda-data/redpanda.git).
571
+ * @param {string} [options.adminMajor] - Admin API major version string used for metadata (e.g., 'v2.0.0').
572
+ * @param {boolean} [options.useAdminMajorVersion] - When true and processing the admin surface, use `adminMajor` for the bundle info.version.
573
+ * @param {boolean} [options.quiet=false] - Suppress logging to stdout/stderr when true.
574
+ * @returns {Object|Object[]} An object (for a single surface) or an array of objects (for both surfaces) with fields:
575
+ * - surface: processed surface name ('admin' or 'connect'),
576
+ * - outputPath: final written file path,
577
+ * - fragmentCount: number of OpenAPI fragment files processed,
578
+ * - bundler: name or command of the bundler used.
579
+ */
580
+ async function bundleOpenAPI(options) {
581
+ const { tag, surface, output, outAdmin, outConnect, repo, adminMajor, useAdminMajorVersion, quiet = false } = options;
582
+
583
+ // Validate required parameters
584
+ if (!tag) {
585
+ throw new Error('Git tag is required');
586
+ }
587
+
588
+ if (!surface || !['admin', 'connect', 'both'].includes(surface)) {
589
+ throw new Error('API surface must be "admin", "connect", or "both"');
590
+ }
591
+
592
+ // Handle different surface options
593
+ const surfaces = surface === 'both' ? ['admin', 'connect'] : [surface];
594
+ const results = [];
595
+
596
+ const tempDir = fs.mkdtempSync(path.join(process.cwd(), 'openapi-bundle-'));
597
+
598
+ // Set up cleanup handlers
599
+ const cleanup = () => {
600
+ try {
601
+ if (fs.existsSync(tempDir)) {
602
+ fs.rmSync(tempDir, { recursive: true, force: true });
603
+ }
604
+ } catch (error) {
605
+ console.error(`Warning: Failed to cleanup temporary directory: ${error.message}`);
606
+ }
607
+ };
608
+
609
+ // Create dedicated handlers that clean up and then terminate
610
+ const cleanupAndExit = (signal) => {
611
+ return () => {
612
+ cleanup();
613
+ process.exit(signal === 'SIGTERM' ? 0 : 1);
614
+ };
615
+ };
616
+
617
+ const cleanupAndCrash = (error) => {
618
+ cleanup();
619
+ console.error('Fatal error:', error);
620
+ process.exit(1);
621
+ };
622
+
623
+ // Handle graceful shutdown and crashes
624
+ const sigintHandler = cleanupAndExit('SIGINT');
625
+ const sigtermHandler = cleanupAndExit('SIGTERM');
626
+
627
+ process.on('SIGINT', sigintHandler);
628
+ process.on('SIGTERM', sigtermHandler);
629
+ process.on('uncaughtException', cleanupAndCrash);
630
+
631
+ try {
632
+ // Clone repository (only once for all surfaces)
633
+ if (!quiet) {
634
+ console.log('📥 Cloning redpanda repository...');
635
+ }
636
+
637
+ const repositoryUrl = repo || 'https://github.com/redpanda-data/redpanda.git';
638
+
639
+ try {
640
+ execSync(`git clone --depth 1 --branch ${tag} ${repositoryUrl} redpanda`, {
641
+ cwd: tempDir,
642
+ stdio: quiet ? 'ignore' : 'inherit',
643
+ timeout: 60000 // 1 minute timeout
644
+ });
645
+ } catch (cloneError) {
646
+ throw new Error(`Failed to clone repository: ${cloneError.message}`);
647
+ }
648
+
649
+ const repoDir = path.join(tempDir, 'redpanda');
650
+
651
+ // Verify repository was cloned
652
+ if (!fs.existsSync(repoDir)) {
653
+ throw new Error('Repository clone failed - directory not found');
654
+ }
655
+
656
+ // Run buf generate
657
+ if (!quiet) {
658
+ console.log('🔧 Running buf generate...');
659
+ }
660
+
661
+ try {
662
+ execSync('buf generate --template buf.gen.openapi.yaml', {
663
+ cwd: repoDir,
664
+ stdio: quiet ? 'ignore' : 'inherit',
665
+ timeout: 120000 // 2 minutes timeout
666
+ });
667
+ } catch (bufError) {
668
+ throw new Error(`buf generate failed: ${bufError.message}`);
669
+ }
670
+
671
+ // Process each surface
672
+ for (const currentSurface of surfaces) {
673
+ // Determine output path based on mode (standalone vs doc-tools integration)
674
+ let finalOutput;
675
+ if (output) {
676
+ // Standalone mode with explicit output
677
+ finalOutput = output;
678
+ } else if (currentSurface === 'admin' && outAdmin) {
679
+ // Doc-tools mode with admin output
680
+ finalOutput = outAdmin;
681
+ } else if (currentSurface === 'connect' && outConnect) {
682
+ // Doc-tools mode with connect output
683
+ finalOutput = outConnect;
684
+ } else {
685
+ // Default paths
686
+ finalOutput = currentSurface === 'admin'
687
+ ? 'admin/redpanda-admin-api.yaml'
688
+ : 'connect/redpanda-connect-api.yaml';
689
+ }
690
+
691
+ if (!quiet) {
692
+ console.log(`🚀 Bundling OpenAPI for ${currentSurface} API (tag: ${tag})`);
693
+ console.log(`📁 Output: ${finalOutput}`);
694
+ }
695
+
696
+ // Find OpenAPI fragments
697
+ const fragmentFiles = createEntrypoint(repoDir, currentSurface);
698
+
699
+ if (!quiet) {
700
+ console.log(`📋 Found ${fragmentFiles.length} OpenAPI fragments`);
701
+ fragmentFiles.forEach(file => {
702
+ const relativePath = path.relative(repoDir, file);
703
+ console.log(` ${relativePath}`);
704
+ });
705
+ }
706
+
707
+ // Detect and use bundler
708
+ const bundler = detectBundler(quiet);
709
+
710
+ // Bundle the OpenAPI fragments
711
+ if (!quiet) {
712
+ console.log('🔄 Bundling OpenAPI fragments...');
713
+ }
714
+
715
+ const tempOutput = path.join(tempDir, `bundled-${currentSurface}.yaml`);
716
+ await runBundler(bundler, fragmentFiles, tempOutput, tempDir, quiet);
717
+
718
+ // Post-process the bundle
719
+ if (!quiet) {
720
+ console.log('📝 Post-processing bundle...');
721
+ }
722
+
723
+ const postProcessOptions = {
724
+ surface: currentSurface,
725
+ tag: tag,
726
+ majorMinor: getMajorMinor(normalizeTag(tag)),
727
+ adminMajor: adminMajor,
728
+ useAdminMajorVersion: useAdminMajorVersion
729
+ };
730
+
731
+ postProcessBundle(tempOutput, postProcessOptions, quiet);
732
+
733
+ // Move to final output location
734
+ const outputDir = path.dirname(finalOutput);
735
+ if (!fs.existsSync(outputDir)) {
736
+ fs.mkdirSync(outputDir, { recursive: true });
737
+ }
738
+
739
+ fs.copyFileSync(tempOutput, finalOutput);
740
+
741
+ if (!quiet) {
742
+ const stats = fs.statSync(finalOutput);
743
+ console.log(`✅ Bundle complete: ${finalOutput} (${Math.round(stats.size / 1024)}KB)`);
744
+ }
745
+
746
+ results.push({
747
+ surface: currentSurface,
748
+ outputPath: finalOutput,
749
+ fragmentCount: fragmentFiles.length,
750
+ bundler: bundler
751
+ });
752
+ }
753
+
754
+ return results.length === 1 ? results[0] : results;
755
+
756
+ } catch (error) {
757
+ if (!quiet) {
758
+ console.error(`❌ Bundling failed: ${error.message}`);
759
+ }
760
+ throw error;
761
+ } finally {
762
+ // Remove event handlers to restore default behavior
763
+ process.removeListener('SIGINT', sigintHandler);
764
+ process.removeListener('SIGTERM', sigtermHandler);
765
+ process.removeListener('uncaughtException', cleanupAndCrash);
766
+
767
+ cleanup();
768
+ }
769
+ }
770
+
771
+ // Export functions for testing
772
+ module.exports = {
773
+ bundleOpenAPI,
774
+ normalizeTag,
775
+ getMajorMinor,
776
+ sortObjectKeys,
777
+ detectBundler,
778
+ createEntrypoint,
779
+ postProcessBundle
780
+ };
781
+
782
+ // CLI interface if run directly
783
+ if (require.main === module) {
784
+ const { program } = require('commander');
785
+
786
+ program
787
+ .name('bundle-openapi')
788
+ .description('Bundle OpenAPI fragments from Redpanda repository')
789
+ .requiredOption('-t, --tag <tag>', 'Git tag to checkout (e.g., v25.1.1)')
790
+ .requiredOption('-s, --surface <surface>', 'API surface', (value) => {
791
+ if (!['admin', 'connect', 'both'].includes(value)) {
792
+ throw new Error('Invalid API surface. Must be "admin", "connect", or "both"');
793
+ }
794
+ return value;
795
+ })
796
+ .option('-o, --output <path>', 'Output file path (defaults: admin/redpanda-admin-api.yaml or connect/redpanda-connect-api.yaml)')
797
+ .option('--out-admin <path>', 'Output path for admin API', 'admin/redpanda-admin-api.yaml')
798
+ .option('--out-connect <path>', 'Output path for connect API', 'connect/redpanda-connect-api.yaml')
799
+ .option('--repo <url>', 'Repository URL', 'https://github.com/redpanda-data/redpanda.git')
800
+ .option('--admin-major <string>', 'Admin API major version', 'v2.0.0')
801
+ .option('--use-admin-major-version', 'Use admin major version for info.version instead of git tag', false)
802
+ .option('-q, --quiet', 'Suppress output', false)
803
+ .action(async (options) => {
804
+ try {
805
+ await bundleOpenAPI(options);
806
+ process.exit(0);
807
+ } catch (error) {
808
+ console.error(`Error: ${error.message}`);
809
+ process.exit(1);
810
+ }
811
+ });
812
+
813
+ program.parse();
814
+ }