@redpanda-data/docs-extensions-and-macros 5.2.5 → 5.3.1

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.
@@ -1,13 +1,15 @@
1
1
  'use strict'
2
2
 
3
3
  const { spawnSync } = require('child_process')
4
+ const crypto = require('crypto')
4
5
  const path = require('path')
5
6
  const fs = require('fs')
6
7
  const os = require('os')
7
8
  const semver = require('semver')
8
9
  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')
10
+ const { generateRpkDocs, applyOverridesToTree, resolveReferences, shouldExcludeCommand, shouldUsePartialDir } = require('./generate-rpk-docs')
11
+ const { detectLinuxOnlyFromSource, warnIfDetectionLooksBroken } = require('./detect-platform-commands')
12
+ const { generateRpkDiff, printDiffReport, generateWhatsNewSection, flattenToMap } = require('./report-delta')
11
13
  const { loadAndValidateOverrides, ValidationResult } = require('./validate-overrides')
12
14
  const { validateDirectory, formatResults } = require('./validate-output')
13
15
 
@@ -16,6 +18,37 @@ const { validateDirectory, formatResults } = require('./validate-output')
16
18
  */
17
19
  const KNOWN_PLUGINS = ['ai', 'check', 'connect', 'k8s', 'oxla']
18
20
 
21
+ /**
22
+ * Plugins whose docs can be refreshed individually with --plugin.
23
+ * oxla is excluded: it is a "Coming Soon" stub with no installable binary.
24
+ */
25
+ const REFRESHABLE_PLUGINS = ['ai', 'check', 'connect', 'k8s']
26
+
27
+ /**
28
+ * Per-plugin install flags that pin a version (rpk <plugin> install <flag> <version>).
29
+ * k8s uses the generic flag name; the others embed the plugin name.
30
+ */
31
+ const PLUGIN_INSTALL_VERSION_FLAGS = {
32
+ ai: '--ai-version',
33
+ check: '--check-version',
34
+ connect: '--connect-version',
35
+ k8s: '--plugin-version'
36
+ }
37
+
38
+ /**
39
+ * Manifest slugs at https://rpk-plugins.redpanda.com/<slug>/manifest.json
40
+ * where the slug differs from the rpk command name.
41
+ */
42
+ const PLUGIN_MANIFEST_SLUGS = { ai: 'rpai' }
43
+
44
+ const PLUGIN_MANIFEST_HOST = 'https://rpk-plugins.redpanda.com'
45
+
46
+ /**
47
+ * Subcommands compiled into rpk itself for managed plugins. A plugin node
48
+ * whose children are only these never actually installed.
49
+ */
50
+ const PLUGIN_SHIM_SUBCOMMANDS = new Set(['install', 'uninstall', 'upgrade'])
51
+
19
52
  /**
20
53
  * Parse Go version from 'go version' output
21
54
  * @param {string} versionOutput - Output from 'go version' command
@@ -347,9 +380,12 @@ function fetchRpkTreeFromSource(sourcePath) {
347
380
  * Builds rpk binary, installs plugins, then runs --print-tree for complete command coverage.
348
381
  * Falls back to native Go build if Docker is unavailable.
349
382
  * @param {string} sourcePath - Path to rpk Go source directory (e.g., ~/redpanda/src/go/rpk)
350
- * @returns {Object} Parsed JSON tree
383
+ * @returns {Object} { tree, failedPlugins } — the parsed JSON tree plus the
384
+ * names of managed plugins whose install failed this run. Callers pass
385
+ * failedPlugins to generateRpkDocs as protectedPlugins so those plugins'
386
+ * existing pages and nav entries are preserved instead of treated as stale.
351
387
  */
