@redpanda-data/docs-extensions-and-macros 5.0.4 → 5.1.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,1650 @@
1
+ 'use strict'
2
+
3
+ const { spawnSync } = require('child_process')
4
+ const path = require('path')
5
+ const fs = require('fs')
6
+ const os = require('os')
7
+ const semver = require('semver')
8
+ const { findRepoRoot } = require('../../cli-utils/doc-tools-utils')
9
+ const { generateRpkDocs, applyOverridesToTree, resolveReferences } = require('./generate-rpk-docs')
10
+ const { generateRpkDiff, printDiffReport, generateWhatsNewSection } = require('./report-delta')
11
+ const { loadAndValidateOverrides, ValidationResult } = require('./validate-overrides')
12
+ const { validateDirectory, formatResults } = require('./validate-output')
13
+
14
+ /**
15
+ * Known rpk plugins that are managed separately (have install/uninstall commands)
16
+ */
17
+ const KNOWN_PLUGINS = ['ai', 'check', 'connect', 'oxla']
18
+
19
+ /**
20
+ * Parse Go version from 'go version' output
21
+ * @param {string} versionOutput - Output from 'go version' command
22
+ * @returns {string|null} Semver-compatible version string or null
23
+ */
24
+ function parseGoVersion(versionOutput) {
25
+ // go version go1.26.4 darwin/arm64 -> 1.26.4
26
+ const match = versionOutput.match(/go(\d+\.\d+(?:\.\d+)?)/)
27
+ return match ? match[1] : null
28
+ }
29
+
30
+ /**
31
+ * Get required Go version from go.mod file
32
+ * @param {string} sourcePath - Path to rpk source directory
33
+ * @returns {string|null} Required Go version or null
34
+ */
35
+ function getRequiredGoVersion(sourcePath) {
36
+ const goModPath = path.join(sourcePath, 'go.mod')
37
+ if (!fs.existsSync(goModPath)) {
38
+ return null
39
+ }
40
+ const content = fs.readFileSync(goModPath, 'utf8')
41
+ // go 1.26.4
42
+ const match = content.match(/^go\s+(\d+\.\d+(?:\.\d+)?)/m)
43
+ return match ? match[1] : null
44
+ }
45
+
46
+ /**
47
+ * Check if installed Go version meets requirements
48
+ * @param {string} installedVersion - Installed Go version
49
+ * @param {string} requiredVersion - Required Go version from go.mod
50
+ * @returns {boolean} True if version is sufficient
51
+ */
52
+ function checkGoVersionSufficient(installedVersion, requiredVersion) {
53
+ // Normalize to semver format (add .0 if needed)
54
+ const normalize = (v) => {
55
+ const parts = v.split('.')
56
+ while (parts.length < 3) parts.push('0')
57
+ return parts.join('.')
58
+ }
59
+ return semver.gte(normalize(installedVersion), normalize(requiredVersion))
60
+ }
61
+
62
+
63
+ /**
64
+ * Extract all command paths from a command tree
65
+ * @param {Object} tree - Command tree
66
+ * @param {string} prefix - Command path prefix
67
+ * @returns {Set<string>} Set of all command paths
68
+ */
69
+ function extractCommandPaths(tree, prefix = '') {
70
+ const paths = new Set()
71
+ const fullPath = prefix ? `${prefix} ${tree.name}` : tree.name || 'rpk'
72
+ paths.add(fullPath)
73
+
74
+ if (tree.commands && Array.isArray(tree.commands)) {
75
+ for (const cmd of tree.commands) {
76
+ const childPaths = extractCommandPaths(cmd, fullPath)
77
+ for (const p of childPaths) {
78
+ paths.add(p)
79
+ }
80
+ }
81
+ }
82
+ return paths
83
+ }
84
+
85
+ /**
86
+ * Detect Linux-only commands by comparing Linux and Darwin builds
87
+ * @param {Object} linuxTree - Command tree from Linux build
88
+ * @param {Object} darwinTree - Command tree from Darwin/macOS build
89
+ * @returns {Set<string>} Commands that exist only on Linux
90
+ */
91
+ function detectLinuxOnlyByComparison(linuxTree, darwinTree) {
92
+ const linuxCommands = extractCommandPaths(linuxTree)
93
+ const darwinCommands = extractCommandPaths(darwinTree)
94
+
95
+ // Find commands in Linux but not in Darwin
96
+ const linuxOnly = new Set()
97
+ for (const cmd of linuxCommands) {
98
+ if (!darwinCommands.has(cmd)) {
99
+ linuxOnly.add(cmd)
100
+ }
101
+ }
102
+
103
+ return linuxOnly
104
+ }
105
+
106
+ /**
107
+ * Platform identifiers
108
+ */
109
+ const PLATFORMS = {
110
+ LINUX: 'linux',
111
+ DARWIN: 'darwin',
112
+ WINDOWS: 'windows'
113
+ }
114
+
115
+ /**
116
+ * Get current platform identifier
117
+ * @returns {string}
118
+ */
119
+ function getCurrentPlatform() {
120
+ const platform = os.platform()
121
+ if (platform === 'darwin') return PLATFORMS.DARWIN
122
+ if (platform === 'win32') return PLATFORMS.WINDOWS
123
+ return PLATFORMS.LINUX
124
+ }
125
+
126
+ /**
127
+ * Check if a command is a plugin by looking for install/uninstall subcommands
128
+ * @param {Object} command - Command object from rpk tree
129
+ * @returns {boolean}
130
+ */
131
+ function isPlugin(command) {
132
+ if (!command.commands || !Array.isArray(command.commands)) return false
133
+ const subcommandNames = command.commands.map(c => c.name)
134
+ return subcommandNames.includes('install') && subcommandNames.includes('uninstall')
135
+ }
136
+
137
+ /**
138
+ * Detect all plugins in the rpk tree
139
+ * @param {Object} tree - Full rpk command tree
140
+ * @returns {string[]} Array of plugin names
141
+ */
142
+ function detectPlugins(tree) {
143
+ if (!tree.commands) return []
144
+ return tree.commands
145
+ .filter(cmd => isPlugin(cmd))
146
+ .map(cmd => cmd.name)
147
+ }
148
+
149
+ /**
150
+ * Prepare rpk source directory from a GitHub ref (branch or tag)
151
+ * If sourcePath is provided and is a git repo, checkout the ref there
152
+ * If no sourcePath, do a sparse checkout from GitHub to a temp directory
153
+ * @param {string} sourceRef - Git ref (branch or tag, e.g., 'dev', 'v26.2.0')
154
+ * @param {string} [sourcePath] - Optional local path to existing repo
155
+ * @returns {string} Path to the rpk source directory (src/go/rpk)
156
+ */
157
+ function prepareSourceFromRef(sourceRef, sourcePath = null) {
158
+ if (sourcePath) {
159
+ // Use existing local repo, checkout the specified ref
160
+ const absolutePath = path.resolve(sourcePath)
161
+
162
+ // Check if it's the rpk subdirectory or the repo root
163
+ let repoRoot = absolutePath
164
+ if (absolutePath.endsWith('src/go/rpk')) {
165
+ repoRoot = absolutePath.replace(/\/src\/go\/rpk$/, '')
166
+ } else if (fs.existsSync(path.join(absolutePath, 'src', 'go', 'rpk'))) {
167
+ // It's the repo root
168
+ } else {
169
+ throw new Error(
170
+ `Cannot determine repo root from ${absolutePath}\n` +
171
+ `Provide either the repo root or src/go/rpk directory.`
172
+ )
173
+ }
174
+
175
+ // Verify it's a git repo
176
+ if (!fs.existsSync(path.join(repoRoot, '.git'))) {
177
+ throw new Error(`Not a git repository: ${repoRoot}`)
178
+ }
179
+
180
+ console.log(`Checking out ref '${sourceRef}' in ${repoRoot}...`)
181
+
182
+ // Fetch and checkout
183
+ const fetchResult = spawnSync('git', ['fetch', 'origin', sourceRef], {
184
+ cwd: repoRoot,
185
+ encoding: 'utf8',
186
+ timeout: 120000
187
+ })
188
+
189
+ if (fetchResult.status !== 0) {
190
+ console.warn(`Warning: Could not fetch ref '${sourceRef}': ${fetchResult.stderr}`)
191
+ }
192
+
193
+ const checkoutResult = spawnSync('git', ['checkout', sourceRef], {
194
+ cwd: repoRoot,
195
+ encoding: 'utf8',
196
+ timeout: 30000
197
+ })
198
+
199
+ if (checkoutResult.status !== 0) {
200
+ throw new Error(`Failed to checkout ref '${sourceRef}': ${checkoutResult.stderr}`)
201
+ }
202
+
203
+ console.log(`Checked out ${sourceRef}`)
204
+ return path.join(repoRoot, 'src', 'go', 'rpk')
205
+ }
206
+
207
+ // No local path - do sparse checkout from GitHub
208
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rpk-source-'))
209
+ const repoDir = path.join(tmpDir, 'redpanda')
210
+
211
+ console.log(`Sparse-cloning redpanda repo (ref: ${sourceRef}) to ${repoDir}...`)
212
+
213
+ // Clone with sparse checkout
214
+ const cloneResult = spawnSync('git', [
215
+ 'clone',
216
+ '--depth', '1',
217
+ '--filter=blob:none',
218
+ '--sparse',
219
+ '--branch', sourceRef,
220
+ 'https://github.com/redpanda-data/redpanda.git',
221
+ repoDir
222
+ ], {
223
+ encoding: 'utf8',
224
+ timeout: 120000,
225
+ stdio: ['pipe', 'pipe', 'pipe']
226
+ })
227
+
228
+ if (cloneResult.status !== 0) {
229
+ throw new Error(
230
+ `Failed to clone redpanda repo with ref '${sourceRef}'.\n` +
231
+ `Make sure the branch or tag exists.\n` +
232
+ `Error: ${cloneResult.stderr}`
233
+ )
234
+ }
235
+
236
+ // Set sparse checkout to only get rpk
237
+ const sparseResult = spawnSync('git', ['sparse-checkout', 'set', 'src/go/rpk'], {
238
+ cwd: repoDir,
239
+ encoding: 'utf8',
240
+ timeout: 60000
241
+ })
242
+
243
+ if (sparseResult.status !== 0) {
244
+ throw new Error(`Failed to set sparse checkout: ${sparseResult.stderr}`)
245
+ }
246
+
247
+ console.log(`Sparse checkout complete`)
248
+ return path.join(repoDir, 'src', 'go', 'rpk')
249
+ }
250
+
251
+ /**
252
+ * Fetch rpk tree by running from Go source code
253
+ * Useful for pre-releases before Docker images are published
254
+ * @param {string} sourcePath - Path to rpk Go source directory (e.g., ~/redpanda/src/go/rpk)
255
+ * @returns {Object} Parsed JSON tree
256
+ */
257
+ function fetchRpkTreeFromSource(sourcePath) {
258
+ // Verify the source path exists
259
+ if (!fs.existsSync(sourcePath)) {
260
+ throw new Error(
261
+ `rpk source directory not found: ${sourcePath}\n` +
262
+ 'To use --from-source, you need a local checkout of the redpanda repository.\n' +
263
+ 'Clone it with: git clone https://github.com/redpanda-data/redpanda.git\n' +
264
+ 'Then point to: <repo>/src/go/rpk'
265
+ )
266
+ }
267
+
268
+ // Verify it looks like the right directory (should have cmd/rpk)
269
+ const mainPath = path.join(sourcePath, 'cmd', 'rpk', 'main.go')
270
+ if (!fs.existsSync(mainPath)) {
271
+ throw new Error(
272
+ `Invalid rpk source directory: ${sourcePath}\n` +
273
+ `Expected to find cmd/rpk/main.go. Make sure you point to the src/go/rpk directory.`
274
+ )
275
+ }
276
+
277
+ // Check if Go is installed
278
+ const goCheck = spawnSync('go', ['version'], { encoding: 'utf8', timeout: 5000 })
279
+ if (goCheck.status !== 0) {
280
+ throw new Error(
281
+ 'Go is required for --from-source but was not found.\n' +
282
+ 'Install Go from https://go.dev/ and ensure it\'s in your PATH.'
283
+ )
284
+ }
285
+
286
+ console.log(`Building and running rpk from source at ${sourcePath}...`)
287
+ console.log(`Go version: ${goCheck.stdout.trim()}`)
288
+
289
+ // Check Go version meets go.mod requirements
290
+ const installedGoVersion = parseGoVersion(goCheck.stdout)
291
+ const requiredGoVersion = getRequiredGoVersion(sourcePath)
292
+ if (installedGoVersion && requiredGoVersion) {
293
+ if (!checkGoVersionSufficient(installedGoVersion, requiredGoVersion)) {
294
+ throw new Error(
295
+ `Go version mismatch: installed ${installedGoVersion}, required >= ${requiredGoVersion}\n` +
296
+ `The rpk source (go.mod) requires Go ${requiredGoVersion} or newer.\n` +
297
+ 'Update Go: brew upgrade go (macOS) or download from https://go.dev/dl/'
298
+ )
299
+ }
300
+ }
301
+
302
+ // Run rpk directly from source using go run
303
+ const result = spawnSync('go', ['run', 'cmd/rpk/main.go', '--print-tree'], {
304
+ cwd: sourcePath,
305
+ encoding: 'utf8',
306
+ timeout: 120000, // 2 minutes (includes build time)
307
+ maxBuffer: 50 * 1024 * 1024
308
+ })
309
+
310
+ if (result.status !== 0) {
311
+ const stderr = result.stderr || ''
312
+ if (stderr.includes('unknown flag')) {
313
+ throw new Error(
314
+ `rpk source does not support --print-tree flag.\n` +
315
+ `This feature requires rpk source from after the --print-tree feature was added.\n` +
316
+ 'Update your source checkout: cd <repo> && git pull origin dev'
317
+ )
318
+ }
319
+ if (stderr.includes('go.mod') || stderr.includes('module')) {
320
+ throw new Error(
321
+ `Go module error while building rpk: ${stderr}\n` +
322
+ 'Try running "go mod download" in the source directory first.'
323
+ )
324
+ }
325
+ throw new Error(
326
+ `Failed to build/run rpk from source: ${stderr}\n` +
327
+ 'Common fixes:\n' +
328
+ ' 1. Update Go to the latest version\n' +
329
+ ' 2. Run "go mod download" in the source directory\n' +
330
+ ' 3. Ensure the source is up to date: git pull origin dev'
331
+ )
332
+ }
333
+
334
+ try {
335
+ return JSON.parse(result.stdout)
336
+ } catch (err) {
337
+ throw new Error(
338
+ `Failed to parse rpk tree JSON from source build: ${err.message}\n` +
339
+ 'The build succeeded but the output was not valid JSON.\n' +
340
+ 'This may indicate a version mismatch or corrupted source.'
341
+ )
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Build rpk from Go source inside a Linux Docker container (optional optimization).
347
+ * Builds rpk binary, installs plugins, then runs --print-tree for complete command coverage.
348
+ * Falls back to native Go build if Docker is unavailable.
349
+ * @param {string} sourcePath - Path to rpk Go source directory (e.g., ~/redpanda/src/go/rpk)
350
+ * @returns {Object} Parsed JSON tree
351
+ */
352
+ function fetchRpkTreeFromLinuxSource(sourcePath) {
353
+ // Resolve to absolute path
354
+ const absoluteSourcePath = path.resolve(sourcePath)
355
+
356
+ // Verify the source path exists
357
+ if (!fs.existsSync(absoluteSourcePath)) {
358
+ throw new Error(
359
+ `rpk source directory not found: ${absoluteSourcePath}\n` +
360
+ 'Expected a checkout of the redpanda repository.\n' +
361
+ 'Clone it with: git clone https://github.com/redpanda-data/redpanda.git'
362
+ )
363
+ }
364
+
365
+ // Verify it looks like the right directory (should have cmd/rpk)
366
+ const mainPath = path.join(absoluteSourcePath, 'cmd', 'rpk', 'main.go')
367
+ if (!fs.existsSync(mainPath)) {
368
+ throw new Error(
369
+ `Invalid rpk source directory: ${absoluteSourcePath}\n` +
370
+ 'Expected to find cmd/rpk/main.go.\n' +
371
+ 'Make sure you point to the src/go/rpk directory inside your redpanda checkout.'
372
+ )
373
+ }
374
+
375
+ // Docker is optional - used when available for Linux plugin support
376
+ const dockerCheck = spawnSync('docker', ['--version'], { encoding: 'utf8', timeout: 5000 })
377
+ if (dockerCheck.status !== 0) {
378
+ // Docker not available - this function shouldn't be called
379
+ throw new Error(
380
+ 'Docker not available for Linux container build.\n' +
381
+ 'Use fetchRpkTreeFromSource() for native Go build instead.'
382
+ )
383
+ }
384
+
385
+ console.log(`Building and running rpk from source in Linux container...`)
386
+ console.log(`Source path: ${absoluteSourcePath}`)
387
+
388
+ // Start a container with the Go image, mount source, build rpk, install plugins, then print-tree
389
+ // Use a long-running container so we can run multiple commands
390
+ console.log('Starting build container...')
391
+ const createResult = spawnSync('docker', [
392
+ 'run', '-d', '--rm',
393
+ '-v', `${absoluteSourcePath}:/rpk-source:ro`,
394
+ '-w', '/rpk-source',
395
+ 'golang:1',
396
+ 'sh', '-c', 'sleep 600' // Keep container alive for 10 minutes
397
+ ], {
398
+ encoding: 'utf8',
399
+ timeout: 60000
400
+ })
401
+
402
+ if (createResult.status !== 0) {
403
+ const stderr = createResult.stderr || ''
404
+ if (stderr.includes('Cannot connect to the Docker daemon')) {
405
+ throw new Error(
406
+ 'Docker daemon is not running.\n' +
407
+ 'Start Docker Desktop or the Docker service and try again.'
408
+ )
409
+ }
410
+ throw new Error(
411
+ `Failed to create build container: ${stderr}\n` +
412
+ 'Make sure Docker is running and has sufficient resources.'
413
+ )
414
+ }
415
+
416
+ const containerId = createResult.stdout.trim()
417
+ console.log(`Build container started: ${containerId.substring(0, 12)}`)
418
+
419
+ try {
420
+ // Step 1: Build rpk binary
421
+ console.log('Building rpk binary...')
422
+ const buildResult = spawnSync('docker', [
423
+ 'exec', containerId,
424
+ 'go', 'build', '-o', '/tmp/rpk', './cmd/rpk'
425
+ ], {
426
+ encoding: 'utf8',
427
+ timeout: 300000 // 5 minutes for build
428
+ })
429
+
430
+ if (buildResult.status !== 0) {
431
+ const stderr = buildResult.stderr || ''
432
+ throw new Error(
433
+ `Failed to build rpk in Linux container: ${stderr}\n` +
434
+ 'Common causes:\n' +
435
+ ' 1. Source code is out of date - run: git pull origin dev\n' +
436
+ ' 2. Go module issues - the container will download dependencies automatically\n' +
437
+ ' 3. Insufficient Docker resources - check Docker Desktop settings'
438
+ )
439
+ }
440
+ console.log(' ✓ rpk binary built')
441
+
442
+ // Step 2: Install plugins
443
+ console.log('Installing plugins for complete command coverage...')
444
+ for (const plugin of KNOWN_PLUGINS) {
445
+ console.log(` Installing plugin: ${plugin}...`)
446
+ const installResult = spawnSync('docker', [
447
+ 'exec', containerId,
448
+ '/tmp/rpk', plugin, 'install'
449
+ ], {
450
+ encoding: 'utf8',
451
+ timeout: 120000
452
+ })
453
+
454
+ if (installResult.status === 0) {
455
+ console.log(` ✓ ${plugin} installed`)
456
+ } else {
457
+ const stderr = installResult.stderr || ''
458
+ const stdout = installResult.stdout || ''
459
+ if (stderr.includes('already installed') || stdout.includes('already installed')) {
460
+ console.log(` ✓ ${plugin} already installed`)
461
+ } else if (stderr.includes('unknown command') || stderr.includes('Error: unknown command')) {
462
+ console.log(` - ${plugin} is not an installable plugin`)
463
+ } else {
464
+ console.warn(` ✗ Failed to install ${plugin}: ${stderr || stdout}`)
465
+ }
466
+ }
467
+ }
468
+
469
+ // Step 3: Run --print-tree with all plugins installed
470
+ console.log('Fetching rpk tree with plugins installed...')
471
+ const result = spawnSync('docker', [
472
+ 'exec', containerId,
473
+ '/tmp/rpk', '--print-tree'
474
+ ], {
475
+ encoding: 'utf8',
476
+ timeout: 120000,
477
+ maxBuffer: 50 * 1024 * 1024
478
+ })
479
+
480
+ if (result.status !== 0) {
481
+ const stderr = result.stderr || ''
482
+ if (stderr.includes('unknown flag')) {
483
+ throw new Error(
484
+ `rpk source does not support --print-tree flag.\n` +
485
+ `This feature requires rpk source from after the --print-tree feature was added.\n` +
486
+ 'Update your source checkout: cd <repo> && git pull origin dev'
487
+ )
488
+ }
489
+ throw new Error(
490
+ `Failed to run rpk --print-tree in Linux container: ${stderr}\n` +
491
+ 'The build succeeded but --print-tree failed.\n' +
492
+ 'This may indicate a version or configuration issue.'
493
+ )
494
+ }
495
+
496
+ try {
497
+ return JSON.parse(result.stdout)
498
+ } catch (err) {
499
+ throw new Error(
500
+ `Failed to parse rpk tree JSON from Linux source build: ${err.message}\n` +
501
+ 'The build and --print-tree succeeded but the output was not valid JSON.\n' +
502
+ 'This may indicate a version mismatch or corrupted source.'
503
+ )
504
+ }
505
+ } finally {
506
+ // Clean up container
507
+ console.log('Cleaning up build container...')
508
+ spawnSync('docker', ['stop', containerId], {
509
+ encoding: 'utf8',
510
+ timeout: 30000
511
+ })
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Scan Go source files for Linux-only build tags
517
+ * Looks for //go:build linux or // +build linux
518
+ * @param {string} sourcePath - Path to rpk Go source directory
519
+ * @returns {Set<string>} Set of Linux-only command paths (e.g., 'rpk redpanda tune')
520
+ */
521
+ function detectLinuxOnlyFromSource(sourcePath) {
522
+ const linuxOnlyCommands = new Set()
523
+
524
+ // Map of source directory patterns to command paths
525
+ // rpk commands are typically in pkg/cli/cmd/<command>/<subcommand>/
526
+ const scanDirectory = (dir, prefix = '') => {
527
+ if (!fs.existsSync(dir)) return
528
+
529
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
530
+
531
+ for (const entry of entries) {
532
+ const fullPath = path.join(dir, entry.name)
533
+
534
+ if (entry.isDirectory()) {
535
+ // Recurse into subdirectories
536
+ const newPrefix = prefix ? `${prefix} ${entry.name}` : entry.name
537
+ scanDirectory(fullPath, newPrefix)
538
+ } else if (entry.name.endsWith('.go') && !entry.name.endsWith('_test.go')) {
539
+ // Check Go files for build tags
540
+ try {
541
+ const content = fs.readFileSync(fullPath, 'utf8')
542
+ const firstLines = content.split('\n').slice(0, 20).join('\n')
543
+
544
+ // Check for Linux build constraints
545
+ const hasLinuxTag = /\/\/go:build\s+.*linux/.test(firstLines) ||
546
+ /\/\/\s*\+build\s+.*linux/.test(firstLines)
547
+
548
+ if (hasLinuxTag) {
549
+ // Try to determine command path from file location
550
+ const commandPath = inferCommandPath(sourcePath, fullPath, entry.name)
551
+ if (commandPath) {
552
+ linuxOnlyCommands.add(commandPath)
553
+ }
554
+ }
555
+ } catch (err) {
556
+ // Skip files we can't read
557
+ }
558
+ }
559
+ }
560
+ }
561
+
562
+ // Scan the cmd/rpk directory for command implementations
563
+ const cmdDir = path.join(sourcePath, 'pkg', 'cli', 'cmd')
564
+ if (fs.existsSync(cmdDir)) {
565
+ scanDirectory(cmdDir, 'rpk')
566
+ }
567
+
568
+ // Also check the older structure
569
+ const oldCmdDir = path.join(sourcePath, 'cmd', 'rpk')
570
+ if (fs.existsSync(oldCmdDir)) {
571
+ scanDirectory(oldCmdDir, 'rpk')
572
+ }
573
+
574
+ return linuxOnlyCommands
575
+ }
576
+
577
+ /**
578
+ * Infer command path from Go source file location
579
+ * @param {string} sourcePath - Root source path
580
+ * @param {string} filePath - Full path to Go file
581
+ * @param {string} fileName - Name of the Go file
582
+ * @returns {string|null} Command path or null
583
+ */
584
+ function inferCommandPath(sourcePath, filePath, fileName) {
585
+ // Get relative path from source root
586
+ const relativePath = filePath.replace(sourcePath, '').replace(/^\//, '')
587
+
588
+ // Common patterns:
589
+ // pkg/cli/cmd/redpanda/tune.go -> rpk redpanda tune
590
+ // pkg/cli/cmd/redpanda/start.go -> rpk redpanda start
591
+ // cmd/rpk/iotune/iotune.go -> rpk iotune
592
+
593
+ // Remove common prefixes and file extension
594
+ let parts = relativePath
595
+ .replace(/^pkg\/cli\/cmd\//, '')
596
+ .replace(/^cmd\/rpk\//, '')
597
+ .replace(/\.go$/, '')
598
+ .split('/')
599
+
600
+ // If file name matches directory name, use directory only
601
+ // e.g., iotune/iotune.go -> iotune
602
+ if (parts.length > 1 && parts[parts.length - 1] === parts[parts.length - 2]) {
603
+ parts = parts.slice(0, -1)
604
+ }
605
+
606
+ // Filter out common non-command directories
607
+ parts = parts.filter(p => !['internal', 'common', 'config', 'cmd'].includes(p))
608
+
609
+ if (parts.length === 0) return null
610
+
611
+ return 'rpk ' + parts.join(' ')
612
+ }
613
+
614
+ /**
615
+ * Add platform markers to command tree based on source analysis
616
+ * @param {Object} tree - Command tree from rpk --print-tree
617
+ * @param {Set<string>} linuxOnlyCommands - Set of Linux-only command paths
618
+ * @returns {Object} Tree with platform availability info
619
+ */
620
+ function addPlatformMarkersFromSource(tree, linuxOnlyCommands) {
621
+ const isLinuxOnly = (cmdPath) => {
622
+ // Check if this command or any parent is Linux-only from source detection
623
+ // Detection comes from: 1) Go build tags in source, 2) dynamic comparison of Linux vs Darwin builds
624
+ return linuxOnlyCommands.has(cmdPath) ||
625
+ [...linuxOnlyCommands].some(loc => cmdPath.startsWith(loc + ' '))
626
+ }
627
+
628
+ const markCommands = (commands, parentPath = 'rpk') => {
629
+ if (!commands) return commands
630
+ return commands.map(cmd => {
631
+ const fullPath = `${parentPath} ${cmd.name}`
632
+ const platforms = isLinuxOnly(fullPath)
633
+ ? [PLATFORMS.LINUX]
634
+ : [PLATFORMS.LINUX, PLATFORMS.DARWIN]
635
+ return {
636
+ ...cmd,
637
+ platforms,
638
+ commands: markCommands(cmd.commands, fullPath)
639
+ }
640
+ })
641
+ }
642
+
643
+ // Log detected Linux-only commands
644
+ if (linuxOnlyCommands.size > 0) {
645
+ console.log(`Detected ${linuxOnlyCommands.size} Linux-only command path(s) from source:`)
646
+ for (const cmd of linuxOnlyCommands) {
647
+ console.log(` - ${cmd}`)
648
+ }
649
+ }
650
+
651
+ return {
652
+ ...tree,
653
+ platforms: [PLATFORMS.LINUX, PLATFORMS.DARWIN],
654
+ linux_only_commands: [...linuxOnlyCommands],
655
+ commands: markCommands(tree.commands)
656
+ }
657
+ }
658
+
659
+ /**
660
+ * Get platform availability description for a command
661
+ * @param {string[]} platforms - Array of platform identifiers
662
+ * @returns {string} Human-readable description
663
+ */
664
+ function getPlatformDescription(platforms) {
665
+ if (!platforms || platforms.length === 0) return ''
666
+ if (platforms.length >= 2 && platforms.includes(PLATFORMS.LINUX) && platforms.includes(PLATFORMS.DARWIN)) {
667
+ return '' // Available on all major platforms, no need to note
668
+ }
669
+ if (platforms.length === 1) {
670
+ switch (platforms[0]) {
671
+ case PLATFORMS.LINUX:
672
+ return 'Linux only'
673
+ case PLATFORMS.DARWIN:
674
+ return 'macOS only'
675
+ case PLATFORMS.WINDOWS:
676
+ return 'Windows only'
677
+ default:
678
+ return platforms[0]
679
+ }
680
+ }
681
+ return platforms.map(p => {
682
+ switch (p) {
683
+ case PLATFORMS.LINUX: return 'Linux'
684
+ case PLATFORMS.DARWIN: return 'macOS'
685
+ case PLATFORMS.WINDOWS: return 'Windows'
686
+ default: return p
687
+ }
688
+ }).join(', ')
689
+ }
690
+
691
+ /**
692
+ * Load overrides from JSON file with validation
693
+ * @param {string} overridesPath - Path to overrides file
694
+ * @param {Object} [commandTree] - Optional command tree for path validation
695
+ * @param {Object} [options] - Options
696
+ * @param {boolean} [options.strict=false] - If true, throw on validation errors
697
+ * @returns {Object|null} Overrides object or null if not found
698
+ */
699
+ function loadOverrides(overridesPath, commandTree = null, options = {}) {
700
+ const { strict = false } = options
701
+
702
+ if (!overridesPath || !fs.existsSync(overridesPath)) {
703
+ return null
704
+ }
705
+
706
+ const { overrides, validation } = loadAndValidateOverrides(overridesPath, commandTree)
707
+
708
+ // Report validation issues
709
+ if (validation.errors.length > 0 || validation.warnings.length > 0) {
710
+ console.log('\n' + '='.repeat(60))
711
+ console.log('OVERRIDE VALIDATION RESULTS')
712
+ console.log('='.repeat(60))
713
+ console.log(validation.format())
714
+ console.log('='.repeat(60) + '\n')
715
+ }
716
+
717
+ // In strict mode, fail on errors
718
+ if (strict && !validation.valid) {
719
+ throw new Error(
720
+ `Override validation failed with ${validation.errors.length} error(s).\n` +
721
+ `Fix the issues above or run without --strict to proceed with warnings.`
722
+ )
723
+ }
724
+
725
+ // Warn but continue on non-strict validation failures
726
+ if (!validation.valid && !strict) {
727
+ console.warn(
728
+ `⚠ Proceeding with ${validation.errors.length} validation error(s). ` +
729
+ `Generated docs may be incorrect.`
730
+ )
731
+ }
732
+
733
+ return overrides
734
+ }
735
+
736
+ /**
737
+ * Save versioned JSON tree
738
+ * @param {Object} data - Data to save
739
+ * @param {string} version - Version string
740
+ * @param {string} dataDir - Output directory
741
+ * @returns {string} Path to saved file
742
+ */
743
+ function saveVersionedJson(data, version, dataDir) {
744
+ const normalizedVersion = version.startsWith('v') ? version : `v${version}`
745
+ const fileName = `rpk-${normalizedVersion}.json`
746
+ const filePath = path.join(dataDir, fileName)
747
+
748
+ fs.mkdirSync(dataDir, { recursive: true })
749
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8')
750
+
751
+ console.log(`Saved versioned JSON to ${filePath}`)
752
+ return filePath
753
+ }
754
+
755
+ /**
756
+ * Load existing versioned JSON
757
+ * @param {string} version - Version string
758
+ * @param {string} dataDir - Data directory
759
+ * @returns {Object|null} Loaded data or null
760
+ */
761
+ function loadVersionedJson(version, dataDir) {
762
+ const normalizedVersion = version.startsWith('v') ? version : `v${version}`
763
+ const fileName = `rpk-${normalizedVersion}.json`
764
+ const filePath = path.join(dataDir, fileName)
765
+
766
+ if (!fs.existsSync(filePath)) {
767
+ return null
768
+ }
769
+
770
+ try {
771
+ const content = fs.readFileSync(filePath, 'utf8')
772
+ return JSON.parse(content)
773
+ } catch (err) {
774
+ console.warn(`Warning: Could not load ${filePath}: ${err.message}`)
775
+ return null
776
+ }
777
+ }
778
+
779
+ /**
780
+ * Update overrides file with introducedInVersion for new commands and flags
781
+ * @param {Object} diffData - Diff data with new commands and flags
782
+ * @param {string} overridesPath - Path to overrides JSON file
783
+ * @param {string} version - Version to set as introducedInVersion
784
+ */
785
+ function updateOverridesWithIntroducedVersions(diffData, overridesPath, version) {
786
+ const hasNewCommands = diffData.details.newCommands && diffData.details.newCommands.length > 0
787
+ const hasNewFlags = diffData.details.newFlags && diffData.details.newFlags.length > 0
788
+
789
+ if (!hasNewCommands && !hasNewFlags) {
790
+ return
791
+ }
792
+
793
+ let overrides = {}
794
+ if (fs.existsSync(overridesPath)) {
795
+ try {
796
+ overrides = JSON.parse(fs.readFileSync(overridesPath, 'utf8'))
797
+ } catch (err) {
798
+ console.warn(`Warning: Could not parse overrides file: ${err.message}`)
799
+ return
800
+ }
801
+ }
802
+
803
+ if (!overrides.commands) {
804
+ overrides.commands = {}
805
+ }
806
+
807
+ let commandsUpdated = 0
808
+ let flagsUpdated = 0
809
+
810
+ // Update new commands
811
+ if (hasNewCommands) {
812
+ for (const newCmd of diffData.details.newCommands) {
813
+ const cmdPath = newCmd.path
814
+ if (!overrides.commands[cmdPath]) {
815
+ overrides.commands[cmdPath] = {}
816
+ }
817
+ // Only set if not already set (preserve manual overrides)
818
+ if (!overrides.commands[cmdPath].introducedInVersion) {
819
+ overrides.commands[cmdPath].introducedInVersion = version
820
+ commandsUpdated++
821
+ }
822
+ }
823
+ }
824
+
825
+ // Update new flags
826
+ if (hasNewFlags) {
827
+ for (const newFlag of diffData.details.newFlags) {
828
+ const cmdPath = newFlag.commandPath
829
+ const flagName = newFlag.flagName
830
+
831
+ if (!overrides.commands[cmdPath]) {
832
+ overrides.commands[cmdPath] = {}
833
+ }
834
+ if (!overrides.commands[cmdPath].flags) {
835
+ overrides.commands[cmdPath].flags = {}
836
+ }
837
+ if (!overrides.commands[cmdPath].flags[flagName]) {
838
+ overrides.commands[cmdPath].flags[flagName] = {}
839
+ }
840
+
841
+ // Only set if not already set (preserve manual overrides)
842
+ if (!overrides.commands[cmdPath].flags[flagName].introducedInVersion) {
843
+ overrides.commands[cmdPath].flags[flagName].introducedInVersion = version
844
+ flagsUpdated++
845
+ }
846
+ }
847
+ }
848
+
849
+ if (commandsUpdated > 0 || flagsUpdated > 0) {
850
+ fs.writeFileSync(overridesPath, JSON.stringify(overrides, null, 2), 'utf8')
851
+ const updates = []
852
+ if (commandsUpdated > 0) updates.push(`${commandsUpdated} new command(s)`)
853
+ if (flagsUpdated > 0) updates.push(`${flagsUpdated} new flag(s)`)
854
+ console.log(`Updated ${overridesPath} with introducedInVersion for ${updates.join(' and ')}`)
855
+ }
856
+ }
857
+
858
+ /**
859
+ * Get the latest documented version from data directory
860
+ * @param {string} dataDir - Data directory path
861
+ * @returns {string|null} Latest version or null
862
+ */
863
+ function getLatestDocumentedVersion(dataDir) {
864
+ if (!fs.existsSync(dataDir)) return null
865
+
866
+ const files = fs.readdirSync(dataDir)
867
+ .filter(f => f.startsWith('rpk-v') && f.endsWith('.json') && !f.includes('diff'))
868
+ .map(f => f.replace('rpk-', '').replace('.json', ''))
869
+ .filter(v => semver.valid(v))
870
+ .sort((a, b) => semver.compare(b, a)) // Descending
871
+
872
+ return files.length > 0 ? files[0] : null
873
+ }
874
+
875
+ /**
876
+ * Common locations where tech writers might have redpanda source checked out
877
+ */
878
+ const COMMON_SOURCE_LOCATIONS = [
879
+ '~/redpanda/src/go/rpk',
880
+ '~/Documents/redpanda/src/go/rpk',
881
+ '~/repos/redpanda/src/go/rpk',
882
+ '~/code/redpanda/src/go/rpk',
883
+ '~/projects/redpanda/src/go/rpk',
884
+ '../redpanda/src/go/rpk',
885
+ '../../redpanda/src/go/rpk'
886
+ ]
887
+
888
+ /**
889
+ * Try to find a local redpanda source checkout
890
+ * @returns {string|null} Path to rpk source directory, or null if not found
891
+ */
892
+ function findLocalSource() {
893
+ const homeDir = os.homedir()
894
+
895
+ for (const location of COMMON_SOURCE_LOCATIONS) {
896
+ const expandedPath = location.replace('~', homeDir)
897
+ const absolutePath = path.resolve(expandedPath)
898
+ const mainGoPath = path.join(absolutePath, 'cmd', 'rpk', 'main.go')
899
+
900
+ if (fs.existsSync(mainGoPath)) {
901
+ return absolutePath
902
+ }
903
+ }
904
+
905
+ return null
906
+ }
907
+
908
+ /**
909
+ * Count total commands in tree (recursive)
910
+ * @param {Object} node - Tree node
911
+ * @returns {number} Total command count
912
+ */
913
+ function countCommands(node) {
914
+ if (!node) return 0
915
+ let count = 1 // Count this node
916
+ if (node.commands && Array.isArray(node.commands)) {
917
+ for (const child of node.commands) {
918
+ count += countCommands(child)
919
+ }
920
+ }
921
+ return count
922
+ }
923
+
924
+ /**
925
+ * Update what's-new file with rpk changes from diff
926
+ * @param {Object} diffData - Diff data from generateRpkDiff
927
+ * @param {string} whatsNewPath - Path to what's-new.adoc file
928
+ * @param {string} version - Version string for display
929
+ */
930
+ function updateWhatsNewFile(diffData, whatsNewPath, version) {
931
+ const whatsNewContent = generateWhatsNewSection(diffData, { version })
932
+
933
+ if (!whatsNewContent) {
934
+ console.log('No Redpanda CLI changes to add to what\'s new')
935
+ return
936
+ }
937
+
938
+ if (!fs.existsSync(whatsNewPath)) {
939
+ console.warn(`Warning: what's-new file not found: ${whatsNewPath}`)
940
+ console.log('Generated what\'s-new content:')
941
+ console.log(whatsNewContent)
942
+ return
943
+ }
944
+
945
+ const existingContent = fs.readFileSync(whatsNewPath, 'utf8')
946
+
947
+ // Check if Redpanda CLI section already exists
948
+ if (existingContent.includes('== Redpanda CLI')) {
949
+ console.log('Redpanda CLI section already exists in what\'s-new file')
950
+ return
951
+ }
952
+
953
+ // Find a good insertion point - before the last section or at the end
954
+ // Look for a pattern like "== New configuration properties" or similar
955
+ const insertionPatterns = [
956
+ /^== New configuration properties/m,
957
+ /^== Deprecations/m,
958
+ /^== Bug fixes/m,
959
+ /^== See also/m
960
+ ]
961
+
962
+ let insertIndex = -1
963
+ for (const pattern of insertionPatterns) {
964
+ const match = existingContent.match(pattern)
965
+ if (match) {
966
+ insertIndex = match.index
967
+ break
968
+ }
969
+ }
970
+
971
+ let updatedContent
972
+ if (insertIndex > 0) {
973
+ // Insert before the matched section
974
+ updatedContent = existingContent.slice(0, insertIndex) +
975
+ whatsNewContent + '\n' +
976
+ existingContent.slice(insertIndex)
977
+ } else {
978
+ // Append at the end
979
+ updatedContent = existingContent + '\n' + whatsNewContent
980
+ }
981
+
982
+ fs.writeFileSync(whatsNewPath, updatedContent, 'utf8')
983
+ console.log(`Updated what's-new file: ${whatsNewPath}`)
984
+ }
985
+
986
+ /**
987
+ * Main handler for rpk docs generation
988
+ *
989
+ * Simplified workflow:
990
+ * 1. Use --ref to specify version (clones from GitHub or uses local source)
991
+ * 2. Build rpk from Go source
992
+ * 3. Parse source to detect Linux-only commands (via build tags)
993
+ * 4. Generate documentation with accurate platform markers
994
+ *
995
+ * @param {Object} options - Generation options
996
+ */
997
+ async function handleRpkDocsGeneration(options = {}) {
998
+ const {
999
+ overrides: overridesPath,
1000
+ fromSource, // Path to local rpk Go source directory
1001
+ fromJson, // Path to existing versioned JSON file to regenerate from
1002
+ ref, // Git ref (branch or tag) to document
1003
+ sourceRef, // Alias for ref
1004
+ diff: diffVersion,
1005
+ updateWhatsNew: whatsNewPath, // Path to what's-new.adoc file to update
1006
+ draftMissing = false,
1007
+ outputDir,
1008
+ cloudSecretDir, // Directory for rpk cloud and rpk security secret commands
1009
+ dataDir: customDataDir,
1010
+ preserveFrom, // Path to existing docs to preserve cloud conditionals and includes from
1011
+ printSummary = false, // Print PR summary to console
1012
+ showInfo = false // Include info-level validation messages
1013
+ } = options
1014
+
1015
+ // Normalize ref/sourceRef
1016
+ const effectiveRef = ref || sourceRef
1017
+
1018
+ const repoRoot = findRepoRoot()
1019
+ const dataDir = customDataDir || path.join(repoRoot, 'docs-data')
1020
+ const defaultOutputDir = path.join(repoRoot, 'modules', 'reference', 'pages', 'rpk')
1021
+ const finalOutputDir = outputDir || defaultOutputDir
1022
+
1023
+ // cloudSecretDir should be relative to outputDir, not repoRoot
1024
+ // If outputDir is .../modules/reference/pages/rpk, cloudSecretDir should be .../modules/reference/partials
1025
+ let defaultCloudSecretDir
1026
+ if (outputDir) {
1027
+ // Check if outputDir follows docs repo structure (ends with pages/rpk or similar)
1028
+ const normalizedOutput = finalOutputDir.replace(/\\/g, '/')
1029
+ if (normalizedOutput.includes('/pages/')) {
1030
+ // Derive cloudSecretDir from outputDir by going up from pages/rpk to partials
1031
+ const pagesIndex = normalizedOutput.lastIndexOf('/pages/')
1032
+ const referenceDir = normalizedOutput.substring(0, pagesIndex)
1033
+ defaultCloudSecretDir = path.join(referenceDir, 'partials')
1034
+ } else {
1035
+ // Arbitrary output directory - use sibling partials directory
1036
+ const outputParent = path.dirname(finalOutputDir)
1037
+ defaultCloudSecretDir = path.join(outputParent, 'partials')
1038
+ }
1039
+ } else {
1040
+ defaultCloudSecretDir = path.join(repoRoot, 'modules', 'reference', 'partials')
1041
+ }
1042
+ const finalCloudSecretDir = cloudSecretDir || defaultCloudSecretDir
1043
+
1044
+ // Determine which version to document
1045
+ const version = effectiveRef || 'dev'
1046
+ console.log(`\nGenerating rpk documentation for version: ${version}`)
1047
+
1048
+ let tree
1049
+ let rpkVersion
1050
+ let sourcePath
1051
+
1052
+ try {
1053
+ // Fast path: regenerate from existing JSON file
1054
+ if (fromJson) {
1055
+ const jsonPath = path.resolve(fromJson)
1056
+ if (!fs.existsSync(jsonPath)) {
1057
+ throw new Error(`JSON file not found: ${jsonPath}`)
1058
+ }
1059
+ console.log(`Loading command tree from ${jsonPath}`)
1060
+ const jsonData = JSON.parse(fs.readFileSync(jsonPath, 'utf8'))
1061
+ tree = jsonData.raw_tree || jsonData.tree
1062
+ rpkVersion = jsonData.rpk_version || 'local'
1063
+
1064
+ if (!tree) {
1065
+ throw new Error('JSON file does not contain a valid command tree')
1066
+ }
1067
+
1068
+ console.log(`Loaded tree with rpk version: ${rpkVersion}`)
1069
+
1070
+ // Skip to documentation generation (after the source building steps)
1071
+ // Load and validate overrides
1072
+ const defaultOverridesPath = path.join(dataDir, 'rpk-overrides.json')
1073
+ const effectiveOverridesPath = overridesPath || defaultOverridesPath
1074
+ const overridesData = loadOverrides(effectiveOverridesPath, tree, { strict: false })
1075
+
1076
+ if (overridesData) {
1077
+ console.log(`Loaded overrides from ${effectiveOverridesPath}`)
1078
+ }
1079
+
1080
+ // Generate AsciiDoc documentation
1081
+ console.log(`\nGenerating AsciiDoc files to ${finalOutputDir}...`)
1082
+
1083
+ const result = await generateRpkDocs({
1084
+ tree,
1085
+ overrides: overridesData,
1086
+ outputDir: finalOutputDir,
1087
+ cloudSecretDir: finalCloudSecretDir,
1088
+ rpkVersion,
1089
+ pluginVersions: {},
1090
+ draftMissing,
1091
+ preservationsDir: preserveFrom
1092
+ })
1093
+
1094
+ console.log(`\nGeneration complete!`)
1095
+ console.log(` - Commands documented: ${result.commandCount}`)
1096
+ console.log(` - Files generated: ${result.filesGenerated}`)
1097
+ console.log(` - Output directory: ${finalOutputDir}`)
1098
+
1099
+ // Run validation on generated output
1100
+ const validationResult = runValidation(finalOutputDir, {
1101
+ showInfo
1102
+ })
1103
+
1104
+ // Generate diff if requested
1105
+ let diffData = null
1106
+ if (diffVersion) {
1107
+ const oldData = loadVersionedJson(diffVersion, dataDir)
1108
+ if (oldData) {
1109
+ // Use raw_tree for diffing (falls back to tree for backward compatibility)
1110
+ const oldTree = oldData.raw_tree || oldData.tree
1111
+ diffData = generateRpkDiff(oldTree, tree, {
1112
+ oldVersion: diffVersion,
1113
+ newVersion: rpkVersion
1114
+ })
1115
+
1116
+ // Save diff
1117
+ const diffFileName = `rpk-diff-${diffVersion}_to_${rpkVersion}.json`
1118
+ const diffPath = path.join(dataDir, diffFileName)
1119
+ fs.writeFileSync(diffPath, JSON.stringify(diffData, null, 2), 'utf8')
1120
+ console.log(`Saved diff to ${diffPath}`)
1121
+
1122
+ // Print diff report
1123
+ printDiffReport(diffData)
1124
+
1125
+ // Update what's-new file if requested
1126
+ if (whatsNewPath) {
1127
+ updateWhatsNewFile(diffData, whatsNewPath, rpkVersion)
1128
+ }
1129
+ } else {
1130
+ console.warn(`Warning: Could not load previous version ${diffVersion} for diff`)
1131
+ }
1132
+ }
1133
+
1134
+ // Generate PR summary
1135
+ const prSummary = generatePRSummary({
1136
+ rpkVersion,
1137
+ commandCount: result.commandCount,
1138
+ filesGenerated: result.filesGenerated,
1139
+ filesSkipped: result.filesSkipped,
1140
+ diffData,
1141
+ validationResult,
1142
+ outputDir: finalOutputDir
1143
+ })
1144
+
1145
+ // Print PR summary if requested or if there are issues
1146
+ if (printSummary || validationResult.summary.totalErrors > 0) {
1147
+ console.log('\n' + '='.repeat(60))
1148
+ console.log('PR SUMMARY (for GitHub Actions)')
1149
+ console.log('='.repeat(60))
1150
+ console.log(prSummary)
1151
+ console.log('='.repeat(60) + '\n')
1152
+ }
1153
+
1154
+ return {
1155
+ success: true,
1156
+ commandCount: result.commandCount,
1157
+ filesGenerated: result.filesGenerated,
1158
+ filesSkipped: result.filesSkipped,
1159
+ outputDir: finalOutputDir,
1160
+ rpkVersion,
1161
+ diffData,
1162
+ validationResult,
1163
+ prSummary
1164
+ }
1165
+ }
1166
+
1167
+ // Step 1: Get source code
1168
+ if (effectiveRef) {
1169
+ // Clone/checkout from ref
1170
+ if (fromSource) {
1171
+ // Use provided local path, checkout the ref
1172
+ sourcePath = prepareSourceFromRef(effectiveRef, fromSource)
1173
+ } else {
1174
+ // Sparse clone from GitHub
1175
+ console.log(`Cloning redpanda source at ref: ${effectiveRef}`)
1176
+ sourcePath = prepareSourceFromRef(effectiveRef, null)
1177
+ }
1178
+ } else if (fromSource) {
1179
+ // Use local source without checkout
1180
+ sourcePath = path.resolve(fromSource)
1181
+ if (!fs.existsSync(path.join(sourcePath, 'cmd', 'rpk', 'main.go'))) {
1182
+ throw new Error(
1183
+ `Invalid source path: ${sourcePath}\n` +
1184
+ 'Expected to find cmd/rpk/main.go. Point to src/go/rpk directory.'
1185
+ )
1186
+ }
1187
+ } else {
1188
+ // Auto-detect local source
1189
+ const localSource = findLocalSource()
1190
+ if (localSource) {
1191
+ console.log(`Auto-detected local source: ${localSource}`)
1192
+ sourcePath = localSource
1193
+ }
1194
+ }
1195
+
1196
+ // Require source
1197
+ if (!sourcePath) {
1198
+ throw new Error(
1199
+ 'No source specified.\n\n' +
1200
+ 'USAGE: npx doc-tools generate rpk-docs --ref <version>\n\n' +
1201
+ 'Examples:\n' +
1202
+ ' --ref dev Document latest development branch\n' +
1203
+ ' --ref v26.2.0 Document specific release\n' +
1204
+ ' --ref main Document main branch\n\n' +
1205
+ 'Requirements:\n' +
1206
+ ' - Git for cloning source\n' +
1207
+ ' - Go (optional) for building rpk natively'
1208
+ )
1209
+ }
1210
+
1211
+ rpkVersion = effectiveRef || 'local'
1212
+
1213
+ // Step 2: Detect Linux-only commands
1214
+ // Try dynamic detection first (compare Linux vs Darwin builds)
1215
+ // Fall back to static lists for plugins and known commands
1216
+ console.log('\nAnalyzing source for Linux-only commands...')
1217
+ let linuxOnlyCommands = detectLinuxOnlyFromSource(sourcePath)
1218
+
1219
+ // Step 3: Build rpk and get command tree
1220
+ // Use Docker if available (for running rpk plugins in Linux)
1221
+ // Otherwise build natively with Go
1222
+ const dockerCheck = spawnSync('docker', ['--version'], { encoding: 'utf8', timeout: 5000 })
1223
+ const goCheck = spawnSync('go', ['version'], { encoding: 'utf8', timeout: 5000 })
1224
+ const canBuildNative = goCheck.status === 0
1225
+ const canBuildLinux = dockerCheck.status === 0
1226
+
1227
+ // Dynamic detection: if we can build on both platforms, compare the trees
1228
+ if (canBuildLinux && canBuildNative && os.platform() !== 'linux') {
1229
+ console.log('\nBuilding rpk on both Linux and Darwin for dynamic platform detection...')
1230
+
1231
+ try {
1232
+ // Build on Linux (in container) - has all commands
1233
+ console.log('Building rpk in Linux container...')
1234
+ const linuxTree = fetchRpkTreeFromLinuxSource(sourcePath)
1235
+
1236
+ // Build natively (on Darwin) - missing Linux-only commands
1237
+ console.log('Building rpk natively for comparison...')
1238
+ const darwinTree = fetchRpkTreeFromSource(sourcePath)
1239
+
1240
+ // Compare trees to find Linux-only commands
1241
+ const dynamicLinuxOnly = detectLinuxOnlyByComparison(linuxTree, darwinTree)
1242
+ if (dynamicLinuxOnly.size > 0) {
1243
+ console.log(`Dynamic detection found ${dynamicLinuxOnly.size} Linux-only command(s):`)
1244
+ for (const cmd of dynamicLinuxOnly) {
1245
+ console.log(` - ${cmd}`)
1246
+ linuxOnlyCommands.add(cmd)
1247
+ }
1248
+ }
1249
+
1250
+ // Use the Linux tree (has all commands)
1251
+ tree = linuxTree
1252
+ } catch (dockerErr) {
1253
+ // Docker build failed (e.g., Go version mismatch) - fall back to native build
1254
+ console.warn(`\n⚠ Docker build failed: ${dockerErr.message}`)
1255
+ console.log('Falling back to native Go build...')
1256
+ console.log('Note: Linux-only commands will be detected via source scanning only.\n')
1257
+ tree = fetchRpkTreeFromSource(sourcePath)
1258
+ }
1259
+ } else if (canBuildLinux) {
1260
+ console.log('\nBuilding rpk in Linux container...')
1261
+ try {
1262
+ tree = fetchRpkTreeFromLinuxSource(sourcePath)
1263
+ } catch (dockerErr) {
1264
+ if (canBuildNative) {
1265
+ console.warn(`\n⚠ Docker build failed: ${dockerErr.message}`)
1266
+ console.log('Falling back to native Go build...\n')
1267
+ tree = fetchRpkTreeFromSource(sourcePath)
1268
+ } else {
1269
+ throw dockerErr
1270
+ }
1271
+ }
1272
+ } else if (canBuildNative) {
1273
+ console.log('\nBuilding rpk natively with Go...')
1274
+ tree = fetchRpkTreeFromSource(sourcePath)
1275
+ } else {
1276
+ throw new Error(
1277
+ 'Neither Docker nor Go available to build rpk.\n' +
1278
+ 'Install Docker or Go to continue.'
1279
+ )
1280
+ }
1281
+
1282
+ // Step 4: Add platform markers based on detection
1283
+ // This includes both source scanning results and static fallback lists
1284
+ tree = addPlatformMarkersFromSource(tree, linuxOnlyCommands)
1285
+
1286
+ console.log(`\nrpk version: ${rpkVersion}`)
1287
+ console.log(`Total commands in tree: ${countCommands(tree)}`)
1288
+
1289
+ // Detect plugins from tree
1290
+ const plugins = detectPlugins(tree)
1291
+ console.log(`Detected plugins: ${plugins.join(', ') || 'none'}`)
1292
+
1293
+ // Note: Plugin versions not available when building from source
1294
+ const pluginVersions = {}
1295
+
1296
+ // Step 5: Scan source for deprecated/hidden commands
1297
+ let deprecatedCommands = {}
1298
+ if (sourcePath && fs.existsSync(sourcePath)) {
1299
+ console.log('\nScanning source for deprecated/hidden commands...')
1300
+ const { scanDeprecatedCommands } = require('./scan-deprecated-commands.js')
1301
+
1302
+ try {
1303
+ deprecatedCommands = scanDeprecatedCommands(sourcePath)
1304
+ if (Object.keys(deprecatedCommands).length > 0) {
1305
+ console.log(`Found ${Object.keys(deprecatedCommands).length} deprecated/hidden command(s)`)
1306
+ }
1307
+ } catch (err) {
1308
+ console.warn(`Warning: Failed to scan for deprecated commands: ${err.message}`)
1309
+ }
1310
+ }
1311
+
1312
+ // Load and validate overrides
1313
+ const defaultOverridesPath = path.join(dataDir, 'rpk-overrides.json')
1314
+ const effectiveOverridesPath = overridesPath || defaultOverridesPath
1315
+ const overridesData = loadOverrides(effectiveOverridesPath, tree, { strict: false })
1316
+
1317
+ if (overridesData) {
1318
+ console.log(`Loaded overrides from ${effectiveOverridesPath}`)
1319
+ }
1320
+
1321
+ // Create enhanced tree with overrides applied (for canonical JSON)
1322
+ let enhancedTree = tree
1323
+ if (overridesData) {
1324
+ const resolvedOverrides = resolveReferences(overridesData, overridesData)
1325
+ enhancedTree = applyOverridesToTree(tree, resolvedOverrides, '')
1326
+ console.log('Applied overrides to command tree for versioned JSON')
1327
+ }
1328
+
1329
+ // Build augmented data structure with enhanced tree
1330
+ const augmentedData = {
1331
+ rpk_version: rpkVersion,
1332
+ plugin_versions: pluginVersions,
1333
+ generated_at: new Date().toISOString(),
1334
+ tree: enhancedTree,
1335
+ // Also include raw tree for diffing purposes (used by diff generation)
1336
+ raw_tree: tree,
1337
+ // Deprecated commands metadata (from source code scanning)
1338
+ deprecated_commands: deprecatedCommands
1339
+ }
1340
+
1341
+ // Save versioned JSON
1342
+ saveVersionedJson(augmentedData, rpkVersion, dataDir)
1343
+
1344
+ // Generate diff if requested or if previous version exists
1345
+ let diffData = null
1346
+ if (diffVersion) {
1347
+ const oldData = loadVersionedJson(diffVersion, dataDir)
1348
+ if (oldData) {
1349
+ // Use raw_tree for diffing (falls back to tree for backward compatibility)
1350
+ const oldTree = oldData.raw_tree || oldData.tree
1351
+ diffData = generateRpkDiff(oldTree, tree, {
1352
+ oldVersion: diffVersion,
1353
+ newVersion: rpkVersion
1354
+ })
1355
+
1356
+ // Save diff
1357
+ const diffFileName = `rpk-diff-${diffVersion}_to_${rpkVersion}.json`
1358
+ const diffPath = path.join(dataDir, diffFileName)
1359
+ fs.writeFileSync(diffPath, JSON.stringify(diffData, null, 2), 'utf8')
1360
+ console.log(`Saved diff to ${diffPath}`)
1361
+
1362
+ // Print diff report
1363
+ printDiffReport(diffData)
1364
+
1365
+ // Update overrides with introducedInVersion for new commands
1366
+ if (diffData.details.newCommands.length > 0 && effectiveOverridesPath) {
1367
+ updateOverridesWithIntroducedVersions(diffData, effectiveOverridesPath, rpkVersion)
1368
+ }
1369
+
1370
+ // Update what's-new file if requested
1371
+ if (whatsNewPath) {
1372
+ updateWhatsNewFile(diffData, whatsNewPath, rpkVersion)
1373
+ }
1374
+ } else {
1375
+ console.warn(`Warning: Could not load previous version ${diffVersion} for diff`)
1376
+ }
1377
+ }
1378
+
1379
+ // Generate AsciiDoc documentation
1380
+ console.log(`\nGenerating AsciiDoc files to ${finalOutputDir}...`)
1381
+
1382
+ const result = await generateRpkDocs({
1383
+ tree,
1384
+ overrides: overridesData,
1385
+ outputDir: finalOutputDir,
1386
+ cloudSecretDir: finalCloudSecretDir,
1387
+ rpkVersion,
1388
+ pluginVersions,
1389
+ draftMissing,
1390
+ preservationsDir: preserveFrom
1391
+ })
1392
+
1393
+ console.log(`\nGeneration complete!`)
1394
+ console.log(` - Commands documented: ${result.commandCount}`)
1395
+ console.log(` - Files generated: ${result.filesGenerated}`)
1396
+ console.log(` - Output directory: ${finalOutputDir}`)
1397
+
1398
+ // Run validation on generated output
1399
+ const validationResult = runValidation(finalOutputDir, {
1400
+ showInfo
1401
+ })
1402
+
1403
+ // Generate PR summary
1404
+ const prSummary = generatePRSummary({
1405
+ rpkVersion,
1406
+ commandCount: result.commandCount,
1407
+ filesGenerated: result.filesGenerated,
1408
+ filesSkipped: result.filesSkipped,
1409
+ diffData,
1410
+ validationResult,
1411
+ outputDir: finalOutputDir
1412
+ })
1413
+
1414
+ // Print PR summary if requested or if there are issues
1415
+ if (printSummary || validationResult.summary.totalErrors > 0) {
1416
+ console.log('\n' + '='.repeat(60))
1417
+ console.log('PR SUMMARY (for GitHub Actions)')
1418
+ console.log('='.repeat(60))
1419
+ console.log(prSummary)
1420
+ console.log('='.repeat(60) + '\n')
1421
+ }
1422
+
1423
+ return {
1424
+ success: true,
1425
+ rpkVersion,
1426
+ pluginVersions,
1427
+ commandCount: result.commandCount,
1428
+ filesGenerated: result.filesGenerated,
1429
+ filesSkipped: result.filesSkipped,
1430
+ outputDir: finalOutputDir,
1431
+ dataDir,
1432
+ diffData,
1433
+ sourcePath,
1434
+ validationResult,
1435
+ prSummary
1436
+ }
1437
+ } catch (err) {
1438
+ console.error(`Error: ${err.message}`)
1439
+ throw err
1440
+ }
1441
+ }
1442
+
1443
+ /**
1444
+ * Generate a markdown summary for GitHub PR descriptions
1445
+ * Includes generation stats, validation results, and diff summary
1446
+ * @param {Object} options - Summary options
1447
+ * @returns {string} Markdown formatted summary
1448
+ */
1449
+ function generatePRSummary(options) {
1450
+ const {
1451
+ rpkVersion,
1452
+ commandCount,
1453
+ filesGenerated,
1454
+ filesSkipped = 0,
1455
+ diffData,
1456
+ validationResult,
1457
+ outputDir
1458
+ } = options
1459
+
1460
+ const lines = []
1461
+
1462
+ // Header
1463
+ lines.push('## rpk Documentation Generation Summary')
1464
+ lines.push('')
1465
+
1466
+ // Version info
1467
+ lines.push(`**Version:** ${rpkVersion}`)
1468
+ lines.push('')
1469
+
1470
+ // Generation stats
1471
+ lines.push('### Generation Statistics')
1472
+ lines.push('')
1473
+ lines.push(`| Metric | Count |`)
1474
+ lines.push(`|--------|-------|`)
1475
+ lines.push(`| Commands documented | ${commandCount} |`)
1476
+ lines.push(`| Files generated | ${filesGenerated} |`)
1477
+ if (filesSkipped > 0) {
1478
+ lines.push(`| Files skipped (excluded) | ${filesSkipped} |`)
1479
+ }
1480
+ lines.push('')
1481
+
1482
+ // Diff summary (if available)
1483
+ if (diffData && diffData.summary) {
1484
+ lines.push('### Changes from Previous Version')
1485
+ lines.push('')
1486
+
1487
+ const { newCommands, removedCommands, newFlags, removedFlags, changedDescriptions } = diffData.summary
1488
+
1489
+ if (newCommands === 0 && removedCommands === 0 && newFlags === 0 && removedFlags === 0) {
1490
+ lines.push('No command or flag changes detected.')
1491
+ } else {
1492
+ lines.push(`| Change Type | Count |`)
1493
+ lines.push(`|-------------|-------|`)
1494
+ if (newCommands > 0) lines.push(`| New commands | ${newCommands} |`)
1495
+ if (removedCommands > 0) lines.push(`| Removed commands | ${removedCommands} |`)
1496
+ if (newFlags > 0) lines.push(`| New flags | ${newFlags} |`)
1497
+ if (removedFlags > 0) lines.push(`| Removed flags | ${removedFlags} |`)
1498
+ if (changedDescriptions > 0) lines.push(`| Changed descriptions | ${changedDescriptions} |`)
1499
+ }
1500
+ lines.push('')
1501
+
1502
+ // List new commands
1503
+ if (diffData.details?.newCommands?.length > 0) {
1504
+ lines.push('<details>')
1505
+ lines.push('<summary>New Commands</summary>')
1506
+ lines.push('')
1507
+ for (const cmd of diffData.details.newCommands.slice(0, 20)) {
1508
+ lines.push(`- \`${cmd.path}\``)
1509
+ }
1510
+ if (diffData.details.newCommands.length > 20) {
1511
+ lines.push(`- ... and ${diffData.details.newCommands.length - 20} more`)
1512
+ }
1513
+ lines.push('')
1514
+ lines.push('</details>')
1515
+ lines.push('')
1516
+ }
1517
+
1518
+ // List removed commands
1519
+ if (diffData.details?.removedCommands?.length > 0) {
1520
+ lines.push('<details>')
1521
+ lines.push('<summary>Removed Commands</summary>')
1522
+ lines.push('')
1523
+ for (const cmd of diffData.details.removedCommands.slice(0, 20)) {
1524
+ lines.push(`- \`${cmd.path}\``)
1525
+ }
1526
+ if (diffData.details.removedCommands.length > 20) {
1527
+ lines.push(`- ... and ${diffData.details.removedCommands.length - 20} more`)
1528
+ }
1529
+ lines.push('')
1530
+ lines.push('</details>')
1531
+ lines.push('')
1532
+ }
1533
+ }
1534
+
1535
+ // Validation results
1536
+ if (validationResult) {
1537
+ lines.push('### Validation Report')
1538
+ lines.push('')
1539
+
1540
+ const { summary } = validationResult
1541
+
1542
+ if (summary.totalErrors === 0 && summary.totalWarnings === 0) {
1543
+ lines.push('✅ All files passed validation.')
1544
+ } else {
1545
+ // Summary table
1546
+ lines.push(`| Severity | Count |`)
1547
+ lines.push(`|----------|-------|`)
1548
+ if (summary.totalErrors > 0) {
1549
+ lines.push(`| ❌ Errors | ${summary.totalErrors} |`)
1550
+ }
1551
+ if (summary.totalWarnings > 0) {
1552
+ lines.push(`| ⚠️ Warnings | ${summary.totalWarnings} |`)
1553
+ }
1554
+ if (summary.totalInfo > 0) {
1555
+ lines.push(`| ℹ️ Info | ${summary.totalInfo} |`)
1556
+ }
1557
+ lines.push('')
1558
+
1559
+ // Issues by rule
1560
+ if (Object.keys(summary.byRule).length > 0) {
1561
+ lines.push('<details>')
1562
+ lines.push('<summary>Issues by Rule</summary>')
1563
+ lines.push('')
1564
+ for (const [rule, counts] of Object.entries(summary.byRule)) {
1565
+ const parts = []
1566
+ if (counts.errors > 0) parts.push(`${counts.errors} errors`)
1567
+ if (counts.warnings > 0) parts.push(`${counts.warnings} warnings`)
1568
+ if (counts.info > 0) parts.push(`${counts.info} info`)
1569
+ lines.push(`- **${rule}**: ${parts.join(', ')}`)
1570
+ }
1571
+ lines.push('')
1572
+ lines.push('</details>')
1573
+ lines.push('')
1574
+ }
1575
+
1576
+ // File details (errors only, limited)
1577
+ const filesWithErrors = validationResult.results.filter(r => r.errors.length > 0)
1578
+ if (filesWithErrors.length > 0) {
1579
+ lines.push('<details>')
1580
+ lines.push('<summary>Files with Errors</summary>')
1581
+ lines.push('')
1582
+ for (const result of filesWithErrors.slice(0, 10)) {
1583
+ lines.push(`**${result.file}**:`)
1584
+ for (const err of result.errors) {
1585
+ lines.push(`- Line ${err.line}: ${err.message}`)
1586
+ }
1587
+ lines.push('')
1588
+ }
1589
+ if (filesWithErrors.length > 10) {
1590
+ lines.push(`... and ${filesWithErrors.length - 10} more files with errors`)
1591
+ lines.push('')
1592
+ }
1593
+ lines.push('</details>')
1594
+ lines.push('')
1595
+ }
1596
+ }
1597
+ }
1598
+
1599
+ // Footer
1600
+ lines.push('---')
1601
+ lines.push(`*Generated by rpk-docs automation*`)
1602
+
1603
+ return lines.join('\n')
1604
+ }
1605
+
1606
+ /**
1607
+ * Run validation on generated output and return structured results
1608
+ * @param {string} outputDir - Directory containing generated docs
1609
+ * @param {Object} options - Validation options
1610
+ * @returns {Object} Validation results with summary
1611
+ */
1612
+ function runValidation(outputDir, options = {}) {
1613
+ console.log('\nRunning validation on generated docs...')
1614
+
1615
+ const validationOutput = validateDirectory(outputDir, {
1616
+ showInfo: options.showInfo || false
1617
+ })
1618
+
1619
+ // Print console report
1620
+ console.log(formatResults(validationOutput, { showInfo: options.showInfo }))
1621
+
1622
+ return validationOutput
1623
+ }
1624
+
1625
+ module.exports = {
1626
+ handleRpkDocsGeneration,
1627
+ fetchRpkTreeFromSource,
1628
+ fetchRpkTreeFromLinuxSource,
1629
+ prepareSourceFromRef,
1630
+ detectLinuxOnlyFromSource,
1631
+ addPlatformMarkersFromSource,
1632
+ detectPlugins,
1633
+ isPlugin,
1634
+ findLocalSource,
1635
+ loadOverrides,
1636
+ saveVersionedJson,
1637
+ loadVersionedJson,
1638
+ getLatestDocumentedVersion,
1639
+ countCommands,
1640
+ getPlatformDescription,
1641
+ getCurrentPlatform,
1642
+ updateOverridesWithIntroducedVersions,
1643
+ KNOWN_PLUGINS,
1644
+ extractCommandPaths,
1645
+ detectLinuxOnlyByComparison,
1646
+ PLATFORMS,
1647
+ COMMON_SOURCE_LOCATIONS,
1648
+ generatePRSummary,
1649
+ runValidation
1650
+ }