352
- function fetchRpkTreeFromLinuxSource(sourcePath) {
388
+ function fetchRpkTreeFromLinuxSource(sourcePath, pluginPins = {}) {
353
389
  // Resolve to absolute path
354
390
  const absoluteSourcePath = path.resolve(sourcePath)
355
391
 
@@ -455,18 +491,46 @@ function fetchRpkTreeFromLinuxSource(sourcePath) {
455
491
  try {
456
492
  // Step 1: Build rpk binary
457
493
  console.log('Building rpk binary...')
458
- const buildResult = spawnSync('docker', [
459
- 'exec', containerId,
460
- 'go', 'build', '-o', '/tmp/rpk', './cmd/rpk'
461
- ], {
462
- encoding: 'utf8',
463
- timeout: 300000 // 5 minutes for build
464
- })
494
+ // Module downloads from proxy.golang.org fail transiently (stream
495
+ // INTERNAL_ERROR), especially on a cold module cache. Retry: the second
496
+ // attempt reuses whatever the first already downloaded.
497
+ let buildResult
498
+ let binaryExists = false
499
+ for (let attempt = 1; attempt <= 4; attempt++) {
500
+ buildResult = spawnSync('docker', [
501
+ 'exec', containerId,
502
+ 'go', 'build', '-o', '/tmp/rpk', './cmd/rpk'
503
+ ], {
504
+ encoding: 'utf8',
505
+ timeout: 300000 // 5 minutes per attempt
506
+ })
507
+
508
+ if (buildResult.status === 0) {
509
+ // Trust but verify: docker exec has been observed returning zero for
510
+ // a build that produced nothing, and everything downstream execs
511
+ // /tmp/rpk. Treat a phantom success as a failed attempt.
512
+ const binCheck = spawnSync('docker', [
513
+ 'exec', containerId, 'test', '-x', '/tmp/rpk'
514
+ ], { encoding: 'utf8', timeout: 15000 })
515
+ if (binCheck.status === 0) {
516
+ binaryExists = true
517
+ break
518
+ }
519
+ if (attempt < 4) {
520
+ console.warn(` Build attempt ${attempt} reported success but produced no binary; retrying...`)
521
+ continue
522
+ }
523
+ } else if (attempt < 4) {
524
+ const firstError = (buildResult.stderr || buildResult.signal || 'unknown error')
525
+ .toString().trim().split('\n').slice(-1)[0]
526
+ console.warn(` Build attempt ${attempt} failed (${firstError}); retrying...`)
527
+ }
528
+ }
465
529
 
466
- if (buildResult.status !== 0) {
530
+ if (!binaryExists) {
467
531
  const stderr = buildResult.stderr || ''
468
532
  throw new Error(
469
- `Failed to build rpk in Linux container: ${stderr}\n` +
533
+ `Failed to build rpk in Linux container: ${stderr || 'build produced no binary'}\n` +
470
534
  'Common causes:\n' +
471
535
  ' 1. Source code is out of date - run: git pull origin dev\n' +
472
536
  ' 2. Go module issues - the container will download dependencies automatically\n' +
@@ -477,15 +541,31 @@ function fetchRpkTreeFromLinuxSource(sourcePath) {
477
541
 
478
542
  // Step 2: Install plugins
479
543
  console.log('Installing plugins for complete command coverage...')
544
+ const failedPlugins = []
480
545
  for (const plugin of KNOWN_PLUGINS) {
481
- console.log(` Installing plugin: ${plugin}...`)
482
- const installResult = spawnSync('docker', [
483
- 'exec', containerId,
484
- '/tmp/rpk', plugin, 'install'
485
- ], {
486
- encoding: 'utf8',
487
- timeout: 120000
488
- })
546
+ // A pin installs a specific version instead of the manifest's latest.
547
+ // This is how pre-GA plugins (no version promoted to latest) get into a
548
+ // full regeneration at all — without a pin their install resolves
549
+ // nothing and their commands are absent from the tree.
550
+ const pin = pluginPins[plugin]
551
+ const versionFlag = PLUGIN_INSTALL_VERSION_FLAGS[plugin]
552
+ const runInstall = (pinned) => {
553
+ const args = ['exec', containerId, '/tmp/rpk', plugin, 'install']
554
+ if (pinned && pin && versionFlag) {
555
+ args.push(versionFlag, pin)
556
+ }
557
+ return spawnSync('docker', args, { encoding: 'utf8', timeout: 120000 })
558
+ }
559
+
560
+ console.log(` Installing plugin: ${plugin}${pin ? ` (pinned to ${pin})` : ''}...`)
561
+ let installResult = runInstall(true)
562
+ if (installResult.status !== 0 && pin) {
563
+ const output = `${installResult.stderr || ''}${installResult.stdout || ''}`
564
+ if (output.includes('unknown flag') || output.includes('is not valid')) {
565
+ console.warn(` rpk rejected the version pin for ${plugin}; retrying without the pin (installs latest)`)
566
+ installResult = runInstall(false)
567
+ }
568
+ }
489
569
 
490
570
  if (installResult.status === 0) {
491
571
  console.log(` ✓ ${plugin} installed`)
@@ -498,13 +578,14 @@ function fetchRpkTreeFromLinuxSource(sourcePath) {
498
578
  console.log(` - ${plugin} is not an installable plugin`)
499
579
  } else {
500
580
  // A failed install is non-fatal: generation continues, but this
501
- // plugin's commands will be absent from the tree. Expected for `k8s`
502
- // during the 26.2 beta window — `rpk k8s install` finds no `latest`
503
- // release because the plugin publisher's stableVersionRe only promotes
504
- // pure X.Y.Z versions, so k8s commands only appear at GA. A pre-GA
505
- // regen that omits `rpk k8s` pages is this, not a generator bug.
581
+ // plugin's commands will be absent from the tree. Expected for
582
+ // beta-only plugins with no version pin — `rpk <plugin> install`
583
+ // finds no `latest` release because the plugin publisher's
584
+ // stableVersionRe only promotes pure X.Y.Z versions, so their
585
+ // commands only appear at GA unless the run pins a version.
506
586
  // See redpanda-data/docs#1801.
507
587
  console.warn(` ✗ Failed to install ${plugin}: ${stderr || stdout}`)
588
+ failedPlugins.push(plugin)
508
589
  }
509
590
  }
510
591
  }
@@ -536,8 +617,9 @@ function fetchRpkTreeFromLinuxSource(sourcePath) {
536
617
  )
537
618
  }
538
619
 
620
+ let tree
539
621
  try {
540
- return JSON.parse(result.stdout)
622
+ tree = JSON.parse(result.stdout)
541
623
  } catch (err) {
542
624
  throw new Error(
543
625
  `Failed to parse rpk tree JSON from Linux source build: ${err.message}\n` +
@@ -545,6 +627,26 @@ function fetchRpkTreeFromLinuxSource(sourcePath) {
545
627
  'This may indicate a version mismatch or corrupted source.'
546
628
  )
547
629
  }
630
+
631
+ // Step 4: Fill in flags for plugin subtrees. Their commands come from
632
+ // --help-autocomplete, which carries no flag data, so without this every
633
+ // plugin command page renders an empty flags section.
634
+ console.log('Extracting flags from plugin command help output...')
635
+ for (const plugin of KNOWN_PLUGINS) {
636
+ const node = (tree.commands || []).find(c => c.name === plugin)
637
+ if (!node || !pluginNodeHasRealCommands(node)) continue
638
+ const enriched = enrichPluginTreeWithFlags(node, (argPath) => {
639
+ const helpResult = spawnSync('docker', [
640
+ 'exec', containerId, '/tmp/rpk', ...argPath, '--help'
641
+ ], { encoding: 'utf8', timeout: 30000 })
642
+ return helpResult.status === 0 ? helpResult.stdout : null
643
+ })
644
+ if (enriched > 0) {
645
+ console.log(` ${plugin}: extracted flags for ${enriched} command(s)`)
646
+ }
647
+ }
648
+
649
+ return { tree, failedPlugins }
548
650
  } finally {
549
651
  // Clean up container
550
652
  console.log('Cleaning up build container...')
@@ -555,105 +657,6 @@ function fetchRpkTreeFromLinuxSource(sourcePath) {
555
657
  }
556
658
  }
557
659
 
558
- /**
559
- * Scan Go source files for Linux-only build tags
560
- * Looks for //go:build linux or // +build linux
561
- * @param {string} sourcePath - Path to rpk Go source directory
562
- * @returns {Set<string>} Set of Linux-only command paths (e.g., 'rpk redpanda tune')
563
- */
564
- function detectLinuxOnlyFromSource(sourcePath) {
565
- const linuxOnlyCommands = new Set()
566
-
567
- // Map of source directory patterns to command paths
568
- // rpk commands are typically in pkg/cli/cmd/<command>/<subcommand>/
569
- const scanDirectory = (dir, prefix = '') => {
570
- if (!fs.existsSync(dir)) return
571
-
572
- const entries = fs.readdirSync(dir, { withFileTypes: true })
573
-
574
- for (const entry of entries) {
575
- const fullPath = path.join(dir, entry.name)
576
-
577
- if (entry.isDirectory()) {
578
- // Recurse into subdirectories
579
- const newPrefix = prefix ? `${prefix} ${entry.name}` : entry.name
580
- scanDirectory(fullPath, newPrefix)
581
- } else if (entry.name.endsWith('.go') && !entry.name.endsWith('_test.go')) {
582
- // Check Go files for build tags
583
- try {
584
- const content = fs.readFileSync(fullPath, 'utf8')
585
- const firstLines = content.split('\n').slice(0, 20).join('\n')
586
-
587
- // Check for Linux build constraints
588
- const hasLinuxTag = /\/\/go:build\s+.*linux/.test(firstLines) ||
589
- /\/\/\s*\+build\s+.*linux/.test(firstLines)
590
-
591
- if (hasLinuxTag) {
592
- // Try to determine command path from file location
593
- const commandPath = inferCommandPath(sourcePath, fullPath, entry.name)
594
- if (commandPath) {
595
- linuxOnlyCommands.add(commandPath)
596
- }
597
- }
598
- } catch (err) {
599
- // Skip files we can't read
600
- }
601
- }
602
- }
603
- }
604
-
605
- // Scan the cmd/rpk directory for command implementations
606
- const cmdDir = path.join(sourcePath, 'pkg', 'cli', 'cmd')
607
- if (fs.existsSync(cmdDir)) {
608
- scanDirectory(cmdDir, 'rpk')
609
- }
610
-
611
- // Also check the older structure
612
- const oldCmdDir = path.join(sourcePath, 'cmd', 'rpk')
613
- if (fs.existsSync(oldCmdDir)) {
614
- scanDirectory(oldCmdDir, 'rpk')
615
- }
616
-
617
- return linuxOnlyCommands
618
- }
619
-
620
- /**
621
- * Infer command path from Go source file location
622
- * @param {string} sourcePath - Root source path
623
- * @param {string} filePath - Full path to Go file
624
- * @param {string} fileName - Name of the Go file
625
- * @returns {string|null} Command path or null
626
- */
627
- function inferCommandPath(sourcePath, filePath, fileName) {
628
- // Get relative path from source root
629
- const relativePath = filePath.replace(sourcePath, '').replace(/^\//, '')
630
-
631
- // Common patterns:
632
- // pkg/cli/cmd/redpanda/tune.go -> rpk redpanda tune
633
- // pkg/cli/cmd/redpanda/start.go -> rpk redpanda start
634
- // cmd/rpk/iotune/iotune.go -> rpk iotune
635
-
636
- // Remove common prefixes and file extension
637
- let parts = relativePath
638
- .replace(/^pkg\/cli\/cmd\//, '')
639
- .replace(/^cmd\/rpk\//, '')
640
- .replace(/\.go$/, '')
641
- .split('/')
642
-
643
- // If file name matches directory name, use directory only
644
- // e.g., iotune/iotune.go -> iotune
645
- if (parts.length > 1 && parts[parts.length - 1] === parts[parts.length - 2]) {
646
- parts = parts.slice(0, -1)
647
- }
648
-
649
- // Filter out common non-command directories
650
- parts = parts.filter(p => !['internal', 'common', 'config', 'cmd'].includes(p))
651
-
652
- if (parts.length === 0) return null
653
-
654
- return 'rpk ' + parts.join(' ')
655
- }
656
-
657
660
  /**
658
661
  * Add platform markers to command tree based on source analysis
659
662
  * @param {Object} tree - Command tree from rpk --print-tree
@@ -668,11 +671,18 @@ function addPlatformMarkersFromSource(tree, linuxOnlyCommands) {
668
671
  [...linuxOnlyCommands].some(loc => cmdPath.startsWith(loc + ' '))
669
672
  }
670
673
 
674
+ // Collect every tree path actually marked Linux-only so the persisted
675
+ // linux_only_commands list is fully expanded (each descendant listed),
676
+ // matching what dynamic Linux-vs-Darwin tree comparison produces.
677
+ const markedPaths = new Set()
678
+
671
679
  const markCommands = (commands, parentPath = 'rpk') => {
672
680
  if (!commands) return commands
673
681
  return commands.map(cmd => {
674
682
  const fullPath = `${parentPath} ${cmd.name}`
675
- const platforms = isLinuxOnly(fullPath)
683
+ const linuxOnly = isLinuxOnly(fullPath)
684
+ if (linuxOnly) markedPaths.add(fullPath)
685
+ const platforms = linuxOnly
676
686
  ? [PLATFORMS.LINUX]
677
687
  : [PLATFORMS.LINUX, PLATFORMS.DARWIN]
678
688
  return {
@@ -691,11 +701,15 @@ function addPlatformMarkersFromSource(tree, linuxOnlyCommands) {
691
701
  }
692
702
  }
693
703
 
704
+ const markedCommands = markCommands(tree.commands)
705
+
694
706
  return {
695
707
  ...tree,
696
708
  platforms: [PLATFORMS.LINUX, PLATFORMS.DARWIN],
697
- linux_only_commands: [...linuxOnlyCommands],
698
- commands: markCommands(tree.commands)
709
+ // Union of detected paths and marked tree paths: keeps detected roots
710
+ // even when the tree was built on a platform where they don't exist
711
+ linux_only_commands: [...new Set([...linuxOnlyCommands, ...markedPaths])].sort(),
712
+ commands: markedCommands
699
713
  }
700
714
  }
701
715
 
@@ -773,7 +787,58 @@ function loadOverrides(overridesPath, commandTree = null, options = {}) {
773
787
  )
774
788
  }
775
789
 
776
- return overrides
790
+ return { overrides, validation }
791
+ }
792
+
793
+ /**
794
+ * Merge deprecation metadata for commands still present in the tree into the
795
+ * overrides file, so their pages render deprecation banners without manual
796
+ * curation. Hidden deprecated commands are excluded: they are absent from
797
+ * --print-tree, have no pages, and would only produce unknown-path warnings.
798
+ * Commands whose overrides already carry a `deprecated` value are left alone.
799
+ * @param {Object} deprecatedCommands - Map from scan-deprecated-commands.js
800
+ * @param {Object} tree - Current command tree
801
+ * @param {string} overridesPath - Path to overrides JSON file
802
+ */
803
+ function mergeVisibleDeprecationsIntoOverrides(deprecatedCommands, tree, overridesPath) {
804
+ const entries = Object.entries(deprecatedCommands || {})
805
+ if (entries.length === 0 || !overridesPath || !fs.existsSync(overridesPath)) {
806
+ return
807
+ }
808
+
809
+ const treePaths = new Set(flattenToMap(tree).keys())
810
+
811
+ let overrides
812
+ try {
813
+ overrides = JSON.parse(fs.readFileSync(overridesPath, 'utf8'))
814
+ } catch (err) {
815
+ console.warn(`Warning: Could not parse overrides file for deprecation merge: ${err.message}`)
816
+ return
817
+ }
818
+ if (!overrides.commands) {
819
+ overrides.commands = {}
820
+ }
821
+
822
+ let annotated = 0
823
+ for (const [cmdPath, info] of entries) {
824
+ if (!treePaths.has(cmdPath)) continue
825
+ const existing = overrides.commands[cmdPath] || {}
826
+ if (existing.deprecated !== undefined) continue
827
+ overrides.commands[cmdPath] = {
828
+ ...existing,
829
+ deprecated: true,
830
+ ...(info.deprecatedMessage && !existing.deprecatedMessage
831
+ ? { deprecatedMessage: info.deprecatedMessage } : {}),
832
+ ...(info.replacement && !existing.replacement
833
+ ? { replacement: info.replacement } : {})
834
+ }
835
+ annotated++
836
+ }
837
+
838
+ if (annotated > 0) {
839
+ fs.writeFileSync(overridesPath, JSON.stringify(overrides, null, 2), 'utf8')
840
+ console.log(`Annotated ${annotated} visible deprecated command(s) in ${overridesPath}`)
841
+ }
777
842
  }
778
843
 
779
844
  /**
@@ -824,8 +889,12 @@ function loadVersionedJson(version, dataDir) {
824
889
  * @param {Object} diffData - Diff data with new commands and flags
825
890
  * @param {string} overridesPath - Path to overrides JSON file
826
891
  * @param {string} version - Version to set as introducedInVersion
892
+ * @param {Object} [pluginVersions] - Plugin versions keyed by rpk command
893
+ * name. Commands under a plugin subtree are stamped with the plugin's own
894
+ * version (the page note renders "introduced in <plugin> version X"), not
895
+ * the rpk version, because plugins release on their own cadence.
827
896
  */
828
- function updateOverridesWithIntroducedVersions(diffData, overridesPath, version) {
897
+ function updateOverridesWithIntroducedVersions(diffData, overridesPath, version, pluginVersions = {}) {
829
898
  const hasNewCommands = diffData.details.newCommands && diffData.details.newCommands.length > 0
830
899
  const hasNewFlags = diffData.details.newFlags && diffData.details.newFlags.length > 0
831
900
 
@@ -833,6 +902,12 @@ function updateOverridesWithIntroducedVersions(diffData, overridesPath, version)
833
902
  return
834
903
  }
835
904
 
905
+ // "rpk connect lint" -> pluginVersions.connect, else the rpk version
906
+ const versionFor = (cmdPath) => {
907
+ const topLevel = cmdPath.split(' ')[1]
908
+ return pluginVersions[topLevel] || version
909
+ }
910
+
836
911
  let overrides = {}
837
912
  if (fs.existsSync(overridesPath)) {
838
913
  try {
@@ -859,7 +934,7 @@ function updateOverridesWithIntroducedVersions(diffData, overridesPath, version)
859
934
  }
860
935
  // Only set if not already set (preserve manual overrides)
861
936
  if (!overrides.commands[cmdPath].introducedInVersion) {
862
- overrides.commands[cmdPath].introducedInVersion = version
937
+ overrides.commands[cmdPath].introducedInVersion = versionFor(cmdPath)
863
938
  commandsUpdated++
864
939
  }
865
940
  }
@@ -883,7 +958,7 @@ function updateOverridesWithIntroducedVersions(diffData, overridesPath, version)
883
958
 
884
959
  // Only set if not already set (preserve manual overrides)
885
960
  if (!overrides.commands[cmdPath].flags[flagName].introducedInVersion) {
886
- overrides.commands[cmdPath].flags[flagName].introducedInVersion = version
961
+ overrides.commands[cmdPath].flags[flagName].introducedInVersion = versionFor(cmdPath)
887
962
  flagsUpdated++
888
963
  }
889
964
  }
@@ -970,8 +1045,80 @@ function countCommands(node) {
970
1045
  * @param {string} whatsNewPath - Path to what's-new.adoc file
971
1046
  * @param {string} version - Version string for display
972
1047
  */
973
- function updateWhatsNewFile(diffData, whatsNewPath, version) {
974
- const whatsNewContent = generateWhatsNewSection(diffData, { version })
1048
+ /**
1049
+ * Build a predicate that reports whether a command path renders as a
1050
+ * linkable page (not excluded and not routed to partials by the overrides).
1051
+ * @param {Object|null} overridesData - Loaded overrides
1052
+ * @returns {Function} (commandPath) => boolean
1053
+ */
1054
+ /**
1055
+ * Build a predicate that reports whether a command path has subcommands,
1056
+ * which determines its page location (groups render into their own dir).
1057
+ * @param {Object} tree - Full command tree
1058
+ * @returns {Function} (commandPath) => boolean
1059
+ */
1060
+ function makeSubcommandPredicate(tree) {
1061
+ const commandMap = flattenToMap(tree)
1062
+ return (commandPath) => {
1063
+ const node = commandMap.get(commandPath)
1064
+ return Boolean(node && (node.commands || []).length > 0)
1065
+ }
1066
+ }
1067
+
1068
+ function makeLinkablePredicate(overridesData) {
1069
+ const resolved = overridesData ? resolveReferences(overridesData, overridesData) : null
1070
+ return (commandPath) => {
1071
+ // rpk cloud and rpk security secret render to partials (single-sourced
1072
+ // into cloud docs), so this repo has no linkable pages for them
1073
+ if (commandPath.startsWith('rpk cloud') || commandPath.startsWith('rpk security secret')) {
1074
+ return false
1075
+ }
1076
+ if (!resolved) return true
1077
+ return !shouldExcludeCommand(resolved, commandPath) &&
1078
+ !shouldUsePartialDir(resolved, commandPath)
1079
+ }
1080
+ }
1081
+
1082
+ // Command subtrees whose changes never belong in the Self-Managed What's
1083
+ // new. rpk ai's documentation home is adp-docs, and the ADP release notes
1084
+ // already cover its CLI changes per release. The plugin-release receiver
1085
+ // workflow excludes ai from --update-whats-new for exactly this reason; the
1086
+ // full-regeneration path must agree, or a full run floods the Self-Managed
1087
+ // release notes with rpk ai entries (a rename release alone produces 21 new
1088
+ // plus 21 removed bullets).
1089
+ const WHATS_NEW_EXCLUDED_SUBTREES = ['rpk ai']
1090
+
1091
+ /**
1092
+ * Return a copy of diffData without entries under the excluded subtrees.
1093
+ * Only the published What's-new block filters; diff reports and PR
1094
+ * summaries keep the full picture.
1095
+ * @param {Object} diffData - Diff from generateRpkDiff
1096
+ * @param {string[]} [excluded] - Command-path prefixes to drop
1097
+ * @returns {Object} Filtered copy
1098
+ */
1099
+ function filterDiffForWhatsNew(diffData, excluded = WHATS_NEW_EXCLUDED_SUBTREES) {
1100
+ const outside = (cmdPath) => !excluded.some(prefix =>
1101
+ cmdPath === prefix || (typeof cmdPath === 'string' && cmdPath.startsWith(prefix + ' ')))
1102
+ const details = diffData.details || {}
1103
+ const filteredDetails = { ...details }
1104
+ for (const key of ['newCommands', 'newlyDeprecatedCommands', 'removedCommands', 'descriptionChanges']) {
1105
+ if (Array.isArray(details[key])) filteredDetails[key] = details[key].filter(e => outside(e.path))
1106
+ }
1107
+ for (const key of ['newFlags', 'removedFlags', 'changedDefaults', 'changedFlagTypes', 'changedFlagRequirements', 'changedFlagDescriptions']) {
1108
+ if (Array.isArray(details[key])) filteredDetails[key] = details[key].filter(e => outside(e.commandPath))
1109
+ }
1110
+ return { ...diffData, details: filteredDetails }
1111
+ }
1112
+
1113
+ function updateWhatsNewFile(diffData, whatsNewPath, version, options = {}) {
1114
+ // Each block opens with a "=== <version>" heading so accumulated blocks
1115
+ // (successive RCs, multiple plugin releases) never collide on section ids
1116
+ const sectionHeading = options.sectionHeading || '== Redpanda CLI'
1117
+ const whatsNewContent = generateWhatsNewSection(diffData, {
1118
+ version,
1119
+ blockLabel: version,
1120
+ ...options
1121
+ })
975
1122
 
976
1123
  if (!whatsNewContent) {
977
1124
  console.log('No Redpanda CLI changes to add to what\'s new')
@@ -987,9 +1134,39 @@ function updateWhatsNewFile(diffData, whatsNewPath, version) {
987
1134
 
988
1135
  const existingContent = fs.readFileSync(whatsNewPath, 'utf8')
989
1136
 
990
- // Check if Redpanda CLI section already exists
991
- if (existingContent.includes('== Redpanda CLI')) {
992
- console.log('Redpanda CLI section already exists in what\'s-new file')
1137
+ // Version-scoped marker block: re-runs for the same version replace their
1138
+ // own block, and later versions (for example, successive RCs in a beta
1139
+ // cycle, or plugin releases) append their own blocks inside the existing
1140
+ // Redpanda CLI section instead of being dropped. Writers can edit or
1141
+ // remove blocks freely; the automation only ever touches content between
1142
+ // its own markers for the same version label.
1143
+ const startMarker = `// AUTOGEN-RPK-CHANGES ${version} START`
1144
+ const endMarker = `// AUTOGEN-RPK-CHANGES ${version} END`
1145
+ const sectionBody = whatsNewContent.replace(/^== [^\n]*\n+/, '')
1146
+ const block = `${startMarker}\n${sectionBody.trimEnd()}\n${endMarker}`
1147
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1148
+
1149
+ if (existingContent.includes(startMarker)) {
1150
+ // Replace this version's existing block (idempotent re-runs)
1151
+ const blockRe = new RegExp(`${escapeRe(startMarker)}[\\s\\S]*?${escapeRe(endMarker)}`)
1152
+ const updatedContent = existingContent.replace(blockRe, block)
1153
+ fs.writeFileSync(whatsNewPath, updatedContent, 'utf8')
1154
+ console.log(`Refreshed existing ${version} block in what's-new file: ${whatsNewPath}`)
1155
+ return
1156
+ }
1157
+
1158
+ const headingMatch = existingContent.match(new RegExp(`^${escapeRe(sectionHeading)}[^\\n]*$`, 'm'))
1159
+ if (headingMatch) {
1160
+ // Append this version's block at the end of the existing section, just
1161
+ // before the next level-2 heading (or end of file)
1162
+ const sectionStart = headingMatch.index + headingMatch[0].length
1163
+ const rest = existingContent.slice(sectionStart)
1164
+ const nextHeading = rest.search(/\n== /)
1165
+ const insertAt = nextHeading === -1 ? existingContent.length : sectionStart + nextHeading
1166
+ const before = existingContent.slice(0, insertAt).replace(/\s*$/, '\n\n')
1167
+ const after = existingContent.slice(insertAt).replace(/^\n*/, '\n')
1168
+ fs.writeFileSync(whatsNewPath, `${before}${block}${after}`, 'utf8')
1169
+ console.log(`Appended ${version} block to the "${sectionHeading}" section in: ${whatsNewPath}`)
993
1170
  return
994
1171
  }
995
1172
 
@@ -1011,19 +1188,583 @@ function updateWhatsNewFile(diffData, whatsNewPath, version) {
1011
1188
  }
1012
1189
  }
1013
1190
 
1191
+ const fullSection = `${sectionHeading}\n\n${block}\n`
1192
+
1014
1193
  let updatedContent
1015
1194
  if (insertIndex > 0) {
1016
1195
  // Insert before the matched section
1017
1196
  updatedContent = existingContent.slice(0, insertIndex) +
1018
- whatsNewContent + '\n' +
1197
+ fullSection + '\n' +
1019
1198
  existingContent.slice(insertIndex)
1020
1199
  } else {
1021
1200
  // Append at the end
1022
- updatedContent = existingContent + '\n' + whatsNewContent
1201
+ updatedContent = existingContent.replace(/\s*$/, '\n\n') + fullSection
1023
1202
  }
1024
1203
 
1025
1204
  fs.writeFileSync(whatsNewPath, updatedContent, 'utf8')
1026
- console.log(`Updated what's-new file: ${whatsNewPath}`)
1205
+ console.log(`Created "${sectionHeading}" section in what's-new file: ${whatsNewPath}`)
1206
+ }
1207
+
1208
+ /**
1209
+ * Build an rpk binary natively from Go source.
1210
+ * @param {string} sourcePath - Path to rpk Go source directory (src/go/rpk)
1211
+ * @param {string} outPath - Where to write the binary
1212
+ * @returns {string} Path to the built binary
1213
+ */
1214
+ function buildRpkBinary(sourcePath, outPath) {
1215
+ const goCheck = spawnSync('go', ['version'], { encoding: 'utf8', timeout: 5000 })
1216
+ if (goCheck.status !== 0) {
1217
+ throw new Error(
1218
+ 'Go is required to build rpk from source but was not found.\n' +
1219
+ 'Install Go from https://go.dev/ and ensure it\'s in your PATH.'
1220
+ )
1221
+ }
1222
+
1223
+ const installedGoVersion = parseGoVersion(goCheck.stdout)
1224
+ const requiredGoVersion = getRequiredGoVersion(sourcePath)
1225
+ if (installedGoVersion && requiredGoVersion &&
1226
+ !checkGoVersionSufficient(installedGoVersion, requiredGoVersion)) {
1227
+ throw new Error(
1228
+ `Go version mismatch: installed ${installedGoVersion}, required >= ${requiredGoVersion}\n` +
1229
+ `The rpk source (go.mod) requires Go ${requiredGoVersion} or newer.`
1230
+ )
1231
+ }
1232
+
1233
+ console.log(`Building rpk from source at ${sourcePath}...`)
1234
+ const buildResult = spawnSync('go', ['build', '-o', outPath, './cmd/rpk'], {
1235
+ cwd: sourcePath,
1236
+ encoding: 'utf8',
1237
+ timeout: 300000
1238
+ })
1239
+
1240
+ if (buildResult.status !== 0) {
1241
+ throw new Error(`Failed to build rpk from source: ${buildResult.stderr}`)
1242
+ }
1243
+
1244
+ return outPath
1245
+ }
1246
+
1247
+ /**
1248
+ * Download an official rpk release binary for the current platform.
1249
+ * @param {string} tag - Release tag (e.g., v26.1.12)
1250
+ * @param {string} destDir - Directory to download and extract into
1251
+ * @returns {string|null} Path to the extracted binary, or null if the
1252
+ * release asset is unavailable (caller falls back to a source build)
1253
+ */
1254
+ function downloadRpkRelease(tag, destDir) {
1255
+ const osName = { darwin: 'darwin', linux: 'linux', win32: 'windows' }[process.platform]
1256
+ const archName = { arm64: 'arm64', x64: 'amd64' }[process.arch]
1257
+ if (!osName || !archName) {
1258
+ console.warn(`No rpk release asset for platform ${process.platform}/${process.arch}`)
1259
+ return null
1260
+ }
1261
+
1262
+ const assetName = `rpk-${osName}-${archName}.zip`
1263
+ const baseUrl = `https://github.com/redpanda-data/redpanda/releases/download/${tag}`
1264
+ const zipPath = path.join(destDir, assetName)
1265
+
1266
+ console.log(`Downloading ${assetName} for ${tag}...`)
1267
+ const curlResult = spawnSync('curl', [
1268
+ '-fL', '--retry', '5', '--retry-all-errors',
1269
+ '--connect-timeout', '30', '--max-time', '300',
1270
+ '-o', zipPath, `${baseUrl}/${assetName}`
1271
+ ], { encoding: 'utf8', timeout: 360000 })
1272
+
1273
+ if (curlResult.status !== 0) {
1274
+ console.warn(`Could not download rpk release for ${tag} (draft or missing release asset)`)
1275
+ return null
1276
+ }
1277
+
1278
+ // Verify against the release checksum file when it exists
1279
+ const checksumAsset = `rpk_${tag.replace(/^v/, '')}_checksums.txt`
1280
+ const checksumPath = path.join(destDir, checksumAsset)
1281
+ const checksumResult = spawnSync('curl', [
1282
+ '-fsSL', '--retry', '3', '--connect-timeout', '30', '--max-time', '60',
1283
+ '-o', checksumPath, `${baseUrl}/${checksumAsset}`
1284
+ ], { encoding: 'utf8', timeout: 90000 })
1285
+
1286
+ if (checksumResult.status === 0) {
1287
+ const expectedLine = fs.readFileSync(checksumPath, 'utf8')
1288
+ .split('\n')
1289
+ .find(line => line.trim().endsWith(assetName))
1290
+ if (expectedLine) {
1291
+ const expected = expectedLine.trim().split(/\s+/)[0]
1292
+ const actual = crypto.createHash('sha256').update(fs.readFileSync(zipPath)).digest('hex')
1293
+ if (expected !== actual) {
1294
+ throw new Error(
1295
+ `Checksum mismatch for ${assetName} (${tag}):\n` +
1296
+ ` expected ${expected}\n actual ${actual}`
1297
+ )
1298
+ }
1299
+ console.log('Checksum verified')
1300
+ }
1301
+ } else {
1302
+ console.warn('No checksum file published for this release; skipping verification')
1303
+ }
1304
+
1305
+ const unzipResult = spawnSync('unzip', ['-o', zipPath, '-d', destDir], {
1306
+ encoding: 'utf8',
1307
+ timeout: 60000
1308
+ })
1309
+ if (unzipResult.status !== 0) {
1310
+ throw new Error(`Failed to extract ${assetName}: ${unzipResult.stderr}`)
1311
+ }
1312
+
1313
+ const binPath = path.join(destDir, 'rpk')
1314
+ if (!fs.existsSync(binPath)) {
1315
+ throw new Error(`Extracted archive did not contain an rpk binary: ${zipPath}`)
1316
+ }
1317
+ fs.chmodSync(binPath, 0o755)
1318
+ return binPath
1319
+ }
1320
+
1321
+ /**
1322
+ * Get an rpk binary matching the given version.
1323
+ * Prefers the official release download (published stable releases only;
1324
+ * RC releases are drafts, so their assets are not publicly downloadable).
1325
+ * Falls back to building from source at the tag.
1326
+ * @param {string} rpkVersion - Version tag from the snapshot (e.g., v26.1.12, v26.2.1-rc2)
1327
+ * @param {Object} [options]
1328
+ * @param {string} [options.rpkBin] - Existing binary to use, skipping download/build
1329
+ * @returns {string} Path to an rpk binary
1330
+ */
1331
+ function acquireRpkBinary(rpkVersion, options = {}) {
1332
+ const { rpkBin } = options
1333
+
1334
+ if (rpkBin) {
1335
+ const binPath = path.resolve(rpkBin)
1336
+ if (!fs.existsSync(binPath)) {
1337
+ throw new Error(`rpk binary not found: ${binPath}`)
1338
+ }
1339
+ console.log(`Using provided rpk binary: ${binPath}`)
1340
+ return binPath
1341
+ }
1342
+
1343
+ const tag = rpkVersion.startsWith('v') ? rpkVersion : `v${rpkVersion}`
1344
+ const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rpk-bin-'))
1345
+
1346
+ // Only published (non-draft) releases have downloadable assets
1347
+ if (/^v\d+\.\d+\.\d+$/.test(tag)) {
1348
+ const downloaded = downloadRpkRelease(tag, workDir)
1349
+ if (downloaded) {
1350
+ return downloaded
1351
+ }
1352
+ console.log('Falling back to building rpk from source...')
1353
+ } else {
1354
+ console.log(`No published release binary for ${tag}; building from source...`)
1355
+ }
1356
+
1357
+ if (!/^v\d+\.\d+\.\d+(-rc\d+)?$/.test(tag) && tag !== 'vdev') {
1358
+ throw new Error(
1359
+ `Cannot acquire an rpk binary for version '${rpkVersion}'.\n` +
1360
+ 'The snapshot\'s rpk_version is not a release tag. ' +
1361
+ 'Pass --rpk-bin <path> to use a local rpk binary instead.'
1362
+ )
1363
+ }
1364
+
1365
+ const sourceRef = tag === 'vdev' ? 'dev' : tag
1366
+ const sourcePath = prepareSourceFromRef(sourceRef, null)
1367
+ try {
1368
+ return buildRpkBinary(sourcePath, path.join(workDir, 'rpk'))
1369
+ } catch (nativeErr) {
1370
+ // Local Go older than go.mod requires is common on laptops; the --ref
1371
+ // path already tolerates this by building in a container, so do the same
1372
+ // here when Docker is available
1373
+ const dockerCheck = spawnSync('docker', ['info'], { encoding: 'utf8', timeout: 10000 })
1374
+ if (dockerCheck.status !== 0) {
1375
+ throw new Error(
1376
+ `${nativeErr.message}\n` +
1377
+ 'Docker is not available for a container build either. ' +
1378
+ 'Update Go, start Docker, or pass --rpk-bin <path> to use an existing rpk binary.'
1379
+ )
1380
+ }
1381
+ console.warn(`Native build failed (${nativeErr.message.split('\n')[0]}); building in a container...`)
1382
+ const goVersion = getRequiredGoVersion(sourcePath)
1383
+ const goImage = goVersion ? `golang:${goVersion}` : 'golang:1'
1384
+ // Cross-compile for the HOST platform: the container reports
1385
+ // GOOS=linux, and a linux binary dies silently when executed on the
1386
+ // macOS host that needs it for plugin installs (review finding on the
1387
+ // 5.3.0 train). rpk builds with CGO disabled, so cross-compilation
1388
+ // from the linux container is safe.
1389
+ // Only macOS and Linux hosts are supported: mapping anything else to
1390
+ // linux would hand a Windows host an unrunnable binary, the exact bug
1391
+ // class the cross-compile fixes (review finding on #238).
1392
+ if (process.platform !== 'darwin' && process.platform !== 'linux') {
1393
+ throw new Error(
1394
+ `${nativeErr.message}\n` +
1395
+ `Container fallback does not support host platform "${process.platform}". ` +
1396
+ 'Update Go, or pass --rpk-bin <path> to use an existing rpk binary.'
1397
+ )
1398
+ }
1399
+ const hostGoos = process.platform === 'darwin' ? 'darwin' : 'linux'
1400
+ const hostGoarch = process.arch === 'arm64' ? 'arm64' : 'amd64'
1401
+ const buildResult = spawnSync('docker', [
1402
+ 'run', '--rm',
1403
+ '-e', `GOOS=${hostGoos}`,
1404
+ '-e', `GOARCH=${hostGoarch}`,
1405
+ '-e', 'CGO_ENABLED=0',
1406
+ '-v', `${path.resolve(sourcePath)}:/rpk-source:ro`,
1407
+ '-v', `${workDir}:/out`,
1408
+ '-w', '/rpk-source',
1409
+ goImage,
1410
+ 'go', 'build', '-o', '/out/rpk', './cmd/rpk'
1411
+ ], { encoding: 'utf8', timeout: 600000 })
1412
+ const binPath = path.join(workDir, 'rpk')
1413
+ if (buildResult.status !== 0 || !fs.existsSync(binPath)) {
1414
+ throw new Error(`Container build failed: ${buildResult.stderr || 'no binary produced'}`)
1415
+ }
1416
+ return binPath
1417
+ }
1418
+ }
1419
+
1420
+ /**
1421
+ * Look up the latest published version of a plugin from its manifest.
1422
+ * @param {string} plugin - rpk command name (ai, connect, k8s, check)
1423
+ * @returns {string|null} Latest version, or null if unavailable
1424
+ */
1425
+ function fetchLatestPluginVersion(plugin) {
1426
+ const slug = PLUGIN_MANIFEST_SLUGS[plugin] || plugin
1427
+ const result = spawnSync('curl', [
1428
+ '-fsSL', '--retry', '3', '--connect-timeout', '15', '--max-time', '30',
1429
+ `${PLUGIN_MANIFEST_HOST}/${slug}/manifest.json`
1430
+ ], { encoding: 'utf8', timeout: 60000 })
1431
+
1432
+ if (result.status !== 0) {
1433
+ console.warn(`Could not fetch plugin manifest for ${plugin} (slug: ${slug})`)
1434
+ return null
1435
+ }
1436
+
1437
+ try {
1438
+ const manifest = JSON.parse(result.stdout)
1439
+ const latest = (manifest.archives || []).find(a => a.is_latest)
1440
+ return latest ? latest.version : null
1441
+ } catch (err) {
1442
+ console.warn(`Could not parse plugin manifest for ${plugin}: ${err.message}`)
1443
+ return null
1444
+ }
1445
+ }
1446
+
1447
+ /**
1448
+ * Check whether a plugin node contains real plugin commands, as opposed to
1449
+ * only the install/uninstall/upgrade shim compiled into rpk itself.
1450
+ * @param {Object} node - Top-level command node for the plugin
1451
+ * @returns {boolean}
1452
+ */
1453
+ function pluginNodeHasRealCommands(node) {
1454
+ return (node.commands || []).some(c => !PLUGIN_SHIM_SUBCOMMANDS.has(c.name))
1455
+ }
1456
+
1457
+ /**
1458
+ * Replace a plugin's top-level node in a command tree.
1459
+ * Replace-only: throws if the plugin is not already present, so a refresh
1460
+ * can never introduce a command that the snapshot's rpk version lacks.
1461
+ * @param {Object} tree - Full command tree (root node)
1462
+ * @param {string} plugin - rpk command name
1463
+ * @param {Object} freshNode - Replacement node
1464
+ * @returns {Object} New tree with the node replaced
1465
+ */
1466
+ function splicePluginNode(tree, plugin, freshNode) {
1467
+ const commands = tree.commands || []
1468
+ if (!commands.some(c => c.name === plugin)) {
1469
+ throw new Error(`Cannot splice plugin '${plugin}': not present in the base tree`)
1470
+ }
1471
+ return {
1472
+ ...tree,
1473
+ commands: commands.map(c => (c.name === plugin ? freshNode : c))
1474
+ }
1475
+ }
1476
+
1477
+ /**
1478
+ * Carry a snapshot's recorded Linux-only command list onto a working tree.
1479
+ * The list comes from scanning Go build tags in the rpk source, which
1480
+ * from-json runs (including --plugin refreshes) never see, so it can only be
1481
+ * inherited from the existing snapshot. Without it the refreshed snapshot
1482
+ * would lose its platform markers for every future from-json run.
1483
+ * @param {Object} tree - Working command tree (root node)
1484
+ * @param {Object} [fallbackTree] - Tree to inherit linux_only_commands from
1485
+ * when the working tree does not carry the field itself
1486
+ * @returns {Object} Tree that carries linux_only_commands when available
1487
+ */
1488
+ function preserveLinuxOnlyCommands(tree, fallbackTree) {
1489
+ if (!tree || tree.linux_only_commands) return tree
1490
+ const inherited = fallbackTree && fallbackTree.linux_only_commands
1491
+ if (!inherited) return tree
1492
+ return { ...tree, linux_only_commands: [...inherited] }
1493
+ }
1494
+
1495
+ /**
1496
+ * Parse the local "Flags:" section of cobra --help output into flag objects
1497
+ * matching the shape rpk --print-tree emits for compiled-in commands.
1498
+ * The "Global Flags:" section is ignored: rpk-core globals are documented by
1499
+ * the shared global-flags table, and plugin-global flags are captured from
1500
+ * the plugin root command's own Flags section.
1501
+ * @param {string} helpText - Output of `rpk <command> --help`
1502
+ * @returns {Array<Object>} Flags: { name, shorthand?, type, description, default? }
1503
+ */
1504
+ function parseCobraFlags(helpText) {
1505
+ const lines = (helpText || '').split('\n')
1506
+ const start = lines.findIndex(l => /^Flags:\s*$/.test(l))
1507
+ if (start === -1) return []
1508
+
1509
+ const flags = []
1510
+ let flagIndent = null
1511
+ for (let i = start + 1; i < lines.length; i++) {
1512
+ const line = lines[i]
1513
+ if (/^\S/.test(line)) break // next section (Global Flags:, Use "...", ...)
1514
+ if (line.trim() === '') break
1515
+
1516
+ const m = line.match(/^\s+(?:-(\w),\s+)?--([\w.-]+)(?:\s+(\S+))?\s{2,}(.*)$/)
1517
+ if (m) {
1518
+ const [, shorthand, name, valueType, desc] = m
1519
+ if (name === 'help') continue
1520
+ flagIndent = line.search(/\S/)
1521
+ const flag = {
1522
+ name,
1523
+ type: valueType || 'bool',
1524
+ description: desc.trim()
1525
+ }
1526
+ if (shorthand) flag.shorthand = shorthand
1527
+ const def = flag.description.match(/\s*\(default (.+)\)$/)
1528
+ if (def) {
1529
+ flag.default = def[1]
1530
+ flag.description = flag.description.slice(0, def.index).trim()
1531
+ }
1532
+ flags.push(flag)
1533
+ } else if (flags.length > 0 && flagIndent !== null && line.search(/\S/) > flagIndent) {
1534
+ // Wrapped description continuation: any line indented deeper than the
1535
+ // flag column that did not parse as a flag. Continuations can begin
1536
+ // with flag-looking tokens ("(alias: --x-ref)" wraps to "--x-ref)"),
1537
+ // so no dash guard — a genuine flag line always matches the regex
1538
+ // above first.
1539
+ const last = flags[flags.length - 1]
1540
+ last.description = `${last.description} ${line.trim()}`.trim()
1541
+ const def = last.description.match(/\s*\(default (.+)\)$/)
1542
+ if (def) {
1543
+ last.default = def[1]
1544
+ last.description = last.description.slice(0, def.index).trim()
1545
+ }
1546
+ }
1547
+ }
1548
+ return flags
1549
+ }
1550
+
1551
+ /**
1552
+ * Parse the OPTIONS section of urfave/cli --help output (Redpanda Connect)
1553
+ * into the same flag shape as parseCobraFlags. Entries look like:
1554
+ * --log.level value override the configured log level
1555
+ * --set value, -s value [ --set value, -s value ] set a field ...
1556
+ * --chilled continue ... (default: false)
1557
+ * The GLOBAL OPTIONS section is skipped, matching the cobra handling.
1558
+ * @param {string} helpText - Output of `rpk <command> --help`
1559
+ * @returns {Array<Object>} Flags: { name, shorthand?, type, description, default? }
1560
+ */
1561
+ function parseUrfaveFlags(helpText) {
1562
+ const lines = (helpText || '').split('\n')
1563
+ const start = lines.findIndex(l => /^OPTIONS:\s*$/.test(l))
1564
+ if (start === -1) return []
1565
+
1566
+ const flags = []
1567
+ let urfaveIndent = null
1568
+ for (let i = start + 1; i < lines.length; i++) {
1569
+ const line = lines[i]
1570
+ if (/^\S/.test(line)) break // next section (GLOBAL OPTIONS:, ...)
1571
+ if (line.trim() === '') break
1572
+
1573
+ const m = line.match(/^\s{2,}(--\S[^\s]*(?:[^ ]| (?!\s))*)\s{2,}(.*)$/)
1574
+ if (m) {
1575
+ const [, spec, desc] = m
1576
+ const nameMatch = spec.match(/^--([\w.-]+)/)
1577
+ if (!nameMatch) continue
1578
+ const name = nameMatch[1]
1579
+ if (name === 'help') continue
1580
+ urfaveIndent = line.search(/\S/)
1581
+
1582
+ const flag = { name, description: desc.trim() }
1583
+ const shorthandMatch = spec.match(/,\s+-(\w)\b/)
1584
+ if (shorthandMatch) flag.shorthand = shorthandMatch[1]
1585
+
1586
+ const repeatable = spec.includes('[ --')
1587
+ const hasValue = new RegExp(`^--${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s+\\S`).test(spec)
1588
+ flag.type = repeatable ? 'strings' : (hasValue ? 'string' : 'bool')
1589
+
1590
+ const def = flag.description.match(/\s*\(default:\s*(.+?)\)$/)
1591
+ if (def) {
1592
+ flag.default = def[1]
1593
+ flag.description = flag.description.slice(0, def.index).trim()
1594
+ }
1595
+ flags.push(flag)
1596
+ } else if (flags.length > 0 && urfaveIndent !== null && line.search(/\S/) > urfaveIndent) {
1597
+ // Continuation lines may begin with flag-looking tokens; see the
1598
+ // cobra parser above for why there is no dash guard
1599
+ const last = flags[flags.length - 1]
1600
+ last.description = `${last.description} ${line.trim()}`.trim()
1601
+ const def = last.description.match(/\s*\(default:\s*(.+?)\)$/)
1602
+ if (def) {
1603
+ last.default = def[1]
1604
+ last.description = last.description.slice(0, def.index).trim()
1605
+ }
1606
+ }
1607
+ }
1608
+ return flags
1609
+ }
1610
+
1611
+ /**
1612
+ * Parse flags from --help output regardless of CLI framework: cobra prints a
1613
+ * "Flags:" section (rpk ai, k8s, check), urfave/cli prints "OPTIONS:"
1614
+ * (Redpanda Connect).
1615
+ * @param {string} helpText
1616
+ * @returns {Array<Object>}
1617
+ */
1618
+ function parseHelpFlags(helpText) {
1619
+ if (/^Flags:\s*$/m.test(helpText || '')) return parseCobraFlags(helpText)
1620
+ if (/^OPTIONS:\s*$/m.test(helpText || '')) return parseUrfaveFlags(helpText)
1621
+ return []
1622
+ }
1623
+
1624
+ /**
1625
+ * Fill in flags for plugin subtree commands by running `--help` per command.
1626
+ * Plugin subcommand nodes come from the plugin's --help-autocomplete output,
1627
+ * which carries no flag information, so without this every plugin command
1628
+ * page renders with an empty flags section. Nodes that already have flags
1629
+ * (the install/uninstall/upgrade shim compiled into rpk) are left alone.
1630
+ * Help failures are non-fatal: the affected command keeps an empty list.
1631
+ * @param {Object} node - The plugin's top-level command node (mutated)
1632
+ * @param {Function} execHelp - (argPath: string[]) => string|null, returns
1633
+ * the help text for `rpk <argPath...> --help`
1634
+ * @returns {number} Number of commands enriched
1635
+ */
1636
+ function enrichPluginTreeWithFlags(node, execHelp) {
1637
+ let enriched = 0
1638
+ const walk = (cmd, argPath) => {
1639
+ if (!cmd.flags || cmd.flags.length === 0) {
1640
+ const helpText = execHelp(argPath)
1641
+ if (helpText) {
1642
+ const flags = parseHelpFlags(helpText)
1643
+ if (flags.length > 0) {
1644
+ cmd.flags = flags
1645
+ enriched++
1646
+ }
1647
+ }
1648
+ }
1649
+ for (const child of cmd.commands || []) {
1650
+ walk(child, [...argPath, child.name])
1651
+ }
1652
+ }
1653
+ walk(node, [node.name])
1654
+ return enriched
1655
+ }
1656
+
1657
+ /**
1658
+ * Install a single plugin and capture its fresh command subtree.
1659
+ * Runs with an isolated HOME so the install never touches the caller's
1660
+ * rpk plugin state, and the caller's installed plugins never leak in.
1661
+ * @param {Object} params
1662
+ * @param {string} params.plugin - rpk command name (ai, connect, k8s, check)
1663
+ * @param {string} [params.pluginVersion] - Version to pin; defaults to latest
1664
+ * @param {string} params.rpkBinPath - rpk binary to run
1665
+ * @returns {Object} { node, version } - Fresh plugin node and the version recorded
1666
+ */
1667
+ function fetchPluginSubtree({ plugin, pluginVersion, rpkBinPath }) {
1668
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'rpk-plugin-home-'))
1669
+ const env = { ...process.env, HOME: tmpHome }
1670
+
1671
+ const runInstall = (pinned) => {
1672
+ const args = [plugin, 'install']
1673
+ const versionFlag = PLUGIN_INSTALL_VERSION_FLAGS[plugin]
1674
+ if (pinned && pluginVersion && versionFlag) {
1675
+ args.push(versionFlag, pluginVersion)
1676
+ }
1677
+ console.log(`Installing plugin: rpk ${args.join(' ')}`)
1678
+ return spawnSync(rpkBinPath, args, { encoding: 'utf8', env, timeout: 300000 })
1679
+ }
1680
+
1681
+ let pinApplied = Boolean(pluginVersion)
1682
+ let installResult = runInstall(true)
1683
+ if (installResult.status !== 0 && pluginVersion) {
1684
+ const output = `${installResult.stderr || ''}${installResult.stdout || ''}`
1685
+ // 'unknown flag': this rpk predates the pin flag.
1686
+ // 'is not valid': rpk's version validation rejected the pin (its regex
1687
+ // caps each segment at two digits, so connect versions like 4.102.0
1688
+ // fail). In both cases installing latest is the right recovery: the
1689
+ // dispatch fires right after a release, when latest IS the release.
1690
+ if (output.includes('unknown flag') || output.includes('is not valid')) {
1691
+ console.warn(
1692
+ `rpk rejected the version pin for '${plugin}' ` +
1693
+ `(${output.trim().split('\n')[0]}); retrying without the pin (installs latest)`
1694
+ )
1695
+ pinApplied = false
1696
+ installResult = runInstall(false)
1697
+ }
1698
+ }
1699
+ if (installResult.status !== 0) {
1700
+ throw new Error(
1701
+ `Failed to install plugin '${plugin}':\n` +
1702
+ `${installResult.stderr || installResult.stdout || 'no output'}`
1703
+ )
1704
+ }
1705
+
1706
+ const treeResult = spawnSync(rpkBinPath, ['--print-tree'], {
1707
+ encoding: 'utf8',
1708
+ env,
1709
+ timeout: 120000,
1710
+ maxBuffer: 50 * 1024 * 1024
1711
+ })
1712
+ if (treeResult.status !== 0) {
1713
+ const stderr = treeResult.stderr || ''
1714
+ if (stderr.includes('unknown flag')) {
1715
+ throw new Error(
1716
+ 'This rpk binary does not support --print-tree (requires rpk >= v26.2.0).\n' +
1717
+ 'Pass --rpk-bin with a newer binary.'
1718
+ )
1719
+ }
1720
+ throw new Error(`Failed to run rpk --print-tree: ${stderr}`)
1721
+ }
1722
+
1723
+ const freshTree = JSON.parse(treeResult.stdout)
1724
+ const node = (freshTree.commands || []).find(c => c.name === plugin)
1725
+ if (!node) {
1726
+ throw new Error(`Plugin '${plugin}' not found in rpk command tree after install`)
1727
+ }
1728
+ if (!pluginNodeHasRealCommands(node)) {
1729
+ throw new Error(
1730
+ `Plugin '${plugin}' installed but exposed no commands beyond the ` +
1731
+ 'install/uninstall/upgrade shim.\n' +
1732
+ 'The install likely resolved nothing. For pre-GA plugins (no promoted ' +
1733
+ 'latest version in the manifest), a version pin is required: ' +
1734
+ `--plugin-version <version>`
1735
+ )
1736
+ }
1737
+
1738
+ // Fill in per-command flags: autocomplete trees carry none, but the plugin
1739
+ // binary is installed, so each command's --help documents them
1740
+ console.log(`Extracting flags from ${plugin} command help output...`)
1741
+ const enriched = enrichPluginTreeWithFlags(node, (argPath) => {
1742
+ const helpResult = spawnSync(rpkBinPath, [...argPath, '--help'], {
1743
+ encoding: 'utf8',
1744
+ env,
1745
+ timeout: 30000
1746
+ })
1747
+ return helpResult.status === 0 ? helpResult.stdout : null
1748
+ })
1749
+ console.log(` Extracted flags for ${enriched} command(s)`)
1750
+
1751
+ // When the pin was applied, record it. When we fell back to latest (or no
1752
+ // pin was given), record what the manifest says latest is: that is what
1753
+ // actually installed.
1754
+ const version = pinApplied ? pluginVersion : fetchLatestPluginVersion(plugin)
1755
+ if (!version) {
1756
+ console.warn(
1757
+ `Could not determine the installed version of '${plugin}'; ` +
1758
+ 'plugin_versions will not be updated for this run'
1759
+ )
1760
+ } else if (pluginVersion && version !== pluginVersion) {
1761
+ console.warn(
1762
+ `Requested ${plugin} ${pluginVersion} but installed latest (${version}) ` +
1763
+ 'because rpk rejected the version pin'
1764
+ )
1765
+ }
1766
+
1767
+ return { node, version }
1027
1768
  }
1028
1769
 
1029
1770
  /**
@@ -1042,6 +1783,10 @@ async function handleRpkDocsGeneration(options = {}) {
1042
1783
  overrides: overridesPath,
1043
1784
  fromSource, // Path to local rpk Go source directory
1044
1785
  fromJson, // Path to existing versioned JSON file to regenerate from
1786
+ plugin, // Refresh a single plugin's subtree (requires fromJson)
1787
+ pluginVersion, // Version to pin for the plugin install (defaults to latest)
1788
+ pluginPins = {}, // Per-plugin version pins for full generation (pre-GA plugins)
1789
+ rpkBin, // Existing rpk binary to use for the plugin refresh
1045
1790
  ref, // Git ref (branch or tag) to document
1046
1791
  sourceRef, // Alias for ref
1047
1792
  diff: diffVersion,
@@ -1058,6 +1803,15 @@ async function handleRpkDocsGeneration(options = {}) {
1058
1803
  // Normalize ref/sourceRef
1059
1804
  const effectiveRef = ref || sourceRef
1060
1805
 
1806
+ for (const pinnedPlugin of Object.keys(pluginPins)) {
1807
+ if (!REFRESHABLE_PLUGINS.includes(pinnedPlugin)) {
1808
+ console.warn(
1809
+ `Warning: --plugin-pin for unknown plugin '${pinnedPlugin}' ` +
1810
+ `(known plugins: ${REFRESHABLE_PLUGINS.join(', ')}); the pin will have no effect`
1811
+ )
1812
+ }
1813
+ }
1814
+
1061
1815
  const repoRoot = findRepoRoot()
1062
1816
  const dataDir = customDataDir || path.join(repoRoot, 'docs-data')
1063
1817
  const defaultOutputDir = path.join(repoRoot, 'modules', 'reference', 'pages', 'rpk')
@@ -1094,6 +1848,10 @@ async function handleRpkDocsGeneration(options = {}) {
1094
1848
  let tree
1095
1849
  let rpkVersion
1096
1850
  let sourcePath
1851
+ // Managed plugins whose install failed this run. Passed to generateRpkDocs
1852
+ // as explicit protectedPlugins (merged there with auto-detection) so a
1853
+ // failed install never deletes or rewrites that plugin's existing docs.
1854
+ let failedPlugins = []
1097
1855
 
1098
1856
  try {
1099
1857
  // Fast path: regenerate from existing JSON file
@@ -1105,6 +1863,10 @@ async function handleRpkDocsGeneration(options = {}) {
1105
1863
  console.log(`Loading command tree from ${jsonPath}`)
1106
1864
  const jsonData = JSON.parse(fs.readFileSync(jsonPath, 'utf8'))
1107
1865
  tree = jsonData.raw_tree || jsonData.tree
1866
+ // Inherit the Linux-only command list from whichever stored tree
1867
+ // carries it: it cannot be re-detected without the rpk source, and
1868
+ // both rendering and the re-saved snapshot below depend on it.
1869
+ tree = preserveLinuxOnlyCommands(tree, jsonData.tree || jsonData.raw_tree)
1108
1870
  rpkVersion = jsonData.rpk_version || 'local'
1109
1871
 
1110
1872
  if (!tree) {
@@ -1113,16 +1875,158 @@ async function handleRpkDocsGeneration(options = {}) {
1113
1875
 
1114
1876
  console.log(`Loaded tree with rpk version: ${rpkVersion}`)
1115
1877
 
1878
+ // Refresh a single plugin's subtree in the snapshot before rendering.
1879
+ // Plugins release on their own cadence, so their commands can change
1880
+ // without a new rpk release. Splice the fresh subtree into the full
1881
+ // tree and re-render everything: only the plugin's pages actually
1882
+ // change, and stale-file cleanup, nav rebuild, and override
1883
+ // validation all see a complete tree.
1884
+ let pluginVersions = jsonData.plugin_versions || {}
1885
+ let pluginDiffData = null
1886
+ if (plugin) {
1887
+ if (!REFRESHABLE_PLUGINS.includes(plugin)) {
1888
+ throw new Error(
1889
+ `Unknown plugin '${plugin}'. Supported plugins: ${REFRESHABLE_PLUGINS.join(', ')}\n` +
1890
+ 'Use the rpk command name (for example "ai"), not the manifest slug ("rpai").'
1891
+ )
1892
+ }
1893
+
1894
+ const oldNode = (tree.commands || []).find(c => c.name === plugin)
1895
+ if (!oldNode) {
1896
+ console.log(
1897
+ `rpk ${rpkVersion} has no '${plugin}' command; nothing to update. ` +
1898
+ 'This is expected when the plugin only exists in a newer rpk line.'
1899
+ )
1900
+ return {
1901
+ success: true,
1902
+ skipped: true,
1903
+ reason: `plugin '${plugin}' not present in rpk ${rpkVersion}`,
1904
+ rpkVersion
1905
+ }
1906
+ }
1907
+
1908
+ const rpkBinPath = acquireRpkBinary(rpkVersion, { rpkBin })
1909
+ const { node: rawNode, version: resolvedVersion } = fetchPluginSubtree({
1910
+ plugin,
1911
+ pluginVersion,
1912
+ rpkBinPath
1913
+ })
1914
+
1915
+ // Reapply platform markers: the fresh subtree comes from a plain
1916
+ // --print-tree run, but every command in the snapshot's tree carries
1917
+ // a platforms field. Reuse the snapshot's recorded Linux-only list.
1918
+ const linuxOnly = new Set(tree.linux_only_commands || [])
1919
+ const freshNode = addPlatformMarkersFromSource(
1920
+ { commands: [rawNode] },
1921
+ linuxOnly
1922
+ ).commands[0]
1923
+
1924
+ // Plugin-scoped diff for the PR summary: compare only the plugin's
1925
+ // subtree, old snapshot state vs fresh install
1926
+ pluginDiffData = generateRpkDiff(
1927
+ { ...tree, commands: [oldNode] },
1928
+ { ...tree, commands: [freshNode] },
1929
+ {
1930
+ oldVersion: pluginVersions[plugin] || 'previous snapshot',
1931
+ newVersion: resolvedVersion || 'latest'
1932
+ }
1933
+ )
1934
+ printDiffReport(pluginDiffData)
1935
+
1936
+ tree = splicePluginNode(tree, plugin, freshNode)
1937
+ if (resolvedVersion) {
1938
+ pluginVersions = { ...pluginVersions, [plugin]: resolvedVersion }
1939
+ }
1940
+ console.log(
1941
+ `Spliced fresh '${plugin}' subtree` +
1942
+ (resolvedVersion ? ` (version ${resolvedVersion})` : '') +
1943
+ ` into snapshot for rpk ${rpkVersion}`
1944
+ )
1945
+
1946
+ // Stamp new plugin commands and flags with the plugin's own version
1947
+ // so pages render "This command was introduced in <plugin> version X".
1948
+ // Runs before the overrides load below, so the stamps apply to this
1949
+ // run's rendering, not just the next one.
1950
+ if (resolvedVersion) {
1951
+ updateOverridesWithIntroducedVersions(
1952
+ pluginDiffData,
1953
+ overridesPath || path.join(dataDir, 'rpk-overrides.json'),
1954
+ resolvedVersion,
1955
+ { [plugin]: resolvedVersion }
1956
+ )
1957
+ }
1958
+
1959
+ if (diffVersion) {
1960
+ console.warn('Note: --diff is ignored in --plugin mode (the summary uses a plugin-scoped diff)')
1961
+ }
1962
+
1963
+ // Plugin releases land in What's new too when requested: their
1964
+ // changes never arrive through a Redpanda release diff. Entries
1965
+ // render without xrefs because plugin subtrees may render as
1966
+ // partials with no linkable pages.
1967
+ if (whatsNewPath) {
1968
+ if (WHATS_NEW_EXCLUDED_SUBTREES.includes(`rpk ${plugin}`)) {
1969
+ console.log(`Skipping What's new for rpk ${plugin}: its documentation home covers CLI changes in its own release notes`)
1970
+ } else {
1971
+ const label = resolvedVersion
1972
+ ? `${plugin} plugin ${resolvedVersion}`
1973
+ : `${plugin} plugin`
1974
+ updateWhatsNewFile(pluginDiffData, whatsNewPath, label, { xrefs: false, sectionHeading: '== rpk plugins' })
1975
+ }
1976
+ }
1977
+ }
1978
+
1116
1979
  // Skip to documentation generation (after the source building steps)
1117
1980
  // Load and validate overrides
1118
1981
  const defaultOverridesPath = path.join(dataDir, 'rpk-overrides.json')
1119
1982
  const effectiveOverridesPath = overridesPath || defaultOverridesPath
1120
- const overridesData = loadOverrides(effectiveOverridesPath, tree, { strict: false })
1983
+
1984
+ // Stamp new commands and flags with introducedInVersion BEFORE the
1985
+ // overrides load so this run's pages render the labels. Without this,
1986
+ // a --from-json --diff run emits unlabeled pages and the labels only
1987
+ // appear on the next regeneration (the from-source path stamps before
1988
+ // rendering already).
1989
+ if (diffVersion && !plugin) {
1990
+ const oldDataForStamp = loadVersionedJson(diffVersion, dataDir)
1991
+ if (oldDataForStamp) {
1992
+ const stampDiff = generateRpkDiff(oldDataForStamp.raw_tree || oldDataForStamp.tree, tree, {
1993
+ oldVersion: diffVersion,
1994
+ newVersion: rpkVersion,
1995
+ oldDeprecatedCommands: oldDataForStamp.deprecated_commands || {},
1996
+ newDeprecatedCommands: jsonData.deprecated_commands || {}
1997
+ })
1998
+ if (stampDiff.details.newCommands.length > 0 || stampDiff.details.newFlags.length > 0) {
1999
+ updateOverridesWithIntroducedVersions(stampDiff, effectiveOverridesPath, rpkVersion, pluginVersions)
2000
+ }
2001
+ }
2002
+ }
2003
+
2004
+ const { overrides: overridesData, validation: overrideValidation } = loadOverrides(effectiveOverridesPath, tree, { strict: false }) || {}
1121
2005
 
1122
2006
  if (overridesData) {
1123
2007
  console.log(`Loaded overrides from ${effectiveOverridesPath}`)
1124
2008
  }
1125
2009
 
2010
+ // Persist the merged snapshot so the refreshed plugin subtree and its
2011
+ // recorded version become the new baseline (mirrors the from-source
2012
+ // path's augmentedData structure)
2013
+ if (plugin) {
2014
+ // The Linux-only list can't be re-detected during a plugin refresh
2015
+ // (it comes from Go build-tag scanning of the rpk source), so make
2016
+ // sure the saved snapshot's trees still carry it before persisting.
2017
+ tree = preserveLinuxOnlyCommands(tree, jsonData.raw_tree || jsonData.tree)
2018
+ let enhancedTree = tree
2019
+ if (overridesData) {
2020
+ const resolvedOverrides = resolveReferences(overridesData, overridesData)
2021
+ enhancedTree = applyOverridesToTree(tree, resolvedOverrides, '')
2022
+ }
2023
+ jsonData.raw_tree = tree
2024
+ jsonData.tree = enhancedTree
2025
+ jsonData.plugin_versions = pluginVersions
2026
+ jsonData.generated_at = new Date().toISOString()
2027
+ saveVersionedJson(jsonData, rpkVersion, dataDir)
2028
+ }
2029
+
1126
2030
  // Generate AsciiDoc documentation
1127
2031
  console.log(`\nGenerating AsciiDoc files to ${finalOutputDir}...`)
1128
2032
 
@@ -1132,10 +2036,11 @@ async function handleRpkDocsGeneration(options = {}) {
1132
2036
  outputDir: finalOutputDir,
1133
2037
  cloudSecretDir: finalCloudSecretDir,
1134
2038
  rpkVersion,
1135
- pluginVersions: {},
2039
+ pluginVersions,
1136
2040
  draftMissing,
1137
2041
  preservationsDir: preserveFrom,
1138
- navFile
2042
+ navFile,
2043
+ protectedPlugins: failedPlugins
1139
2044
  })
1140
2045
 
1141
2046
  console.log(`\nGeneration complete!`)
@@ -1154,16 +2059,19 @@ async function handleRpkDocsGeneration(options = {}) {
1154
2059
  showInfo
1155
2060
  })
1156
2061
 
1157
- // Generate diff if requested
2062
+ // Generate diff if requested (rpk-version diff; not used in --plugin
2063
+ // mode, which produces its own plugin-scoped diff)
1158
2064
  let diffData = null
1159
- if (diffVersion) {
2065
+ if (diffVersion && !plugin) {
1160
2066
  const oldData = loadVersionedJson(diffVersion, dataDir)
1161
2067
  if (oldData) {
1162
2068
  // Use raw_tree for diffing (falls back to tree for backward compatibility)
1163
2069
  const oldTree = oldData.raw_tree || oldData.tree
1164
2070
  diffData = generateRpkDiff(oldTree, tree, {
1165
2071
  oldVersion: diffVersion,
1166
- newVersion: rpkVersion
2072
+ newVersion: rpkVersion,
2073
+ oldDeprecatedCommands: oldData.deprecated_commands || {},
2074
+ newDeprecatedCommands: jsonData.deprecated_commands || {}
1167
2075
  })
1168
2076
 
1169
2077
  // Save diff
@@ -1177,7 +2085,7 @@ async function handleRpkDocsGeneration(options = {}) {
1177
2085
 
1178
2086
  // Update what's-new file if requested
1179
2087
  if (whatsNewPath) {
1180
- updateWhatsNewFile(diffData, whatsNewPath, rpkVersion)
2088
+ updateWhatsNewFile(filterDiffForWhatsNew(diffData), whatsNewPath, rpkVersion, { linkable: makeLinkablePredicate(overridesData), hasSubcommands: makeSubcommandPredicate(tree) })
1181
2089
  }
1182
2090
  } else {
1183
2091
  console.warn(`Warning: Could not load previous version ${diffVersion} for diff`)
@@ -1187,11 +2095,15 @@ async function handleRpkDocsGeneration(options = {}) {
1187
2095
  // Generate PR summary
1188
2096
  const prSummary = generatePRSummary({
1189
2097
  rpkVersion,
2098
+ plugin,
2099
+ pluginVersion: plugin ? pluginVersions[plugin] : undefined,
1190
2100
  commandCount: result.commandCount,
1191
2101
  filesGenerated: result.filesGenerated,
1192
2102
  filesSkipped: result.filesSkipped,
1193
- diffData,
2103
+ diffData: plugin ? pluginDiffData : diffData,
1194
2104
  validationResult,
2105
+ overrideValidation,
2106
+ descriptionCoverage: computeDescriptionCoverage(tree, overridesData),
1195
2107
  outputDir: finalOutputDir
1196
2108
  })
1197
2109
 
@@ -1211,12 +2123,21 @@ async function handleRpkDocsGeneration(options = {}) {
1211
2123
  filesSkipped: result.filesSkipped,
1212
2124
  outputDir: finalOutputDir,
1213
2125
  rpkVersion,
1214
- diffData,
2126
+ plugin,
2127
+ pluginVersion: plugin ? pluginVersions[plugin] : undefined,
2128
+ diffData: plugin ? pluginDiffData : diffData,
1215
2129
  validationResult,
1216
2130
  prSummary
1217
2131
  }
1218
2132
  }
1219
2133
 
2134
+ if (plugin) {
2135
+ throw new Error(
2136
+ '--plugin requires --from-json <snapshot>.\n' +
2137
+ 'A plugin refresh splices the fresh subtree into an existing committed snapshot.'
2138
+ )
2139
+ }
2140
+
1220
2141
  // Step 1: Get source code
1221
2142
  if (effectiveRef) {
1222
2143
  // Clone/checkout from ref
@@ -1264,10 +2185,17 @@ async function handleRpkDocsGeneration(options = {}) {
1264
2185
  rpkVersion = effectiveRef || 'local'
1265
2186
 
1266
2187
  // Step 2: Detect Linux-only commands
1267
- // Try dynamic detection first (compare Linux vs Darwin builds)
1268
- // Fall back to static lists for plugins and known commands
2188
+ // Static detection (build-constraint analysis) works on any platform,
2189
+ // including Linux CI runners. Dynamic detection (comparing Linux vs
2190
+ // Darwin builds) supplements it below when the host can run both.
1269
2191
  console.log('\nAnalyzing source for Linux-only commands...')
1270
2192
  let linuxOnlyCommands = detectLinuxOnlyFromSource(sourcePath)
2193
+ if (linuxOnlyCommands.size > 0) {
2194
+ console.log(`Static detection found ${linuxOnlyCommands.size} Linux-only command path(s):`)
2195
+ for (const cmd of [...linuxOnlyCommands].sort()) {
2196
+ console.log(` - ${cmd}`)
2197
+ }
2198
+ }
1271
2199
 
1272
2200
  // Step 3: Build rpk and get command tree
1273
2201
  // Use Docker if available (for running rpk plugins in Linux)
@@ -1277,33 +2205,56 @@ async function handleRpkDocsGeneration(options = {}) {
1277
2205
  const canBuildNative = goCheck.status === 0
1278
2206
  const canBuildLinux = dockerCheck.status === 0
1279
2207
 
1280
- // Dynamic detection: if we can build on both platforms, compare the trees
1281
- if (canBuildLinux && canBuildNative && os.platform() !== 'linux') {
2208
+ // Dynamic detection: if we can build on both platforms, compare the trees.
2209
+ // Only possible on non-Linux hosts: a Linux runner can cross-compile a
2210
+ // darwin rpk but cannot execute it to get its command tree, so Linux CI
2211
+ // relies on the static detection above.
2212
+ // RPK_DOCS_SKIP_DYNAMIC_DETECTION=1 forces the static-only path (useful
2213
+ // for testing what a Linux CI runner would produce).
2214
+ const skipDynamicDetection = ['1', 'true'].includes(
2215
+ String(process.env.RPK_DOCS_SKIP_DYNAMIC_DETECTION || '').toLowerCase()
2216
+ )
2217
+ if (skipDynamicDetection) {
2218
+ console.log('\nDynamic platform detection disabled via RPK_DOCS_SKIP_DYNAMIC_DETECTION')
2219
+ }
2220
+ if (canBuildLinux && canBuildNative && os.platform() !== 'linux' && !skipDynamicDetection) {
1282
2221
  console.log('\nBuilding rpk on both Linux and Darwin for dynamic platform detection...')
1283
2222
 
1284
2223
  try {
1285
2224
  // Build on Linux (in container) - has all commands
1286
2225
  console.log('Building rpk in Linux container...')
1287
- const linuxTree = fetchRpkTreeFromLinuxSource(sourcePath)
1288
-
1289
- // Build natively (on Darwin) - missing Linux-only commands
1290
- console.log('Building rpk natively for comparison...')
1291
- const darwinTree = fetchRpkTreeFromSource(sourcePath)
1292
-
1293
- // Compare trees to find Linux-only commands
1294
- const dynamicLinuxOnly = detectLinuxOnlyByComparison(linuxTree, darwinTree)
1295
- if (dynamicLinuxOnly.size > 0) {
1296
- console.log(`Dynamic detection found ${dynamicLinuxOnly.size} Linux-only command(s):`)
1297
- for (const cmd of dynamicLinuxOnly) {
1298
- console.log(` - ${cmd}`)
1299
- linuxOnlyCommands.add(cmd)
2226
+ const { tree: linuxTree, failedPlugins: linuxFailedPlugins } = fetchRpkTreeFromLinuxSource(sourcePath, pluginPins)
2227
+
2228
+ // Build natively (on Darwin) for comparison. The Linux tree is
2229
+ // authoritative, so a comparison-build failure (for example, local
2230
+ // Go older than go.mod requires) only skips dynamic platform
2231
+ // detection - it must not discard the Linux tree.
2232
+ let darwinTree = null
2233
+ try {
2234
+ console.log('Building rpk natively for comparison...')
2235
+ darwinTree = fetchRpkTreeFromSource(sourcePath)
2236
+ } catch (nativeErr) {
2237
+ console.warn(`⚠ Native comparison build failed: ${nativeErr.message}`)
2238
+ console.log('Skipping dynamic platform detection; using source scanning only.')
2239
+ }
2240
+
2241
+ if (darwinTree) {
2242
+ // Compare trees to find Linux-only commands
2243
+ const dynamicLinuxOnly = detectLinuxOnlyByComparison(linuxTree, darwinTree)
2244
+ if (dynamicLinuxOnly.size > 0) {
2245
+ console.log(`Dynamic detection found ${dynamicLinuxOnly.size} Linux-only command(s):`)
2246
+ for (const cmd of dynamicLinuxOnly) {
2247
+ console.log(` - ${cmd}`)
2248
+ linuxOnlyCommands.add(cmd)
2249
+ }
1300
2250
  }
1301
2251
  }
1302
2252
 
1303
2253
  // Use the Linux tree (has all commands)
1304
2254
  tree = linuxTree
2255
+ failedPlugins = linuxFailedPlugins
1305
2256
  } catch (dockerErr) {
1306
- // Docker build failed (e.g., Go version mismatch) - fall back to native build
2257
+ // Docker build failed - fall back to native build
1307
2258
  console.warn(`\n⚠ Docker build failed: ${dockerErr.message}`)
1308
2259
  console.log('Falling back to native Go build...')
1309
2260
  console.log('Note: Linux-only commands will be detected via source scanning only.\n')
@@ -1312,7 +2263,7 @@ async function handleRpkDocsGeneration(options = {}) {
1312
2263
  } else if (canBuildLinux) {
1313
2264
  console.log('\nBuilding rpk in Linux container...')
1314
2265
  try {
1315
- tree = fetchRpkTreeFromLinuxSource(sourcePath)
2266
+ ({ tree, failedPlugins } = fetchRpkTreeFromLinuxSource(sourcePath, pluginPins))
1316
2267
  } catch (dockerErr) {
1317
2268
  if (canBuildNative) {
1318
2269
  console.warn(`\n⚠ Docker build failed: ${dockerErr.message}`)
@@ -1332,6 +2283,12 @@ async function handleRpkDocsGeneration(options = {}) {
1332
2283
  )
1333
2284
  }
1334
2285
 
2286
+ // Tripwire: an empty combined detection result while the source tree
2287
+ // demonstrably contains Linux-gated files means the scan is broken and
2288
+ // Linux-only pages are about to be published as cross-platform
2289
+ // (see redpanda-data/docs#1831).
2290
+ warnIfDetectionLooksBroken(sourcePath, linuxOnlyCommands)
2291
+
1335
2292
  // Step 4: Add platform markers based on detection
1336
2293
  // This includes both source scanning results and static fallback lists
1337
2294
  tree = addPlatformMarkersFromSource(tree, linuxOnlyCommands)
@@ -1343,8 +2300,23 @@ async function handleRpkDocsGeneration(options = {}) {
1343
2300
  const plugins = detectPlugins(tree)
1344
2301
  console.log(`Detected plugins: ${plugins.join(', ') || 'none'}`)
1345
2302
 
1346
- // Note: Plugin versions not available when building from source
2303
+ // Record the version of each plugin that actually installed during the
2304
+ // build (`rpk <plugin> install` resolves the manifest's latest, so the
2305
+ // manifest lookup reflects what the tree contains). Shim-only plugins
2306
+ // (failed installs) are skipped: their subtree carries no plugin commands.
1347
2307
  const pluginVersions = {}
2308
+ for (const pluginName of REFRESHABLE_PLUGINS) {
2309
+ const node = (tree.commands || []).find(c => c.name === pluginName)
2310
+ if (node && pluginNodeHasRealCommands(node)) {
2311
+ const installedVersion = pluginPins[pluginName] || fetchLatestPluginVersion(pluginName)
2312
+ if (installedVersion) {
2313
+ pluginVersions[pluginName] = installedVersion
2314
+ }
2315
+ }
2316
+ }
2317
+ if (Object.keys(pluginVersions).length > 0) {
2318
+ console.log(`Plugin versions: ${JSON.stringify(pluginVersions)}`)
2319
+ }
1348
2320
 
1349
2321
  // Step 5: Scan source for deprecated/hidden commands
1350
2322
  let deprecatedCommands = {}
@@ -1365,7 +2337,13 @@ async function handleRpkDocsGeneration(options = {}) {
1365
2337
  // Load and validate overrides
1366
2338
  const defaultOverridesPath = path.join(dataDir, 'rpk-overrides.json')
1367
2339
  const effectiveOverridesPath = overridesPath || defaultOverridesPath
1368
- const overridesData = loadOverrides(effectiveOverridesPath, tree, { strict: false })
2340
+
2341
+ // Annotate deprecated commands that are still visible in the tree so
2342
+ // their pages render deprecation banners without manual curation. Runs
2343
+ // before the overrides load so the annotations apply to this run.
2344
+ mergeVisibleDeprecationsIntoOverrides(deprecatedCommands, tree, effectiveOverridesPath)
2345
+
2346
+ const { overrides: overridesData, validation: overrideValidation } = loadOverrides(effectiveOverridesPath, tree, { strict: false }) || {}
1369
2347
 
1370
2348
  if (overridesData) {
1371
2349
  console.log(`Loaded overrides from ${effectiveOverridesPath}`)
@@ -1403,7 +2381,9 @@ async function handleRpkDocsGeneration(options = {}) {
1403
2381
  const oldTree = oldData.raw_tree || oldData.tree
1404
2382
  diffData = generateRpkDiff(oldTree, tree, {
1405
2383
  oldVersion: diffVersion,
1406
- newVersion: rpkVersion
2384
+ newVersion: rpkVersion,
2385
+ oldDeprecatedCommands: oldData.deprecated_commands || {},
2386
+ newDeprecatedCommands: deprecatedCommands
1407
2387
  })
1408
2388
 
1409
2389
  // Save diff
@@ -1415,14 +2395,17 @@ async function handleRpkDocsGeneration(options = {}) {
1415
2395
  // Print diff report
1416
2396
  printDiffReport(diffData)
1417
2397
 
1418
- // Update overrides with introducedInVersion for new commands
1419
- if (diffData.details.newCommands.length > 0 && effectiveOverridesPath) {
1420
- updateOverridesWithIntroducedVersions(diffData, effectiveOverridesPath, rpkVersion)
2398
+ // Update overrides with introducedInVersion for new commands. Plugin
2399
+ // commands get the plugin's own version (plugins release on their own
2400
+ // cadence, so the rpk version would be wrong and the page note would
2401
+ // render "introduced in <plugin> version <rpk version>").
2402
+ if ((diffData.details.newCommands.length > 0 || diffData.details.newFlags.length > 0) && effectiveOverridesPath) {
2403
+ updateOverridesWithIntroducedVersions(diffData, effectiveOverridesPath, rpkVersion, pluginVersions)
1421
2404
  }
1422
2405
 
1423
2406
  // Update what's-new file if requested
1424
2407
  if (whatsNewPath) {
1425
- updateWhatsNewFile(diffData, whatsNewPath, rpkVersion)
2408
+ updateWhatsNewFile(filterDiffForWhatsNew(diffData), whatsNewPath, rpkVersion, { linkable: makeLinkablePredicate(overridesData), hasSubcommands: makeSubcommandPredicate(tree) })
1426
2409
  }
1427
2410
  } else {
1428
2411
  console.warn(`Warning: Could not load previous version ${diffVersion} for diff`)
@@ -1441,7 +2424,8 @@ async function handleRpkDocsGeneration(options = {}) {
1441
2424
  pluginVersions,
1442
2425
  draftMissing,
1443
2426
  preservationsDir: preserveFrom,
1444
- navFile
2427
+ navFile,
2428
+ protectedPlugins: failedPlugins
1445
2429
  })
1446
2430
 
1447
2431
  console.log(`\nGeneration complete!`)
@@ -1463,11 +2447,13 @@ async function handleRpkDocsGeneration(options = {}) {
1463
2447
  // Generate PR summary
1464
2448
  const prSummary = generatePRSummary({
1465
2449
  rpkVersion,
2450
+ overrideValidation,
1466
2451
  commandCount: result.commandCount,
1467
2452
  filesGenerated: result.filesGenerated,
1468
2453
  filesSkipped: result.filesSkipped,
1469
2454
  diffData,
1470
2455
  validationResult,
2456
+ descriptionCoverage: computeDescriptionCoverage(tree, overridesData),
1471
2457
  outputDir: finalOutputDir
1472
2458
  })
1473
2459
 
@@ -1506,26 +2492,75 @@ async function handleRpkDocsGeneration(options = {}) {
1506
2492
  * @param {Object} options - Summary options
1507
2493
  * @returns {string} Markdown formatted summary
1508
2494
  */
2495
+ /**
2496
+ * Find description overrides that replace substantially longer source help.
2497
+ * Curation is legitimate, but silent replacement is how stale or wrong
2498
+ * content survives regeneration, so every regen PR lists what is hidden.
2499
+ * @param {Object} tree - Raw command tree
2500
+ * @param {Object} overridesData - Parsed overrides
2501
+ * @returns {Array<{commandPath: string, overrideChars: number, sourceChars: number}>}
2502
+ */
2503
+ function computeDescriptionCoverage(tree, overridesData) {
2504
+ if (!tree || !overridesData?.commands) return []
2505
+ const commandMap = flattenToMap(tree)
2506
+ const results = []
2507
+ for (const [cmdPath, override] of Object.entries(overridesData.commands)) {
2508
+ if (typeof override?.description !== 'string') continue
2509
+ const cmd = commandMap.get(cmdPath)
2510
+ if (!cmd || typeof cmd.description !== 'string') continue
2511
+ const sourceChars = cmd.description.trim().length
2512
+ // appendToDescription content still reaches the page, so count it
2513
+ const appended = typeof override.appendToDescription === 'string' ? override.appendToDescription.trim().length : 0
2514
+ const overrideChars = override.description.trim().length + appended
2515
+ if (sourceChars - overrideChars > 500) {
2516
+ results.push({ commandPath: cmdPath, overrideChars, sourceChars })
2517
+ }
2518
+ }
2519
+ return results.sort((a, b) => (b.sourceChars - b.overrideChars) - (a.sourceChars - a.overrideChars))
2520
+ }
2521
+
1509
2522
  function generatePRSummary(options) {
1510
2523
  const {
1511
2524
  rpkVersion,
2525
+ plugin,
2526
+ pluginVersion,
1512
2527
  commandCount,
1513
2528
  filesGenerated,
1514
2529
  filesSkipped = 0,
1515
2530
  diffData,
1516
2531
  validationResult,
2532
+ overrideValidation,
2533
+ descriptionCoverage,
1517
2534
  outputDir
1518
2535
  } = options
1519
2536
 
1520
2537
  const lines = []
1521
2538
 
1522
2539
  // Header
1523
- lines.push('## rpk Documentation Generation Summary')
1524
- lines.push('')
2540
+ if (plugin) {
2541
+ lines.push(`## rpk ${plugin} Plugin Documentation Update`)
2542
+ lines.push('')
2543
+ if (pluginVersion) {
2544
+ lines.push(`**Plugin version:** ${pluginVersion}`)
2545
+ }
2546
+ lines.push(`**Base rpk snapshot:** ${rpkVersion}`)
2547
+ lines.push('')
2548
+ lines.push(
2549
+ `The \`rpk ${plugin}\` subtree was refreshed in the snapshot, then the ` +
2550
+ 'full rpk reference was re-rendered from it as a converge. Pages outside ' +
2551
+ `\`rpk ${plugin}\` can carry template-level or consistency updates, and ` +
2552
+ 'stale generated files are cleaned up, so the change list may be wider ' +
2553
+ 'than the plugin itself.'
2554
+ )
2555
+ lines.push('')
2556
+ } else {
2557
+ lines.push('## rpk Documentation Generation Summary')
2558
+ lines.push('')
1525
2559
 
1526
- // Version info
1527
- lines.push(`**Version:** ${rpkVersion}`)
1528
- lines.push('')
2560
+ // Version info
2561
+ lines.push(`**Version:** ${rpkVersion}`)
2562
+ lines.push('')
2563
+ }
1529
2564
 
1530
2565
  // Generation stats
1531
2566
  lines.push('### Generation Statistics')
@@ -1544,52 +2579,118 @@ function generatePRSummary(options) {
1544
2579
  lines.push('### Changes from Previous Version')
1545
2580
  lines.push('')
1546
2581
 
1547
- const { newCommands, removedCommands, newFlags, removedFlags, changedDescriptions } = diffData.summary
1548
-
1549
- if (newCommands === 0 && removedCommands === 0 && newFlags === 0 && removedFlags === 0) {
1550
- lines.push('No command or flag changes detected.')
2582
+ const {
2583
+ newCommands,
2584
+ removedCommands,
2585
+ newFlags,
2586
+ removedFlags,
2587
+ changedDefaults = 0,
2588
+ changedFlagTypes = 0,
2589
+ changedFlagRequirements = 0,
2590
+ changedFlagDescriptions = 0,
2591
+ descriptionChanges = 0,
2592
+ newlyDeprecatedCommands = 0
2593
+ } = diffData.summary
2594
+
2595
+ const totalChanges = newCommands + removedCommands + newFlags + removedFlags +
2596
+ changedDefaults + changedFlagTypes + changedFlagRequirements +
2597
+ changedFlagDescriptions + descriptionChanges + newlyDeprecatedCommands
2598
+
2599
+ if (totalChanges === 0) {
2600
+ lines.push('No command, flag, or default changes detected.')
1551
2601
  } else {
1552
2602
  lines.push(`| Change Type | Count |`)
1553
2603
  lines.push(`|-------------|-------|`)
1554
2604
  if (newCommands > 0) lines.push(`| New commands | ${newCommands} |`)
2605
+ if (newlyDeprecatedCommands > 0) lines.push(`| Deprecated commands | ${newlyDeprecatedCommands} |`)
1555
2606
  if (removedCommands > 0) lines.push(`| Removed commands | ${removedCommands} |`)
1556
2607
  if (newFlags > 0) lines.push(`| New flags | ${newFlags} |`)
1557
2608
  if (removedFlags > 0) lines.push(`| Removed flags | ${removedFlags} |`)
1558
- if (changedDescriptions > 0) lines.push(`| Changed descriptions | ${changedDescriptions} |`)
2609
+ if (changedDefaults > 0) lines.push(`| Changed flag defaults | ${changedDefaults} |`)
2610
+ if (changedFlagTypes > 0) lines.push(`| Changed flag types | ${changedFlagTypes} |`)
2611
+ if (changedFlagRequirements > 0) lines.push(`| Changed flag requirements | ${changedFlagRequirements} |`)
2612
+ if (changedFlagDescriptions > 0) lines.push(`| Changed flag descriptions | ${changedFlagDescriptions} |`)
2613
+ if (descriptionChanges > 0) lines.push(`| Changed command descriptions | ${descriptionChanges} |`)
2614
+ if (diffData.summary?.flagDataBackfilled > 0) lines.push(`| Flag docs backfilled (not new flags) | ${diffData.summary.flagDataBackfilled} commands |`)
1559
2615
  }
1560
2616
  lines.push('')
1561
2617
 
1562
- // List new commands
1563
- if (diffData.details?.newCommands?.length > 0) {
2618
+ // Collapsible list helper: itemized up to a cap, with an overflow note
2619
+ const pushDetailsList = (title, items, renderItem) => {
2620
+ if (!items || items.length === 0) return
1564
2621
  lines.push('<details>')
1565
- lines.push('<summary>New Commands</summary>')
2622
+ lines.push(`<summary>${title}</summary>`)
1566
2623
  lines.push('')
1567
- for (const cmd of diffData.details.newCommands.slice(0, 20)) {
1568
- lines.push(`- \`${cmd.path}\``)
2624
+ for (const item of items.slice(0, 30)) {
2625
+ lines.push(renderItem(item))
1569
2626
  }
1570
- if (diffData.details.newCommands.length > 20) {
1571
- lines.push(`- ... and ${diffData.details.newCommands.length - 20} more`)
2627
+ if (items.length > 30) {
2628
+ lines.push(`- ... and ${items.length - 30} more (see the diff JSON in docs-data/)`)
1572
2629
  }
1573
2630
  lines.push('')
1574
2631
  lines.push('</details>')
1575
2632
  lines.push('')
1576
2633
  }
1577
2634
 
1578
- // List removed commands
1579
- if (diffData.details?.removedCommands?.length > 0) {
1580
- lines.push('<details>')
1581
- lines.push('<summary>Removed Commands</summary>')
1582
- lines.push('')
1583
- for (const cmd of diffData.details.removedCommands.slice(0, 20)) {
1584
- lines.push(`- \`${cmd.path}\``)
1585
- }
1586
- if (diffData.details.removedCommands.length > 20) {
1587
- lines.push(`- ... and ${diffData.details.removedCommands.length - 20} more`)
1588
- }
1589
- lines.push('')
1590
- lines.push('</details>')
1591
- lines.push('')
2635
+ const formatValue = (value) => {
2636
+ if (value === undefined) return 'unset'
2637
+ if (value === null) return 'null'
2638
+ if (typeof value === 'object') return JSON.stringify(value)
2639
+ return String(value)
2640
+ }
2641
+
2642
+ pushDetailsList('New Commands', diffData.details?.newCommands,
2643
+ cmd => `- \`${cmd.path}\``)
2644
+ pushDetailsList('Deprecated Commands', diffData.details?.newlyDeprecatedCommands,
2645
+ cmd => `- \`${cmd.path}\`${cmd.message ? ` — ${cmd.message}` : ''}${cmd.hidden ? ' _(hidden from help output)_' : ''}`)
2646
+ pushDetailsList('Removed Commands', diffData.details?.removedCommands,
2647
+ cmd => `- \`${cmd.path}\``)
2648
+ pushDetailsList('New Flags', diffData.details?.newFlags,
2649
+ flag => `- \`${flag.commandPath}\`: \`--${flag.flagName}\` (${flag.type})`)
2650
+ pushDetailsList('Removed Flags', diffData.details?.removedFlags,
2651
+ flag => `- \`${flag.commandPath}\`: \`--${flag.flagName}\``)
2652
+ pushDetailsList('Changed Flag Defaults', diffData.details?.changedDefaults,
2653
+ change => `- \`${change.commandPath}\`: \`--${change.flagName}\` default \`${formatValue(change.oldDefault)}\` → \`${formatValue(change.newDefault)}\``)
2654
+ pushDetailsList('Changed Flag Types', diffData.details?.changedFlagTypes,
2655
+ change => `- \`${change.commandPath}\`: \`--${change.flagName}\` type \`${change.oldType}\` → \`${change.newType}\``)
2656
+ pushDetailsList('Changed Flag Requirements', diffData.details?.changedFlagRequirements,
2657
+ change => `- \`${change.commandPath}\`: \`--${change.flagName}\` required \`${change.oldRequired}\` → \`${change.newRequired}\``)
2658
+ pushDetailsList('Changed Command Descriptions', diffData.details?.descriptionChanges,
2659
+ change => `- \`${change.path}\``)
2660
+ }
2661
+
2662
+ // Override validation: entries referencing commands the tree lacks do
2663
+ // nothing silently, so surface them where they become actionable
2664
+ if (overrideValidation && overrideValidation.errors && overrideValidation.errors.length > 0) {
2665
+ lines.push('### Override Validation')
2666
+ lines.push('')
2667
+ lines.push(`⚠ ${overrideValidation.errors.length} override entr${overrideValidation.errors.length === 1 ? 'y' : 'ies'} did not apply (unknown command paths — stale entries in the overrides file):`)
2668
+ lines.push('')
2669
+ for (const err of overrideValidation.errors.slice(0, 10)) {
2670
+ lines.push(`- ${typeof err === 'string' ? err : err.message || JSON.stringify(err)}`)
2671
+ }
2672
+ if (overrideValidation.errors.length > 10) {
2673
+ lines.push(`- ... and ${overrideValidation.errors.length - 10} more`)
2674
+ }
2675
+ lines.push('')
2676
+ }
2677
+
2678
+ // Description overrides that hide most of the source help. Curation is
2679
+ // fine, but this is where stale content hides, so keep it reviewable.
2680
+ if (descriptionCoverage && descriptionCoverage.length > 0) {
2681
+ lines.push('### Curated descriptions replacing source help')
2682
+ lines.push('')
2683
+ lines.push('These overrides replace substantially longer help text from the rpk source. Confirm the curated version still carries every operational detail (or intentionally omits it):')
2684
+ lines.push('')
2685
+ lines.push('| Command | Override | Source help |')
2686
+ lines.push('|---------|----------|-------------|')
2687
+ for (const entry of descriptionCoverage.slice(0, 15)) {
2688
+ lines.push(`| \`${entry.commandPath}\` | ${entry.overrideChars} chars | ${entry.sourceChars} chars |`)
2689
+ }
2690
+ if (descriptionCoverage.length > 15) {
2691
+ lines.push(`| ... and ${descriptionCoverage.length - 15} more | | |`)
1592
2692
  }
2693
+ lines.push('')
1593
2694
  }
1594
2695
 
1595
2696
  // Validation results
@@ -1686,6 +2787,23 @@ module.exports = {
1686
2787
  handleRpkDocsGeneration,
1687
2788
  fetchRpkTreeFromSource,
1688
2789
  fetchRpkTreeFromLinuxSource,
2790
+ acquireRpkBinary,
2791
+ buildRpkBinary,
2792
+ downloadRpkRelease,
2793
+ fetchPluginSubtree,
2794
+ parseCobraFlags,
2795
+ parseUrfaveFlags,
2796
+ parseHelpFlags,
2797
+ mergeVisibleDeprecationsIntoOverrides,
2798
+ makeLinkablePredicate,
2799
+ enrichPluginTreeWithFlags,
2800
+ fetchLatestPluginVersion,
2801
+ pluginNodeHasRealCommands,
2802
+ splicePluginNode,
2803
+ preserveLinuxOnlyCommands,
2804
+ REFRESHABLE_PLUGINS,
2805
+ PLUGIN_INSTALL_VERSION_FLAGS,
2806
+ PLUGIN_MANIFEST_SLUGS,
1689
2807
  getRequiredGoVersion,
1690
2808
  prepareSourceFromRef,
1691
2809
  detectLinuxOnlyFromSource,
@@ -1701,6 +2819,9 @@ module.exports = {
1701
2819
  getPlatformDescription,
1702
2820
  getCurrentPlatform,
1703
2821
  updateOverridesWithIntroducedVersions,
2822
+ filterDiffForWhatsNew,
2823
+ computeDescriptionCoverage,
2824
+ updateWhatsNewFile,
1704
2825
  KNOWN_PLUGINS,
1705
2826
  extractCommandPaths,
1706
2827
  detectLinuxOnlyByComparison,