@redpanda-data/docs-extensions-and-macros 5.2.5 → 5.3.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.
@@ -1,13 +1,14 @@
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 { generateRpkDiff, printDiffReport, generateWhatsNewSection, flattenToMap } = require('./report-delta')
11
12
  const { loadAndValidateOverrides, ValidationResult } = require('./validate-overrides')
12
13
  const { validateDirectory, formatResults } = require('./validate-output')
13
14
 
@@ -16,6 +17,37 @@ const { validateDirectory, formatResults } = require('./validate-output')
16
17
  */
17
18
  const KNOWN_PLUGINS = ['ai', 'check', 'connect', 'k8s', 'oxla']
18
19
 
20
+ /**
21
+ * Plugins whose docs can be refreshed individually with --plugin.
22
+ * oxla is excluded: it is a "Coming Soon" stub with no installable binary.
23
+ */
24
+ const REFRESHABLE_PLUGINS = ['ai', 'check', 'connect', 'k8s']
25
+
26
+ /**
27
+ * Per-plugin install flags that pin a version (rpk <plugin> install <flag> <version>).
28
+ * k8s uses the generic flag name; the others embed the plugin name.
29
+ */
30
+ const PLUGIN_INSTALL_VERSION_FLAGS = {
31
+ ai: '--ai-version',
32
+ check: '--check-version',
33
+ connect: '--connect-version',
34
+ k8s: '--plugin-version'
35
+ }
36
+
37
+ /**
38
+ * Manifest slugs at https://rpk-plugins.redpanda.com/<slug>/manifest.json
39
+ * where the slug differs from the rpk command name.
40
+ */
41
+ const PLUGIN_MANIFEST_SLUGS = { ai: 'rpai' }
42
+
43
+ const PLUGIN_MANIFEST_HOST = 'https://rpk-plugins.redpanda.com'
44
+
45
+ /**
46
+ * Subcommands compiled into rpk itself for managed plugins. A plugin node
47
+ * whose children are only these never actually installed.
48
+ */
49
+ const PLUGIN_SHIM_SUBCOMMANDS = new Set(['install', 'uninstall', 'upgrade'])
50
+
19
51
  /**
20
52
  * Parse Go version from 'go version' output
21
53
  * @param {string} versionOutput - Output from 'go version' command
@@ -347,9 +379,12 @@ function fetchRpkTreeFromSource(sourcePath) {
347
379
  * Builds rpk binary, installs plugins, then runs --print-tree for complete command coverage.
348
380
  * Falls back to native Go build if Docker is unavailable.
349
381
  * @param {string} sourcePath - Path to rpk Go source directory (e.g., ~/redpanda/src/go/rpk)
350
- * @returns {Object} Parsed JSON tree
382
+ * @returns {Object} { tree, failedPlugins } — the parsed JSON tree plus the
383
+ * names of managed plugins whose install failed this run. Callers pass
384
+ * failedPlugins to generateRpkDocs as protectedPlugins so those plugins'
385
+ * existing pages and nav entries are preserved instead of treated as stale.
351
386
  */
352
- function fetchRpkTreeFromLinuxSource(sourcePath) {
387
+ function fetchRpkTreeFromLinuxSource(sourcePath, pluginPins = {}) {
353
388
  // Resolve to absolute path
354
389
  const absoluteSourcePath = path.resolve(sourcePath)
355
390
 
@@ -455,18 +490,46 @@ function fetchRpkTreeFromLinuxSource(sourcePath) {
455
490
  try {
456
491
  // Step 1: Build rpk binary
457
492
  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
- })
493
+ // Module downloads from proxy.golang.org fail transiently (stream
494
+ // INTERNAL_ERROR), especially on a cold module cache. Retry: the second
495
+ // attempt reuses whatever the first already downloaded.
496
+ let buildResult
497
+ let binaryExists = false
498
+ for (let attempt = 1; attempt <= 4; attempt++) {
499
+ buildResult = spawnSync('docker', [
500
+ 'exec', containerId,
501
+ 'go', 'build', '-o', '/tmp/rpk', './cmd/rpk'
502
+ ], {
503
+ encoding: 'utf8',
504
+ timeout: 300000 // 5 minutes per attempt
505
+ })
465
506
 
466
- if (buildResult.status !== 0) {
507
+ if (buildResult.status === 0) {
508
+ // Trust but verify: docker exec has been observed returning zero for
509
+ // a build that produced nothing, and everything downstream execs
510
+ // /tmp/rpk. Treat a phantom success as a failed attempt.
511
+ const binCheck = spawnSync('docker', [
512
+ 'exec', containerId, 'test', '-x', '/tmp/rpk'
513
+ ], { encoding: 'utf8', timeout: 15000 })
514
+ if (binCheck.status === 0) {
515
+ binaryExists = true
516
+ break
517
+ }
518
+ if (attempt < 4) {
519
+ console.warn(` Build attempt ${attempt} reported success but produced no binary; retrying...`)
520
+ continue
521
+ }
522
+ } else if (attempt < 4) {
523
+ const firstError = (buildResult.stderr || buildResult.signal || 'unknown error')
524
+ .toString().trim().split('\n').slice(-1)[0]
525
+ console.warn(` Build attempt ${attempt} failed (${firstError}); retrying...`)
526
+ }
527
+ }
528
+
529
+ if (!binaryExists) {
467
530
  const stderr = buildResult.stderr || ''
468
531
  throw new Error(
469
- `Failed to build rpk in Linux container: ${stderr}\n` +
532
+ `Failed to build rpk in Linux container: ${stderr || 'build produced no binary'}\n` +
470
533
  'Common causes:\n' +
471
534
  ' 1. Source code is out of date - run: git pull origin dev\n' +
472
535
  ' 2. Go module issues - the container will download dependencies automatically\n' +
@@ -477,15 +540,31 @@ function fetchRpkTreeFromLinuxSource(sourcePath) {
477
540
 
478
541
  // Step 2: Install plugins
479
542
  console.log('Installing plugins for complete command coverage...')
543
+ const failedPlugins = []
480
544
  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
- })
545
+ // A pin installs a specific version instead of the manifest's latest.
546
+ // This is how pre-GA plugins (no version promoted to latest) get into a
547
+ // full regeneration at all — without a pin their install resolves
548
+ // nothing and their commands are absent from the tree.
549
+ const pin = pluginPins[plugin]
550
+ const versionFlag = PLUGIN_INSTALL_VERSION_FLAGS[plugin]
551
+ const runInstall = (pinned) => {
552
+ const args = ['exec', containerId, '/tmp/rpk', plugin, 'install']
553
+ if (pinned && pin && versionFlag) {
554
+ args.push(versionFlag, pin)
555
+ }
556
+ return spawnSync('docker', args, { encoding: 'utf8', timeout: 120000 })
557
+ }
558
+
559
+ console.log(` Installing plugin: ${plugin}${pin ? ` (pinned to ${pin})` : ''}...`)
560
+ let installResult = runInstall(true)
561
+ if (installResult.status !== 0 && pin) {
562
+ const output = `${installResult.stderr || ''}${installResult.stdout || ''}`
563
+ if (output.includes('unknown flag') || output.includes('is not valid')) {
564
+ console.warn(` rpk rejected the version pin for ${plugin}; retrying without the pin (installs latest)`)
565
+ installResult = runInstall(false)
566
+ }
567
+ }
489
568
 
490
569
  if (installResult.status === 0) {
491
570
  console.log(` ✓ ${plugin} installed`)
@@ -498,13 +577,14 @@ function fetchRpkTreeFromLinuxSource(sourcePath) {
498
577
  console.log(` - ${plugin} is not an installable plugin`)
499
578
  } else {
500
579
  // 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.
580
+ // plugin's commands will be absent from the tree. Expected for
581
+ // beta-only plugins with no version pin — `rpk <plugin> install`
582
+ // finds no `latest` release because the plugin publisher's
583
+ // stableVersionRe only promotes pure X.Y.Z versions, so their
584
+ // commands only appear at GA unless the run pins a version.
506
585
  // See redpanda-data/docs#1801.
507
586
  console.warn(` ✗ Failed to install ${plugin}: ${stderr || stdout}`)
587
+ failedPlugins.push(plugin)
508
588
  }
509
589
  }
510
590
  }
@@ -536,8 +616,9 @@ function fetchRpkTreeFromLinuxSource(sourcePath) {
536
616
  )
537
617
  }
538
618
 
619
+ let tree
539
620
  try {
540
- return JSON.parse(result.stdout)
621
+ tree = JSON.parse(result.stdout)
541
622
  } catch (err) {
542
623
  throw new Error(
543
624
  `Failed to parse rpk tree JSON from Linux source build: ${err.message}\n` +
@@ -545,6 +626,26 @@ function fetchRpkTreeFromLinuxSource(sourcePath) {
545
626
  'This may indicate a version mismatch or corrupted source.'
546
627
  )
547
628
  }
629
+
630
+ // Step 4: Fill in flags for plugin subtrees. Their commands come from
631
+ // --help-autocomplete, which carries no flag data, so without this every
632
+ // plugin command page renders an empty flags section.
633
+ console.log('Extracting flags from plugin command help output...')
634
+ for (const plugin of KNOWN_PLUGINS) {
635
+ const node = (tree.commands || []).find(c => c.name === plugin)
636
+ if (!node || !pluginNodeHasRealCommands(node)) continue
637
+ const enriched = enrichPluginTreeWithFlags(node, (argPath) => {
638
+ const helpResult = spawnSync('docker', [
639
+ 'exec', containerId, '/tmp/rpk', ...argPath, '--help'
640
+ ], { encoding: 'utf8', timeout: 30000 })
641
+ return helpResult.status === 0 ? helpResult.stdout : null
642
+ })
643
+ if (enriched > 0) {
644
+ console.log(` ${plugin}: extracted flags for ${enriched} command(s)`)
645
+ }
646
+ }
647
+
648
+ return { tree, failedPlugins }
548
649
  } finally {
549
650
  // Clean up container
550
651
  console.log('Cleaning up build container...')
@@ -773,7 +874,58 @@ function loadOverrides(overridesPath, commandTree = null, options = {}) {
773
874
  )
774
875
  }
775
876
 
776
- return overrides
877
+ return { overrides, validation }
878
+ }
879
+
880
+ /**
881
+ * Merge deprecation metadata for commands still present in the tree into the
882
+ * overrides file, so their pages render deprecation banners without manual
883
+ * curation. Hidden deprecated commands are excluded: they are absent from
884
+ * --print-tree, have no pages, and would only produce unknown-path warnings.
885
+ * Commands whose overrides already carry a `deprecated` value are left alone.
886
+ * @param {Object} deprecatedCommands - Map from scan-deprecated-commands.js
887
+ * @param {Object} tree - Current command tree
888
+ * @param {string} overridesPath - Path to overrides JSON file
889
+ */
890
+ function mergeVisibleDeprecationsIntoOverrides(deprecatedCommands, tree, overridesPath) {
891
+ const entries = Object.entries(deprecatedCommands || {})
892
+ if (entries.length === 0 || !overridesPath || !fs.existsSync(overridesPath)) {
893
+ return
894
+ }
895
+
896
+ const treePaths = new Set(flattenToMap(tree).keys())
897
+
898
+ let overrides
899
+ try {
900
+ overrides = JSON.parse(fs.readFileSync(overridesPath, 'utf8'))
901
+ } catch (err) {
902
+ console.warn(`Warning: Could not parse overrides file for deprecation merge: ${err.message}`)
903
+ return
904
+ }
905
+ if (!overrides.commands) {
906
+ overrides.commands = {}
907
+ }
908
+
909
+ let annotated = 0
910
+ for (const [cmdPath, info] of entries) {
911
+ if (!treePaths.has(cmdPath)) continue
912
+ const existing = overrides.commands[cmdPath] || {}
913
+ if (existing.deprecated !== undefined) continue
914
+ overrides.commands[cmdPath] = {
915
+ ...existing,
916
+ deprecated: true,
917
+ ...(info.deprecatedMessage && !existing.deprecatedMessage
918
+ ? { deprecatedMessage: info.deprecatedMessage } : {}),
919
+ ...(info.replacement && !existing.replacement
920
+ ? { replacement: info.replacement } : {})
921
+ }
922
+ annotated++
923
+ }
924
+
925
+ if (annotated > 0) {
926
+ fs.writeFileSync(overridesPath, JSON.stringify(overrides, null, 2), 'utf8')
927
+ console.log(`Annotated ${annotated} visible deprecated command(s) in ${overridesPath}`)
928
+ }
777
929
  }
778
930
 
779
931
  /**
@@ -824,8 +976,12 @@ function loadVersionedJson(version, dataDir) {
824
976
  * @param {Object} diffData - Diff data with new commands and flags
825
977
  * @param {string} overridesPath - Path to overrides JSON file
826
978
  * @param {string} version - Version to set as introducedInVersion
979
+ * @param {Object} [pluginVersions] - Plugin versions keyed by rpk command
980
+ * name. Commands under a plugin subtree are stamped with the plugin's own
981
+ * version (the page note renders "introduced in <plugin> version X"), not
982
+ * the rpk version, because plugins release on their own cadence.
827
983
  */
828
- function updateOverridesWithIntroducedVersions(diffData, overridesPath, version) {
984
+ function updateOverridesWithIntroducedVersions(diffData, overridesPath, version, pluginVersions = {}) {
829
985
  const hasNewCommands = diffData.details.newCommands && diffData.details.newCommands.length > 0
830
986
  const hasNewFlags = diffData.details.newFlags && diffData.details.newFlags.length > 0
831
987
 
@@ -833,6 +989,12 @@ function updateOverridesWithIntroducedVersions(diffData, overridesPath, version)
833
989
  return
834
990
  }
835
991
 
992
+ // "rpk connect lint" -> pluginVersions.connect, else the rpk version
993
+ const versionFor = (cmdPath) => {
994
+ const topLevel = cmdPath.split(' ')[1]
995
+ return pluginVersions[topLevel] || version
996
+ }
997
+
836
998
  let overrides = {}
837
999
  if (fs.existsSync(overridesPath)) {
838
1000
  try {
@@ -859,7 +1021,7 @@ function updateOverridesWithIntroducedVersions(diffData, overridesPath, version)
859
1021
  }
860
1022
  // Only set if not already set (preserve manual overrides)
861
1023
  if (!overrides.commands[cmdPath].introducedInVersion) {
862
- overrides.commands[cmdPath].introducedInVersion = version
1024
+ overrides.commands[cmdPath].introducedInVersion = versionFor(cmdPath)
863
1025
  commandsUpdated++
864
1026
  }
865
1027
  }
@@ -883,7 +1045,7 @@ function updateOverridesWithIntroducedVersions(diffData, overridesPath, version)
883
1045
 
884
1046
  // Only set if not already set (preserve manual overrides)
885
1047
  if (!overrides.commands[cmdPath].flags[flagName].introducedInVersion) {
886
- overrides.commands[cmdPath].flags[flagName].introducedInVersion = version
1048
+ overrides.commands[cmdPath].flags[flagName].introducedInVersion = versionFor(cmdPath)
887
1049
  flagsUpdated++
888
1050
  }
889
1051
  }
@@ -970,8 +1132,49 @@ function countCommands(node) {
970
1132
  * @param {string} whatsNewPath - Path to what's-new.adoc file
971
1133
  * @param {string} version - Version string for display
972
1134
  */
973
- function updateWhatsNewFile(diffData, whatsNewPath, version) {
974
- const whatsNewContent = generateWhatsNewSection(diffData, { version })
1135
+ /**
1136
+ * Build a predicate that reports whether a command path renders as a
1137
+ * linkable page (not excluded and not routed to partials by the overrides).
1138
+ * @param {Object|null} overridesData - Loaded overrides
1139
+ * @returns {Function} (commandPath) => boolean
1140
+ */
1141
+ /**
1142
+ * Build a predicate that reports whether a command path has subcommands,
1143
+ * which determines its page location (groups render into their own dir).
1144
+ * @param {Object} tree - Full command tree
1145
+ * @returns {Function} (commandPath) => boolean
1146
+ */
1147
+ function makeSubcommandPredicate(tree) {
1148
+ const commandMap = flattenToMap(tree)
1149
+ return (commandPath) => {
1150
+ const node = commandMap.get(commandPath)
1151
+ return Boolean(node && (node.commands || []).length > 0)
1152
+ }
1153
+ }
1154
+
1155
+ function makeLinkablePredicate(overridesData) {
1156
+ const resolved = overridesData ? resolveReferences(overridesData, overridesData) : null
1157
+ return (commandPath) => {
1158
+ // rpk cloud and rpk security secret render to partials (single-sourced
1159
+ // into cloud docs), so this repo has no linkable pages for them
1160
+ if (commandPath.startsWith('rpk cloud') || commandPath.startsWith('rpk security secret')) {
1161
+ return false
1162
+ }
1163
+ if (!resolved) return true
1164
+ return !shouldExcludeCommand(resolved, commandPath) &&
1165
+ !shouldUsePartialDir(resolved, commandPath)
1166
+ }
1167
+ }
1168
+
1169
+ function updateWhatsNewFile(diffData, whatsNewPath, version, options = {}) {
1170
+ // Each block opens with a "=== <version>" heading so accumulated blocks
1171
+ // (successive RCs, multiple plugin releases) never collide on section ids
1172
+ const sectionHeading = options.sectionHeading || '== Redpanda CLI'
1173
+ const whatsNewContent = generateWhatsNewSection(diffData, {
1174
+ version,
1175
+ blockLabel: version,
1176
+ ...options
1177
+ })
975
1178
 
976
1179
  if (!whatsNewContent) {
977
1180
  console.log('No Redpanda CLI changes to add to what\'s new')
@@ -987,9 +1190,39 @@ function updateWhatsNewFile(diffData, whatsNewPath, version) {
987
1190
 
988
1191
  const existingContent = fs.readFileSync(whatsNewPath, 'utf8')
989
1192
 
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')
1193
+ // Version-scoped marker block: re-runs for the same version replace their
1194
+ // own block, and later versions (for example, successive RCs in a beta
1195
+ // cycle, or plugin releases) append their own blocks inside the existing
1196
+ // Redpanda CLI section instead of being dropped. Writers can edit or
1197
+ // remove blocks freely; the automation only ever touches content between
1198
+ // its own markers for the same version label.
1199
+ const startMarker = `// AUTOGEN-RPK-CHANGES ${version} START`
1200
+ const endMarker = `// AUTOGEN-RPK-CHANGES ${version} END`
1201
+ const sectionBody = whatsNewContent.replace(/^== [^\n]*\n+/, '')
1202
+ const block = `${startMarker}\n${sectionBody.trimEnd()}\n${endMarker}`
1203
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1204
+
1205
+ if (existingContent.includes(startMarker)) {
1206
+ // Replace this version's existing block (idempotent re-runs)
1207
+ const blockRe = new RegExp(`${escapeRe(startMarker)}[\\s\\S]*?${escapeRe(endMarker)}`)
1208
+ const updatedContent = existingContent.replace(blockRe, block)
1209
+ fs.writeFileSync(whatsNewPath, updatedContent, 'utf8')
1210
+ console.log(`Refreshed existing ${version} block in what's-new file: ${whatsNewPath}`)
1211
+ return
1212
+ }
1213
+
1214
+ const headingMatch = existingContent.match(new RegExp(`^${escapeRe(sectionHeading)}[^\\n]*$`, 'm'))
1215
+ if (headingMatch) {
1216
+ // Append this version's block at the end of the existing section, just
1217
+ // before the next level-2 heading (or end of file)
1218
+ const sectionStart = headingMatch.index + headingMatch[0].length
1219
+ const rest = existingContent.slice(sectionStart)
1220
+ const nextHeading = rest.search(/\n== /)
1221
+ const insertAt = nextHeading === -1 ? existingContent.length : sectionStart + nextHeading
1222
+ const before = existingContent.slice(0, insertAt).replace(/\s*$/, '\n\n')
1223
+ const after = existingContent.slice(insertAt).replace(/^\n*/, '\n')
1224
+ fs.writeFileSync(whatsNewPath, `${before}${block}${after}`, 'utf8')
1225
+ console.log(`Appended ${version} block to the "${sectionHeading}" section in: ${whatsNewPath}`)
993
1226
  return
994
1227
  }
995
1228
 
@@ -1011,19 +1244,563 @@ function updateWhatsNewFile(diffData, whatsNewPath, version) {
1011
1244
  }
1012
1245
  }
1013
1246
 
1247
+ const fullSection = `${sectionHeading}\n\n${block}\n`
1248
+
1014
1249
  let updatedContent
1015
1250
  if (insertIndex > 0) {
1016
1251
  // Insert before the matched section
1017
1252
  updatedContent = existingContent.slice(0, insertIndex) +
1018
- whatsNewContent + '\n' +
1253
+ fullSection + '\n' +
1019
1254
  existingContent.slice(insertIndex)
1020
1255
  } else {
1021
1256
  // Append at the end
1022
- updatedContent = existingContent + '\n' + whatsNewContent
1257
+ updatedContent = existingContent.replace(/\s*$/, '\n\n') + fullSection
1023
1258
  }
1024
1259
 
1025
1260
  fs.writeFileSync(whatsNewPath, updatedContent, 'utf8')
1026
- console.log(`Updated what's-new file: ${whatsNewPath}`)
1261
+ console.log(`Created "${sectionHeading}" section in what's-new file: ${whatsNewPath}`)
1262
+ }
1263
+
1264
+ /**
1265
+ * Build an rpk binary natively from Go source.
1266
+ * @param {string} sourcePath - Path to rpk Go source directory (src/go/rpk)
1267
+ * @param {string} outPath - Where to write the binary
1268
+ * @returns {string} Path to the built binary
1269
+ */
1270
+ function buildRpkBinary(sourcePath, outPath) {
1271
+ const goCheck = spawnSync('go', ['version'], { encoding: 'utf8', timeout: 5000 })
1272
+ if (goCheck.status !== 0) {
1273
+ throw new Error(
1274
+ 'Go is required to build rpk from source but was not found.\n' +
1275
+ 'Install Go from https://go.dev/ and ensure it\'s in your PATH.'
1276
+ )
1277
+ }
1278
+
1279
+ const installedGoVersion = parseGoVersion(goCheck.stdout)
1280
+ const requiredGoVersion = getRequiredGoVersion(sourcePath)
1281
+ if (installedGoVersion && requiredGoVersion &&
1282
+ !checkGoVersionSufficient(installedGoVersion, requiredGoVersion)) {
1283
+ throw new Error(
1284
+ `Go version mismatch: installed ${installedGoVersion}, required >= ${requiredGoVersion}\n` +
1285
+ `The rpk source (go.mod) requires Go ${requiredGoVersion} or newer.`
1286
+ )
1287
+ }
1288
+
1289
+ console.log(`Building rpk from source at ${sourcePath}...`)
1290
+ const buildResult = spawnSync('go', ['build', '-o', outPath, './cmd/rpk'], {
1291
+ cwd: sourcePath,
1292
+ encoding: 'utf8',
1293
+ timeout: 300000
1294
+ })
1295
+
1296
+ if (buildResult.status !== 0) {
1297
+ throw new Error(`Failed to build rpk from source: ${buildResult.stderr}`)
1298
+ }
1299
+
1300
+ return outPath
1301
+ }
1302
+
1303
+ /**
1304
+ * Download an official rpk release binary for the current platform.
1305
+ * @param {string} tag - Release tag (e.g., v26.1.12)
1306
+ * @param {string} destDir - Directory to download and extract into
1307
+ * @returns {string|null} Path to the extracted binary, or null if the
1308
+ * release asset is unavailable (caller falls back to a source build)
1309
+ */
1310
+ function downloadRpkRelease(tag, destDir) {
1311
+ const osName = { darwin: 'darwin', linux: 'linux', win32: 'windows' }[process.platform]
1312
+ const archName = { arm64: 'arm64', x64: 'amd64' }[process.arch]
1313
+ if (!osName || !archName) {
1314
+ console.warn(`No rpk release asset for platform ${process.platform}/${process.arch}`)
1315
+ return null
1316
+ }
1317
+
1318
+ const assetName = `rpk-${osName}-${archName}.zip`
1319
+ const baseUrl = `https://github.com/redpanda-data/redpanda/releases/download/${tag}`
1320
+ const zipPath = path.join(destDir, assetName)
1321
+
1322
+ console.log(`Downloading ${assetName} for ${tag}...`)
1323
+ const curlResult = spawnSync('curl', [
1324
+ '-fL', '--retry', '5', '--retry-all-errors',
1325
+ '--connect-timeout', '30', '--max-time', '300',
1326
+ '-o', zipPath, `${baseUrl}/${assetName}`
1327
+ ], { encoding: 'utf8', timeout: 360000 })
1328
+
1329
+ if (curlResult.status !== 0) {
1330
+ console.warn(`Could not download rpk release for ${tag} (draft or missing release asset)`)
1331
+ return null
1332
+ }
1333
+
1334
+ // Verify against the release checksum file when it exists
1335
+ const checksumAsset = `rpk_${tag.replace(/^v/, '')}_checksums.txt`
1336
+ const checksumPath = path.join(destDir, checksumAsset)
1337
+ const checksumResult = spawnSync('curl', [
1338
+ '-fsSL', '--retry', '3', '--connect-timeout', '30', '--max-time', '60',
1339
+ '-o', checksumPath, `${baseUrl}/${checksumAsset}`
1340
+ ], { encoding: 'utf8', timeout: 90000 })
1341
+
1342
+ if (checksumResult.status === 0) {
1343
+ const expectedLine = fs.readFileSync(checksumPath, 'utf8')
1344
+ .split('\n')
1345
+ .find(line => line.trim().endsWith(assetName))
1346
+ if (expectedLine) {
1347
+ const expected = expectedLine.trim().split(/\s+/)[0]
1348
+ const actual = crypto.createHash('sha256').update(fs.readFileSync(zipPath)).digest('hex')
1349
+ if (expected !== actual) {
1350
+ throw new Error(
1351
+ `Checksum mismatch for ${assetName} (${tag}):\n` +
1352
+ ` expected ${expected}\n actual ${actual}`
1353
+ )
1354
+ }
1355
+ console.log('Checksum verified')
1356
+ }
1357
+ } else {
1358
+ console.warn('No checksum file published for this release; skipping verification')
1359
+ }
1360
+
1361
+ const unzipResult = spawnSync('unzip', ['-o', zipPath, '-d', destDir], {
1362
+ encoding: 'utf8',
1363
+ timeout: 60000
1364
+ })
1365
+ if (unzipResult.status !== 0) {
1366
+ throw new Error(`Failed to extract ${assetName}: ${unzipResult.stderr}`)
1367
+ }
1368
+
1369
+ const binPath = path.join(destDir, 'rpk')
1370
+ if (!fs.existsSync(binPath)) {
1371
+ throw new Error(`Extracted archive did not contain an rpk binary: ${zipPath}`)
1372
+ }
1373
+ fs.chmodSync(binPath, 0o755)
1374
+ return binPath
1375
+ }
1376
+
1377
+ /**
1378
+ * Get an rpk binary matching the given version.
1379
+ * Prefers the official release download (published stable releases only;
1380
+ * RC releases are drafts, so their assets are not publicly downloadable).
1381
+ * Falls back to building from source at the tag.
1382
+ * @param {string} rpkVersion - Version tag from the snapshot (e.g., v26.1.12, v26.2.1-rc2)
1383
+ * @param {Object} [options]
1384
+ * @param {string} [options.rpkBin] - Existing binary to use, skipping download/build
1385
+ * @returns {string} Path to an rpk binary
1386
+ */
1387
+ function acquireRpkBinary(rpkVersion, options = {}) {
1388
+ const { rpkBin } = options
1389
+
1390
+ if (rpkBin) {
1391
+ const binPath = path.resolve(rpkBin)
1392
+ if (!fs.existsSync(binPath)) {
1393
+ throw new Error(`rpk binary not found: ${binPath}`)
1394
+ }
1395
+ console.log(`Using provided rpk binary: ${binPath}`)
1396
+ return binPath
1397
+ }
1398
+
1399
+ const tag = rpkVersion.startsWith('v') ? rpkVersion : `v${rpkVersion}`
1400
+ const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rpk-bin-'))
1401
+
1402
+ // Only published (non-draft) releases have downloadable assets
1403
+ if (/^v\d+\.\d+\.\d+$/.test(tag)) {
1404
+ const downloaded = downloadRpkRelease(tag, workDir)
1405
+ if (downloaded) {
1406
+ return downloaded
1407
+ }
1408
+ console.log('Falling back to building rpk from source...')
1409
+ } else {
1410
+ console.log(`No published release binary for ${tag}; building from source...`)
1411
+ }
1412
+
1413
+ if (!/^v\d+\.\d+\.\d+(-rc\d+)?$/.test(tag) && tag !== 'vdev') {
1414
+ throw new Error(
1415
+ `Cannot acquire an rpk binary for version '${rpkVersion}'.\n` +
1416
+ 'The snapshot\'s rpk_version is not a release tag. ' +
1417
+ 'Pass --rpk-bin <path> to use a local rpk binary instead.'
1418
+ )
1419
+ }
1420
+
1421
+ const sourceRef = tag === 'vdev' ? 'dev' : tag
1422
+ const sourcePath = prepareSourceFromRef(sourceRef, null)
1423
+ try {
1424
+ return buildRpkBinary(sourcePath, path.join(workDir, 'rpk'))
1425
+ } catch (nativeErr) {
1426
+ // Local Go older than go.mod requires is common on laptops; the --ref
1427
+ // path already tolerates this by building in a container, so do the same
1428
+ // here when Docker is available
1429
+ const dockerCheck = spawnSync('docker', ['info'], { encoding: 'utf8', timeout: 10000 })
1430
+ if (dockerCheck.status !== 0) {
1431
+ throw new Error(
1432
+ `${nativeErr.message}\n` +
1433
+ 'Docker is not available for a container build either. ' +
1434
+ 'Update Go, start Docker, or pass --rpk-bin <path> to use an existing rpk binary.'
1435
+ )
1436
+ }
1437
+ console.warn(`Native build failed (${nativeErr.message.split('\n')[0]}); building in a container...`)
1438
+ const goVersion = getRequiredGoVersion(sourcePath)
1439
+ const goImage = goVersion ? `golang:${goVersion}` : 'golang:1'
1440
+ const buildResult = spawnSync('docker', [
1441
+ 'run', '--rm',
1442
+ '-v', `${path.resolve(sourcePath)}:/rpk-source:ro`,
1443
+ '-v', `${workDir}:/out`,
1444
+ '-w', '/rpk-source',
1445
+ goImage,
1446
+ 'go', 'build', '-o', '/out/rpk', './cmd/rpk'
1447
+ ], { encoding: 'utf8', timeout: 600000 })
1448
+ const binPath = path.join(workDir, 'rpk')
1449
+ if (buildResult.status !== 0 || !fs.existsSync(binPath)) {
1450
+ throw new Error(`Container build failed: ${buildResult.stderr || 'no binary produced'}`)
1451
+ }
1452
+ return binPath
1453
+ }
1454
+ }
1455
+
1456
+ /**
1457
+ * Look up the latest published version of a plugin from its manifest.
1458
+ * @param {string} plugin - rpk command name (ai, connect, k8s, check)
1459
+ * @returns {string|null} Latest version, or null if unavailable
1460
+ */
1461
+ function fetchLatestPluginVersion(plugin) {
1462
+ const slug = PLUGIN_MANIFEST_SLUGS[plugin] || plugin
1463
+ const result = spawnSync('curl', [
1464
+ '-fsSL', '--retry', '3', '--connect-timeout', '15', '--max-time', '30',
1465
+ `${PLUGIN_MANIFEST_HOST}/${slug}/manifest.json`
1466
+ ], { encoding: 'utf8', timeout: 60000 })
1467
+
1468
+ if (result.status !== 0) {
1469
+ console.warn(`Could not fetch plugin manifest for ${plugin} (slug: ${slug})`)
1470
+ return null
1471
+ }
1472
+
1473
+ try {
1474
+ const manifest = JSON.parse(result.stdout)
1475
+ const latest = (manifest.archives || []).find(a => a.is_latest)
1476
+ return latest ? latest.version : null
1477
+ } catch (err) {
1478
+ console.warn(`Could not parse plugin manifest for ${plugin}: ${err.message}`)
1479
+ return null
1480
+ }
1481
+ }
1482
+
1483
+ /**
1484
+ * Check whether a plugin node contains real plugin commands, as opposed to
1485
+ * only the install/uninstall/upgrade shim compiled into rpk itself.
1486
+ * @param {Object} node - Top-level command node for the plugin
1487
+ * @returns {boolean}
1488
+ */
1489
+ function pluginNodeHasRealCommands(node) {
1490
+ return (node.commands || []).some(c => !PLUGIN_SHIM_SUBCOMMANDS.has(c.name))
1491
+ }
1492
+
1493
+ /**
1494
+ * Replace a plugin's top-level node in a command tree.
1495
+ * Replace-only: throws if the plugin is not already present, so a refresh
1496
+ * can never introduce a command that the snapshot's rpk version lacks.
1497
+ * @param {Object} tree - Full command tree (root node)
1498
+ * @param {string} plugin - rpk command name
1499
+ * @param {Object} freshNode - Replacement node
1500
+ * @returns {Object} New tree with the node replaced
1501
+ */
1502
+ function splicePluginNode(tree, plugin, freshNode) {
1503
+ const commands = tree.commands || []
1504
+ if (!commands.some(c => c.name === plugin)) {
1505
+ throw new Error(`Cannot splice plugin '${plugin}': not present in the base tree`)
1506
+ }
1507
+ return {
1508
+ ...tree,
1509
+ commands: commands.map(c => (c.name === plugin ? freshNode : c))
1510
+ }
1511
+ }
1512
+
1513
+ /**
1514
+ * Carry a snapshot's recorded Linux-only command list onto a working tree.
1515
+ * The list comes from scanning Go build tags in the rpk source, which
1516
+ * from-json runs (including --plugin refreshes) never see, so it can only be
1517
+ * inherited from the existing snapshot. Without it the refreshed snapshot
1518
+ * would lose its platform markers for every future from-json run.
1519
+ * @param {Object} tree - Working command tree (root node)
1520
+ * @param {Object} [fallbackTree] - Tree to inherit linux_only_commands from
1521
+ * when the working tree does not carry the field itself
1522
+ * @returns {Object} Tree that carries linux_only_commands when available
1523
+ */
1524
+ function preserveLinuxOnlyCommands(tree, fallbackTree) {
1525
+ if (!tree || tree.linux_only_commands) return tree
1526
+ const inherited = fallbackTree && fallbackTree.linux_only_commands
1527
+ if (!inherited) return tree
1528
+ return { ...tree, linux_only_commands: [...inherited] }
1529
+ }
1530
+
1531
+ /**
1532
+ * Parse the local "Flags:" section of cobra --help output into flag objects
1533
+ * matching the shape rpk --print-tree emits for compiled-in commands.
1534
+ * The "Global Flags:" section is ignored: rpk-core globals are documented by
1535
+ * the shared global-flags table, and plugin-global flags are captured from
1536
+ * the plugin root command's own Flags section.
1537
+ * @param {string} helpText - Output of `rpk <command> --help`
1538
+ * @returns {Array<Object>} Flags: { name, shorthand?, type, description, default? }
1539
+ */
1540
+ function parseCobraFlags(helpText) {
1541
+ const lines = (helpText || '').split('\n')
1542
+ const start = lines.findIndex(l => /^Flags:\s*$/.test(l))
1543
+ if (start === -1) return []
1544
+
1545
+ const flags = []
1546
+ let flagIndent = null
1547
+ for (let i = start + 1; i < lines.length; i++) {
1548
+ const line = lines[i]
1549
+ if (/^\S/.test(line)) break // next section (Global Flags:, Use "...", ...)
1550
+ if (line.trim() === '') break
1551
+
1552
+ const m = line.match(/^\s+(?:-(\w),\s+)?--([\w.-]+)(?:\s+(\S+))?\s{2,}(.*)$/)
1553
+ if (m) {
1554
+ const [, shorthand, name, valueType, desc] = m
1555
+ if (name === 'help') continue
1556
+ flagIndent = line.search(/\S/)
1557
+ const flag = {
1558
+ name,
1559
+ type: valueType || 'bool',
1560
+ description: desc.trim()
1561
+ }
1562
+ if (shorthand) flag.shorthand = shorthand
1563
+ const def = flag.description.match(/\s*\(default (.+)\)$/)
1564
+ if (def) {
1565
+ flag.default = def[1]
1566
+ flag.description = flag.description.slice(0, def.index).trim()
1567
+ }
1568
+ flags.push(flag)
1569
+ } else if (flags.length > 0 && flagIndent !== null && line.search(/\S/) > flagIndent) {
1570
+ // Wrapped description continuation: any line indented deeper than the
1571
+ // flag column that did not parse as a flag. Continuations can begin
1572
+ // with flag-looking tokens ("(alias: --x-ref)" wraps to "--x-ref)"),
1573
+ // so no dash guard — a genuine flag line always matches the regex
1574
+ // above first.
1575
+ const last = flags[flags.length - 1]
1576
+ last.description = `${last.description} ${line.trim()}`.trim()
1577
+ const def = last.description.match(/\s*\(default (.+)\)$/)
1578
+ if (def) {
1579
+ last.default = def[1]
1580
+ last.description = last.description.slice(0, def.index).trim()
1581
+ }
1582
+ }
1583
+ }
1584
+ return flags
1585
+ }
1586
+
1587
+ /**
1588
+ * Parse the OPTIONS section of urfave/cli --help output (Redpanda Connect)
1589
+ * into the same flag shape as parseCobraFlags. Entries look like:
1590
+ * --log.level value override the configured log level
1591
+ * --set value, -s value [ --set value, -s value ] set a field ...
1592
+ * --chilled continue ... (default: false)
1593
+ * The GLOBAL OPTIONS section is skipped, matching the cobra handling.
1594
+ * @param {string} helpText - Output of `rpk <command> --help`
1595
+ * @returns {Array<Object>} Flags: { name, shorthand?, type, description, default? }
1596
+ */
1597
+ function parseUrfaveFlags(helpText) {
1598
+ const lines = (helpText || '').split('\n')
1599
+ const start = lines.findIndex(l => /^OPTIONS:\s*$/.test(l))
1600
+ if (start === -1) return []
1601
+
1602
+ const flags = []
1603
+ let urfaveIndent = null
1604
+ for (let i = start + 1; i < lines.length; i++) {
1605
+ const line = lines[i]
1606
+ if (/^\S/.test(line)) break // next section (GLOBAL OPTIONS:, ...)
1607
+ if (line.trim() === '') break
1608
+
1609
+ const m = line.match(/^\s{2,}(--\S[^\s]*(?:[^ ]| (?!\s))*)\s{2,}(.*)$/)
1610
+ if (m) {
1611
+ const [, spec, desc] = m
1612
+ const nameMatch = spec.match(/^--([\w.-]+)/)
1613
+ if (!nameMatch) continue
1614
+ const name = nameMatch[1]
1615
+ if (name === 'help') continue
1616
+ urfaveIndent = line.search(/\S/)
1617
+
1618
+ const flag = { name, description: desc.trim() }
1619
+ const shorthandMatch = spec.match(/,\s+-(\w)\b/)
1620
+ if (shorthandMatch) flag.shorthand = shorthandMatch[1]
1621
+
1622
+ const repeatable = spec.includes('[ --')
1623
+ const hasValue = new RegExp(`^--${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s+\\S`).test(spec)
1624
+ flag.type = repeatable ? 'strings' : (hasValue ? 'string' : 'bool')
1625
+
1626
+ const def = flag.description.match(/\s*\(default:\s*(.+?)\)$/)
1627
+ if (def) {
1628
+ flag.default = def[1]
1629
+ flag.description = flag.description.slice(0, def.index).trim()
1630
+ }
1631
+ flags.push(flag)
1632
+ } else if (flags.length > 0 && urfaveIndent !== null && line.search(/\S/) > urfaveIndent) {
1633
+ // Continuation lines may begin with flag-looking tokens; see the
1634
+ // cobra parser above for why there is no dash guard
1635
+ const last = flags[flags.length - 1]
1636
+ last.description = `${last.description} ${line.trim()}`.trim()
1637
+ const def = last.description.match(/\s*\(default:\s*(.+?)\)$/)
1638
+ if (def) {
1639
+ last.default = def[1]
1640
+ last.description = last.description.slice(0, def.index).trim()
1641
+ }
1642
+ }
1643
+ }
1644
+ return flags
1645
+ }
1646
+
1647
+ /**
1648
+ * Parse flags from --help output regardless of CLI framework: cobra prints a
1649
+ * "Flags:" section (rpk ai, k8s, check), urfave/cli prints "OPTIONS:"
1650
+ * (Redpanda Connect).
1651
+ * @param {string} helpText
1652
+ * @returns {Array<Object>}
1653
+ */
1654
+ function parseHelpFlags(helpText) {
1655
+ if (/^Flags:\s*$/m.test(helpText || '')) return parseCobraFlags(helpText)
1656
+ if (/^OPTIONS:\s*$/m.test(helpText || '')) return parseUrfaveFlags(helpText)
1657
+ return []
1658
+ }
1659
+
1660
+ /**
1661
+ * Fill in flags for plugin subtree commands by running `--help` per command.
1662
+ * Plugin subcommand nodes come from the plugin's --help-autocomplete output,
1663
+ * which carries no flag information, so without this every plugin command
1664
+ * page renders with an empty flags section. Nodes that already have flags
1665
+ * (the install/uninstall/upgrade shim compiled into rpk) are left alone.
1666
+ * Help failures are non-fatal: the affected command keeps an empty list.
1667
+ * @param {Object} node - The plugin's top-level command node (mutated)
1668
+ * @param {Function} execHelp - (argPath: string[]) => string|null, returns
1669
+ * the help text for `rpk <argPath...> --help`
1670
+ * @returns {number} Number of commands enriched
1671
+ */
1672
+ function enrichPluginTreeWithFlags(node, execHelp) {
1673
+ let enriched = 0
1674
+ const walk = (cmd, argPath) => {
1675
+ if (!cmd.flags || cmd.flags.length === 0) {
1676
+ const helpText = execHelp(argPath)
1677
+ if (helpText) {
1678
+ const flags = parseHelpFlags(helpText)
1679
+ if (flags.length > 0) {
1680
+ cmd.flags = flags
1681
+ enriched++
1682
+ }
1683
+ }
1684
+ }
1685
+ for (const child of cmd.commands || []) {
1686
+ walk(child, [...argPath, child.name])
1687
+ }
1688
+ }
1689
+ walk(node, [node.name])
1690
+ return enriched
1691
+ }
1692
+
1693
+ /**
1694
+ * Install a single plugin and capture its fresh command subtree.
1695
+ * Runs with an isolated HOME so the install never touches the caller's
1696
+ * rpk plugin state, and the caller's installed plugins never leak in.
1697
+ * @param {Object} params
1698
+ * @param {string} params.plugin - rpk command name (ai, connect, k8s, check)
1699
+ * @param {string} [params.pluginVersion] - Version to pin; defaults to latest
1700
+ * @param {string} params.rpkBinPath - rpk binary to run
1701
+ * @returns {Object} { node, version } - Fresh plugin node and the version recorded
1702
+ */
1703
+ function fetchPluginSubtree({ plugin, pluginVersion, rpkBinPath }) {
1704
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'rpk-plugin-home-'))
1705
+ const env = { ...process.env, HOME: tmpHome }
1706
+
1707
+ const runInstall = (pinned) => {
1708
+ const args = [plugin, 'install']
1709
+ const versionFlag = PLUGIN_INSTALL_VERSION_FLAGS[plugin]
1710
+ if (pinned && pluginVersion && versionFlag) {
1711
+ args.push(versionFlag, pluginVersion)
1712
+ }
1713
+ console.log(`Installing plugin: rpk ${args.join(' ')}`)
1714
+ return spawnSync(rpkBinPath, args, { encoding: 'utf8', env, timeout: 300000 })
1715
+ }
1716
+
1717
+ let pinApplied = Boolean(pluginVersion)
1718
+ let installResult = runInstall(true)
1719
+ if (installResult.status !== 0 && pluginVersion) {
1720
+ const output = `${installResult.stderr || ''}${installResult.stdout || ''}`
1721
+ // 'unknown flag': this rpk predates the pin flag.
1722
+ // 'is not valid': rpk's version validation rejected the pin (its regex
1723
+ // caps each segment at two digits, so connect versions like 4.102.0
1724
+ // fail). In both cases installing latest is the right recovery: the
1725
+ // dispatch fires right after a release, when latest IS the release.
1726
+ if (output.includes('unknown flag') || output.includes('is not valid')) {
1727
+ console.warn(
1728
+ `rpk rejected the version pin for '${plugin}' ` +
1729
+ `(${output.trim().split('\n')[0]}); retrying without the pin (installs latest)`
1730
+ )
1731
+ pinApplied = false
1732
+ installResult = runInstall(false)
1733
+ }
1734
+ }
1735
+ if (installResult.status !== 0) {
1736
+ throw new Error(
1737
+ `Failed to install plugin '${plugin}':\n` +
1738
+ `${installResult.stderr || installResult.stdout || 'no output'}`
1739
+ )
1740
+ }
1741
+
1742
+ const treeResult = spawnSync(rpkBinPath, ['--print-tree'], {
1743
+ encoding: 'utf8',
1744
+ env,
1745
+ timeout: 120000,
1746
+ maxBuffer: 50 * 1024 * 1024
1747
+ })
1748
+ if (treeResult.status !== 0) {
1749
+ const stderr = treeResult.stderr || ''
1750
+ if (stderr.includes('unknown flag')) {
1751
+ throw new Error(
1752
+ 'This rpk binary does not support --print-tree (requires rpk >= v26.2.0).\n' +
1753
+ 'Pass --rpk-bin with a newer binary.'
1754
+ )
1755
+ }
1756
+ throw new Error(`Failed to run rpk --print-tree: ${stderr}`)
1757
+ }
1758
+
1759
+ const freshTree = JSON.parse(treeResult.stdout)
1760
+ const node = (freshTree.commands || []).find(c => c.name === plugin)
1761
+ if (!node) {
1762
+ throw new Error(`Plugin '${plugin}' not found in rpk command tree after install`)
1763
+ }
1764
+ if (!pluginNodeHasRealCommands(node)) {
1765
+ throw new Error(
1766
+ `Plugin '${plugin}' installed but exposed no commands beyond the ` +
1767
+ 'install/uninstall/upgrade shim.\n' +
1768
+ 'The install likely resolved nothing. For pre-GA plugins (no promoted ' +
1769
+ 'latest version in the manifest), a version pin is required: ' +
1770
+ `--plugin-version <version>`
1771
+ )
1772
+ }
1773
+
1774
+ // Fill in per-command flags: autocomplete trees carry none, but the plugin
1775
+ // binary is installed, so each command's --help documents them
1776
+ console.log(`Extracting flags from ${plugin} command help output...`)
1777
+ const enriched = enrichPluginTreeWithFlags(node, (argPath) => {
1778
+ const helpResult = spawnSync(rpkBinPath, [...argPath, '--help'], {
1779
+ encoding: 'utf8',
1780
+ env,
1781
+ timeout: 30000
1782
+ })
1783
+ return helpResult.status === 0 ? helpResult.stdout : null
1784
+ })
1785
+ console.log(` Extracted flags for ${enriched} command(s)`)
1786
+
1787
+ // When the pin was applied, record it. When we fell back to latest (or no
1788
+ // pin was given), record what the manifest says latest is: that is what
1789
+ // actually installed.
1790
+ const version = pinApplied ? pluginVersion : fetchLatestPluginVersion(plugin)
1791
+ if (!version) {
1792
+ console.warn(
1793
+ `Could not determine the installed version of '${plugin}'; ` +
1794
+ 'plugin_versions will not be updated for this run'
1795
+ )
1796
+ } else if (pluginVersion && version !== pluginVersion) {
1797
+ console.warn(
1798
+ `Requested ${plugin} ${pluginVersion} but installed latest (${version}) ` +
1799
+ 'because rpk rejected the version pin'
1800
+ )
1801
+ }
1802
+
1803
+ return { node, version }
1027
1804
  }
1028
1805
 
1029
1806
  /**
@@ -1042,6 +1819,10 @@ async function handleRpkDocsGeneration(options = {}) {
1042
1819
  overrides: overridesPath,
1043
1820
  fromSource, // Path to local rpk Go source directory
1044
1821
  fromJson, // Path to existing versioned JSON file to regenerate from
1822
+ plugin, // Refresh a single plugin's subtree (requires fromJson)
1823
+ pluginVersion, // Version to pin for the plugin install (defaults to latest)
1824
+ pluginPins = {}, // Per-plugin version pins for full generation (pre-GA plugins)
1825
+ rpkBin, // Existing rpk binary to use for the plugin refresh
1045
1826
  ref, // Git ref (branch or tag) to document
1046
1827
  sourceRef, // Alias for ref
1047
1828
  diff: diffVersion,
@@ -1058,6 +1839,15 @@ async function handleRpkDocsGeneration(options = {}) {
1058
1839
  // Normalize ref/sourceRef
1059
1840
  const effectiveRef = ref || sourceRef
1060
1841
 
1842
+ for (const pinnedPlugin of Object.keys(pluginPins)) {
1843
+ if (!REFRESHABLE_PLUGINS.includes(pinnedPlugin)) {
1844
+ console.warn(
1845
+ `Warning: --plugin-pin for unknown plugin '${pinnedPlugin}' ` +
1846
+ `(known plugins: ${REFRESHABLE_PLUGINS.join(', ')}); the pin will have no effect`
1847
+ )
1848
+ }
1849
+ }
1850
+
1061
1851
  const repoRoot = findRepoRoot()
1062
1852
  const dataDir = customDataDir || path.join(repoRoot, 'docs-data')
1063
1853
  const defaultOutputDir = path.join(repoRoot, 'modules', 'reference', 'pages', 'rpk')
@@ -1094,6 +1884,10 @@ async function handleRpkDocsGeneration(options = {}) {
1094
1884
  let tree
1095
1885
  let rpkVersion
1096
1886
  let sourcePath
1887
+ // Managed plugins whose install failed this run. Passed to generateRpkDocs
1888
+ // as explicit protectedPlugins (merged there with auto-detection) so a
1889
+ // failed install never deletes or rewrites that plugin's existing docs.
1890
+ let failedPlugins = []
1097
1891
 
1098
1892
  try {
1099
1893
  // Fast path: regenerate from existing JSON file
@@ -1105,6 +1899,10 @@ async function handleRpkDocsGeneration(options = {}) {
1105
1899
  console.log(`Loading command tree from ${jsonPath}`)
1106
1900
  const jsonData = JSON.parse(fs.readFileSync(jsonPath, 'utf8'))
1107
1901
  tree = jsonData.raw_tree || jsonData.tree
1902
+ // Inherit the Linux-only command list from whichever stored tree
1903
+ // carries it: it cannot be re-detected without the rpk source, and
1904
+ // both rendering and the re-saved snapshot below depend on it.
1905
+ tree = preserveLinuxOnlyCommands(tree, jsonData.tree || jsonData.raw_tree)
1108
1906
  rpkVersion = jsonData.rpk_version || 'local'
1109
1907
 
1110
1908
  if (!tree) {
@@ -1113,16 +1911,154 @@ async function handleRpkDocsGeneration(options = {}) {
1113
1911
 
1114
1912
  console.log(`Loaded tree with rpk version: ${rpkVersion}`)
1115
1913
 
1914
+ // Refresh a single plugin's subtree in the snapshot before rendering.
1915
+ // Plugins release on their own cadence, so their commands can change
1916
+ // without a new rpk release. Splice the fresh subtree into the full
1917
+ // tree and re-render everything: only the plugin's pages actually
1918
+ // change, and stale-file cleanup, nav rebuild, and override
1919
+ // validation all see a complete tree.
1920
+ let pluginVersions = jsonData.plugin_versions || {}
1921
+ let pluginDiffData = null
1922
+ if (plugin) {
1923
+ if (!REFRESHABLE_PLUGINS.includes(plugin)) {
1924
+ throw new Error(
1925
+ `Unknown plugin '${plugin}'. Supported plugins: ${REFRESHABLE_PLUGINS.join(', ')}\n` +
1926
+ 'Use the rpk command name (for example "ai"), not the manifest slug ("rpai").'
1927
+ )
1928
+ }
1929
+
1930
+ const oldNode = (tree.commands || []).find(c => c.name === plugin)
1931
+ if (!oldNode) {
1932
+ console.log(
1933
+ `rpk ${rpkVersion} has no '${plugin}' command; nothing to update. ` +
1934
+ 'This is expected when the plugin only exists in a newer rpk line.'
1935
+ )
1936
+ return {
1937
+ success: true,
1938
+ skipped: true,
1939
+ reason: `plugin '${plugin}' not present in rpk ${rpkVersion}`,
1940
+ rpkVersion
1941
+ }
1942
+ }
1943
+
1944
+ const rpkBinPath = acquireRpkBinary(rpkVersion, { rpkBin })
1945
+ const { node: rawNode, version: resolvedVersion } = fetchPluginSubtree({
1946
+ plugin,
1947
+ pluginVersion,
1948
+ rpkBinPath
1949
+ })
1950
+
1951
+ // Reapply platform markers: the fresh subtree comes from a plain
1952
+ // --print-tree run, but every command in the snapshot's tree carries
1953
+ // a platforms field. Reuse the snapshot's recorded Linux-only list.
1954
+ const linuxOnly = new Set(tree.linux_only_commands || [])
1955
+ const freshNode = addPlatformMarkersFromSource(
1956
+ { commands: [rawNode] },
1957
+ linuxOnly
1958
+ ).commands[0]
1959
+
1960
+ // Plugin-scoped diff for the PR summary: compare only the plugin's
1961
+ // subtree, old snapshot state vs fresh install
1962
+ pluginDiffData = generateRpkDiff(
1963
+ { ...tree, commands: [oldNode] },
1964
+ { ...tree, commands: [freshNode] },
1965
+ {
1966
+ oldVersion: pluginVersions[plugin] || 'previous snapshot',
1967
+ newVersion: resolvedVersion || 'latest'
1968
+ }
1969
+ )
1970
+ printDiffReport(pluginDiffData)
1971
+
1972
+ tree = splicePluginNode(tree, plugin, freshNode)
1973
+ if (resolvedVersion) {
1974
+ pluginVersions = { ...pluginVersions, [plugin]: resolvedVersion }
1975
+ }
1976
+ console.log(
1977
+ `Spliced fresh '${plugin}' subtree` +
1978
+ (resolvedVersion ? ` (version ${resolvedVersion})` : '') +
1979
+ ` into snapshot for rpk ${rpkVersion}`
1980
+ )
1981
+
1982
+ // Stamp new plugin commands and flags with the plugin's own version
1983
+ // so pages render "This command was introduced in <plugin> version X".
1984
+ // Runs before the overrides load below, so the stamps apply to this
1985
+ // run's rendering, not just the next one.
1986
+ if (resolvedVersion) {
1987
+ updateOverridesWithIntroducedVersions(
1988
+ pluginDiffData,
1989
+ overridesPath || path.join(dataDir, 'rpk-overrides.json'),
1990
+ resolvedVersion,
1991
+ { [plugin]: resolvedVersion }
1992
+ )
1993
+ }
1994
+
1995
+ if (diffVersion) {
1996
+ console.warn('Note: --diff is ignored in --plugin mode (the summary uses a plugin-scoped diff)')
1997
+ }
1998
+
1999
+ // Plugin releases land in What's new too when requested: their
2000
+ // changes never arrive through a Redpanda release diff. Entries
2001
+ // render without xrefs because plugin subtrees may render as
2002
+ // partials with no linkable pages.
2003
+ if (whatsNewPath) {
2004
+ const label = resolvedVersion
2005
+ ? `${plugin} plugin ${resolvedVersion}`
2006
+ : `${plugin} plugin`
2007
+ updateWhatsNewFile(pluginDiffData, whatsNewPath, label, { xrefs: false, sectionHeading: '== rpk plugins' })
2008
+ }
2009
+ }
2010
+
1116
2011
  // Skip to documentation generation (after the source building steps)
1117
2012
  // Load and validate overrides
1118
2013
  const defaultOverridesPath = path.join(dataDir, 'rpk-overrides.json')
1119
2014
  const effectiveOverridesPath = overridesPath || defaultOverridesPath
1120
- const overridesData = loadOverrides(effectiveOverridesPath, tree, { strict: false })
2015
+
2016
+ // Stamp new commands and flags with introducedInVersion BEFORE the
2017
+ // overrides load so this run's pages render the labels. Without this,
2018
+ // a --from-json --diff run emits unlabeled pages and the labels only
2019
+ // appear on the next regeneration (the from-source path stamps before
2020
+ // rendering already).
2021
+ if (diffVersion && !plugin) {
2022
+ const oldDataForStamp = loadVersionedJson(diffVersion, dataDir)
2023
+ if (oldDataForStamp) {
2024
+ const stampDiff = generateRpkDiff(oldDataForStamp.raw_tree || oldDataForStamp.tree, tree, {
2025
+ oldVersion: diffVersion,
2026
+ newVersion: rpkVersion,
2027
+ oldDeprecatedCommands: oldDataForStamp.deprecated_commands || {},
2028
+ newDeprecatedCommands: jsonData.deprecated_commands || {}
2029
+ })
2030
+ if (stampDiff.details.newCommands.length > 0 || stampDiff.details.newFlags.length > 0) {
2031
+ updateOverridesWithIntroducedVersions(stampDiff, effectiveOverridesPath, rpkVersion, pluginVersions)
2032
+ }
2033
+ }
2034
+ }
2035
+
2036
+ const { overrides: overridesData, validation: overrideValidation } = loadOverrides(effectiveOverridesPath, tree, { strict: false }) || {}
1121
2037
 
1122
2038
  if (overridesData) {
1123
2039
  console.log(`Loaded overrides from ${effectiveOverridesPath}`)
1124
2040
  }
1125
2041
 
2042
+ // Persist the merged snapshot so the refreshed plugin subtree and its
2043
+ // recorded version become the new baseline (mirrors the from-source
2044
+ // path's augmentedData structure)
2045
+ if (plugin) {
2046
+ // The Linux-only list can't be re-detected during a plugin refresh
2047
+ // (it comes from Go build-tag scanning of the rpk source), so make
2048
+ // sure the saved snapshot's trees still carry it before persisting.
2049
+ tree = preserveLinuxOnlyCommands(tree, jsonData.raw_tree || jsonData.tree)
2050
+ let enhancedTree = tree
2051
+ if (overridesData) {
2052
+ const resolvedOverrides = resolveReferences(overridesData, overridesData)
2053
+ enhancedTree = applyOverridesToTree(tree, resolvedOverrides, '')
2054
+ }
2055
+ jsonData.raw_tree = tree
2056
+ jsonData.tree = enhancedTree
2057
+ jsonData.plugin_versions = pluginVersions
2058
+ jsonData.generated_at = new Date().toISOString()
2059
+ saveVersionedJson(jsonData, rpkVersion, dataDir)
2060
+ }
2061
+
1126
2062
  // Generate AsciiDoc documentation
1127
2063
  console.log(`\nGenerating AsciiDoc files to ${finalOutputDir}...`)
1128
2064
 
@@ -1132,10 +2068,11 @@ async function handleRpkDocsGeneration(options = {}) {
1132
2068
  outputDir: finalOutputDir,
1133
2069
  cloudSecretDir: finalCloudSecretDir,
1134
2070
  rpkVersion,
1135
- pluginVersions: {},
2071
+ pluginVersions,
1136
2072
  draftMissing,
1137
2073
  preservationsDir: preserveFrom,
1138
- navFile
2074
+ navFile,
2075
+ protectedPlugins: failedPlugins
1139
2076
  })
1140
2077
 
1141
2078
  console.log(`\nGeneration complete!`)
@@ -1154,16 +2091,19 @@ async function handleRpkDocsGeneration(options = {}) {
1154
2091
  showInfo
1155
2092
  })
1156
2093
 
1157
- // Generate diff if requested
2094
+ // Generate diff if requested (rpk-version diff; not used in --plugin
2095
+ // mode, which produces its own plugin-scoped diff)
1158
2096
  let diffData = null
1159
- if (diffVersion) {
2097
+ if (diffVersion && !plugin) {
1160
2098
  const oldData = loadVersionedJson(diffVersion, dataDir)
1161
2099
  if (oldData) {
1162
2100
  // Use raw_tree for diffing (falls back to tree for backward compatibility)
1163
2101
  const oldTree = oldData.raw_tree || oldData.tree
1164
2102
  diffData = generateRpkDiff(oldTree, tree, {
1165
2103
  oldVersion: diffVersion,
1166
- newVersion: rpkVersion
2104
+ newVersion: rpkVersion,
2105
+ oldDeprecatedCommands: oldData.deprecated_commands || {},
2106
+ newDeprecatedCommands: jsonData.deprecated_commands || {}
1167
2107
  })
1168
2108
 
1169
2109
  // Save diff
@@ -1177,7 +2117,7 @@ async function handleRpkDocsGeneration(options = {}) {
1177
2117
 
1178
2118
  // Update what's-new file if requested
1179
2119
  if (whatsNewPath) {
1180
- updateWhatsNewFile(diffData, whatsNewPath, rpkVersion)
2120
+ updateWhatsNewFile(diffData, whatsNewPath, rpkVersion, { linkable: makeLinkablePredicate(overridesData), hasSubcommands: makeSubcommandPredicate(tree) })
1181
2121
  }
1182
2122
  } else {
1183
2123
  console.warn(`Warning: Could not load previous version ${diffVersion} for diff`)
@@ -1187,11 +2127,15 @@ async function handleRpkDocsGeneration(options = {}) {
1187
2127
  // Generate PR summary
1188
2128
  const prSummary = generatePRSummary({
1189
2129
  rpkVersion,
2130
+ plugin,
2131
+ pluginVersion: plugin ? pluginVersions[plugin] : undefined,
1190
2132
  commandCount: result.commandCount,
1191
2133
  filesGenerated: result.filesGenerated,
1192
2134
  filesSkipped: result.filesSkipped,
1193
- diffData,
2135
+ diffData: plugin ? pluginDiffData : diffData,
1194
2136
  validationResult,
2137
+ overrideValidation,
2138
+ descriptionCoverage: computeDescriptionCoverage(tree, overridesData),
1195
2139
  outputDir: finalOutputDir
1196
2140
  })
1197
2141
 
@@ -1211,12 +2155,21 @@ async function handleRpkDocsGeneration(options = {}) {
1211
2155
  filesSkipped: result.filesSkipped,
1212
2156
  outputDir: finalOutputDir,
1213
2157
  rpkVersion,
1214
- diffData,
2158
+ plugin,
2159
+ pluginVersion: plugin ? pluginVersions[plugin] : undefined,
2160
+ diffData: plugin ? pluginDiffData : diffData,
1215
2161
  validationResult,
1216
2162
  prSummary
1217
2163
  }
1218
2164
  }
1219
2165
 
2166
+ if (plugin) {
2167
+ throw new Error(
2168
+ '--plugin requires --from-json <snapshot>.\n' +
2169
+ 'A plugin refresh splices the fresh subtree into an existing committed snapshot.'
2170
+ )
2171
+ }
2172
+
1220
2173
  // Step 1: Get source code
1221
2174
  if (effectiveRef) {
1222
2175
  // Clone/checkout from ref
@@ -1284,26 +2237,38 @@ async function handleRpkDocsGeneration(options = {}) {
1284
2237
  try {
1285
2238
  // Build on Linux (in container) - has all commands
1286
2239
  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)
2240
+ const { tree: linuxTree, failedPlugins: linuxFailedPlugins } = fetchRpkTreeFromLinuxSource(sourcePath, pluginPins)
2241
+
2242
+ // Build natively (on Darwin) for comparison. The Linux tree is
2243
+ // authoritative, so a comparison-build failure (for example, local
2244
+ // Go older than go.mod requires) only skips dynamic platform
2245
+ // detection - it must not discard the Linux tree.
2246
+ let darwinTree = null
2247
+ try {
2248
+ console.log('Building rpk natively for comparison...')
2249
+ darwinTree = fetchRpkTreeFromSource(sourcePath)
2250
+ } catch (nativeErr) {
2251
+ console.warn(`⚠ Native comparison build failed: ${nativeErr.message}`)
2252
+ console.log('Skipping dynamic platform detection; using source scanning only.')
2253
+ }
2254
+
2255
+ if (darwinTree) {
2256
+ // Compare trees to find Linux-only commands
2257
+ const dynamicLinuxOnly = detectLinuxOnlyByComparison(linuxTree, darwinTree)
2258
+ if (dynamicLinuxOnly.size > 0) {
2259
+ console.log(`Dynamic detection found ${dynamicLinuxOnly.size} Linux-only command(s):`)
2260
+ for (const cmd of dynamicLinuxOnly) {
2261
+ console.log(` - ${cmd}`)
2262
+ linuxOnlyCommands.add(cmd)
2263
+ }
1300
2264
  }
1301
2265
  }
1302
2266
 
1303
2267
  // Use the Linux tree (has all commands)
1304
2268
  tree = linuxTree
2269
+ failedPlugins = linuxFailedPlugins
1305
2270
  } catch (dockerErr) {
1306
- // Docker build failed (e.g., Go version mismatch) - fall back to native build
2271
+ // Docker build failed - fall back to native build
1307
2272
  console.warn(`\n⚠ Docker build failed: ${dockerErr.message}`)
1308
2273
  console.log('Falling back to native Go build...')
1309
2274
  console.log('Note: Linux-only commands will be detected via source scanning only.\n')
@@ -1312,7 +2277,7 @@ async function handleRpkDocsGeneration(options = {}) {
1312
2277
  } else if (canBuildLinux) {
1313
2278
  console.log('\nBuilding rpk in Linux container...')
1314
2279
  try {
1315
- tree = fetchRpkTreeFromLinuxSource(sourcePath)
2280
+ ({ tree, failedPlugins } = fetchRpkTreeFromLinuxSource(sourcePath, pluginPins))
1316
2281
  } catch (dockerErr) {
1317
2282
  if (canBuildNative) {
1318
2283
  console.warn(`\n⚠ Docker build failed: ${dockerErr.message}`)
@@ -1343,8 +2308,23 @@ async function handleRpkDocsGeneration(options = {}) {
1343
2308
  const plugins = detectPlugins(tree)
1344
2309
  console.log(`Detected plugins: ${plugins.join(', ') || 'none'}`)
1345
2310
 
1346
- // Note: Plugin versions not available when building from source
2311
+ // Record the version of each plugin that actually installed during the
2312
+ // build (`rpk <plugin> install` resolves the manifest's latest, so the
2313
+ // manifest lookup reflects what the tree contains). Shim-only plugins
2314
+ // (failed installs) are skipped: their subtree carries no plugin commands.
1347
2315
  const pluginVersions = {}
2316
+ for (const pluginName of REFRESHABLE_PLUGINS) {
2317
+ const node = (tree.commands || []).find(c => c.name === pluginName)
2318
+ if (node && pluginNodeHasRealCommands(node)) {
2319
+ const installedVersion = pluginPins[pluginName] || fetchLatestPluginVersion(pluginName)
2320
+ if (installedVersion) {
2321
+ pluginVersions[pluginName] = installedVersion
2322
+ }
2323
+ }
2324
+ }
2325
+ if (Object.keys(pluginVersions).length > 0) {
2326
+ console.log(`Plugin versions: ${JSON.stringify(pluginVersions)}`)
2327
+ }
1348
2328
 
1349
2329
  // Step 5: Scan source for deprecated/hidden commands
1350
2330
  let deprecatedCommands = {}
@@ -1365,7 +2345,13 @@ async function handleRpkDocsGeneration(options = {}) {
1365
2345
  // Load and validate overrides
1366
2346
  const defaultOverridesPath = path.join(dataDir, 'rpk-overrides.json')
1367
2347
  const effectiveOverridesPath = overridesPath || defaultOverridesPath
1368
- const overridesData = loadOverrides(effectiveOverridesPath, tree, { strict: false })
2348
+
2349
+ // Annotate deprecated commands that are still visible in the tree so
2350
+ // their pages render deprecation banners without manual curation. Runs
2351
+ // before the overrides load so the annotations apply to this run.
2352
+ mergeVisibleDeprecationsIntoOverrides(deprecatedCommands, tree, effectiveOverridesPath)
2353
+
2354
+ const { overrides: overridesData, validation: overrideValidation } = loadOverrides(effectiveOverridesPath, tree, { strict: false }) || {}
1369
2355
 
1370
2356
  if (overridesData) {
1371
2357
  console.log(`Loaded overrides from ${effectiveOverridesPath}`)
@@ -1403,7 +2389,9 @@ async function handleRpkDocsGeneration(options = {}) {
1403
2389
  const oldTree = oldData.raw_tree || oldData.tree
1404
2390
  diffData = generateRpkDiff(oldTree, tree, {
1405
2391
  oldVersion: diffVersion,
1406
- newVersion: rpkVersion
2392
+ newVersion: rpkVersion,
2393
+ oldDeprecatedCommands: oldData.deprecated_commands || {},
2394
+ newDeprecatedCommands: deprecatedCommands
1407
2395
  })
1408
2396
 
1409
2397
  // Save diff
@@ -1415,14 +2403,17 @@ async function handleRpkDocsGeneration(options = {}) {
1415
2403
  // Print diff report
1416
2404
  printDiffReport(diffData)
1417
2405
 
1418
- // Update overrides with introducedInVersion for new commands
1419
- if (diffData.details.newCommands.length > 0 && effectiveOverridesPath) {
1420
- updateOverridesWithIntroducedVersions(diffData, effectiveOverridesPath, rpkVersion)
2406
+ // Update overrides with introducedInVersion for new commands. Plugin
2407
+ // commands get the plugin's own version (plugins release on their own
2408
+ // cadence, so the rpk version would be wrong and the page note would
2409
+ // render "introduced in <plugin> version <rpk version>").
2410
+ if ((diffData.details.newCommands.length > 0 || diffData.details.newFlags.length > 0) && effectiveOverridesPath) {
2411
+ updateOverridesWithIntroducedVersions(diffData, effectiveOverridesPath, rpkVersion, pluginVersions)
1421
2412
  }
1422
2413
 
1423
2414
  // Update what's-new file if requested
1424
2415
  if (whatsNewPath) {
1425
- updateWhatsNewFile(diffData, whatsNewPath, rpkVersion)
2416
+ updateWhatsNewFile(diffData, whatsNewPath, rpkVersion, { linkable: makeLinkablePredicate(overridesData), hasSubcommands: makeSubcommandPredicate(tree) })
1426
2417
  }
1427
2418
  } else {
1428
2419
  console.warn(`Warning: Could not load previous version ${diffVersion} for diff`)
@@ -1441,7 +2432,8 @@ async function handleRpkDocsGeneration(options = {}) {
1441
2432
  pluginVersions,
1442
2433
  draftMissing,
1443
2434
  preservationsDir: preserveFrom,
1444
- navFile
2435
+ navFile,
2436
+ protectedPlugins: failedPlugins
1445
2437
  })
1446
2438
 
1447
2439
  console.log(`\nGeneration complete!`)
@@ -1463,11 +2455,13 @@ async function handleRpkDocsGeneration(options = {}) {
1463
2455
  // Generate PR summary
1464
2456
  const prSummary = generatePRSummary({
1465
2457
  rpkVersion,
2458
+ overrideValidation,
1466
2459
  commandCount: result.commandCount,
1467
2460
  filesGenerated: result.filesGenerated,
1468
2461
  filesSkipped: result.filesSkipped,
1469
2462
  diffData,
1470
2463
  validationResult,
2464
+ descriptionCoverage: computeDescriptionCoverage(tree, overridesData),
1471
2465
  outputDir: finalOutputDir
1472
2466
  })
1473
2467
 
@@ -1506,26 +2500,75 @@ async function handleRpkDocsGeneration(options = {}) {
1506
2500
  * @param {Object} options - Summary options
1507
2501
  * @returns {string} Markdown formatted summary
1508
2502
  */
2503
+ /**
2504
+ * Find description overrides that replace substantially longer source help.
2505
+ * Curation is legitimate, but silent replacement is how stale or wrong
2506
+ * content survives regeneration, so every regen PR lists what is hidden.
2507
+ * @param {Object} tree - Raw command tree
2508
+ * @param {Object} overridesData - Parsed overrides
2509
+ * @returns {Array<{commandPath: string, overrideChars: number, sourceChars: number}>}
2510
+ */
2511
+ function computeDescriptionCoverage(tree, overridesData) {
2512
+ if (!tree || !overridesData?.commands) return []
2513
+ const commandMap = flattenToMap(tree)
2514
+ const results = []
2515
+ for (const [cmdPath, override] of Object.entries(overridesData.commands)) {
2516
+ if (typeof override?.description !== 'string') continue
2517
+ const cmd = commandMap.get(cmdPath)
2518
+ if (!cmd || typeof cmd.description !== 'string') continue
2519
+ const sourceChars = cmd.description.trim().length
2520
+ // appendToDescription content still reaches the page, so count it
2521
+ const appended = typeof override.appendToDescription === 'string' ? override.appendToDescription.trim().length : 0
2522
+ const overrideChars = override.description.trim().length + appended
2523
+ if (sourceChars - overrideChars > 500) {
2524
+ results.push({ commandPath: cmdPath, overrideChars, sourceChars })
2525
+ }
2526
+ }
2527
+ return results.sort((a, b) => (b.sourceChars - b.overrideChars) - (a.sourceChars - a.overrideChars))
2528
+ }
2529
+
1509
2530
  function generatePRSummary(options) {
1510
2531
  const {
1511
2532
  rpkVersion,
2533
+ plugin,
2534
+ pluginVersion,
1512
2535
  commandCount,
1513
2536
  filesGenerated,
1514
2537
  filesSkipped = 0,
1515
2538
  diffData,
1516
2539
  validationResult,
2540
+ overrideValidation,
2541
+ descriptionCoverage,
1517
2542
  outputDir
1518
2543
  } = options
1519
2544
 
1520
2545
  const lines = []
1521
2546
 
1522
2547
  // Header
1523
- lines.push('## rpk Documentation Generation Summary')
1524
- lines.push('')
2548
+ if (plugin) {
2549
+ lines.push(`## rpk ${plugin} Plugin Documentation Update`)
2550
+ lines.push('')
2551
+ if (pluginVersion) {
2552
+ lines.push(`**Plugin version:** ${pluginVersion}`)
2553
+ }
2554
+ lines.push(`**Base rpk snapshot:** ${rpkVersion}`)
2555
+ lines.push('')
2556
+ lines.push(
2557
+ `The \`rpk ${plugin}\` subtree was refreshed in the snapshot, then the ` +
2558
+ 'full rpk reference was re-rendered from it as a converge. Pages outside ' +
2559
+ `\`rpk ${plugin}\` can carry template-level or consistency updates, and ` +
2560
+ 'stale generated files are cleaned up, so the change list may be wider ' +
2561
+ 'than the plugin itself.'
2562
+ )
2563
+ lines.push('')
2564
+ } else {
2565
+ lines.push('## rpk Documentation Generation Summary')
2566
+ lines.push('')
1525
2567
 
1526
- // Version info
1527
- lines.push(`**Version:** ${rpkVersion}`)
1528
- lines.push('')
2568
+ // Version info
2569
+ lines.push(`**Version:** ${rpkVersion}`)
2570
+ lines.push('')
2571
+ }
1529
2572
 
1530
2573
  // Generation stats
1531
2574
  lines.push('### Generation Statistics')
@@ -1544,52 +2587,118 @@ function generatePRSummary(options) {
1544
2587
  lines.push('### Changes from Previous Version')
1545
2588
  lines.push('')
1546
2589
 
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.')
2590
+ const {
2591
+ newCommands,
2592
+ removedCommands,
2593
+ newFlags,
2594
+ removedFlags,
2595
+ changedDefaults = 0,
2596
+ changedFlagTypes = 0,
2597
+ changedFlagRequirements = 0,
2598
+ changedFlagDescriptions = 0,
2599
+ descriptionChanges = 0,
2600
+ newlyDeprecatedCommands = 0
2601
+ } = diffData.summary
2602
+
2603
+ const totalChanges = newCommands + removedCommands + newFlags + removedFlags +
2604
+ changedDefaults + changedFlagTypes + changedFlagRequirements +
2605
+ changedFlagDescriptions + descriptionChanges + newlyDeprecatedCommands
2606
+
2607
+ if (totalChanges === 0) {
2608
+ lines.push('No command, flag, or default changes detected.')
1551
2609
  } else {
1552
2610
  lines.push(`| Change Type | Count |`)
1553
2611
  lines.push(`|-------------|-------|`)
1554
2612
  if (newCommands > 0) lines.push(`| New commands | ${newCommands} |`)
2613
+ if (newlyDeprecatedCommands > 0) lines.push(`| Deprecated commands | ${newlyDeprecatedCommands} |`)
1555
2614
  if (removedCommands > 0) lines.push(`| Removed commands | ${removedCommands} |`)
1556
2615
  if (newFlags > 0) lines.push(`| New flags | ${newFlags} |`)
1557
2616
  if (removedFlags > 0) lines.push(`| Removed flags | ${removedFlags} |`)
1558
- if (changedDescriptions > 0) lines.push(`| Changed descriptions | ${changedDescriptions} |`)
2617
+ if (changedDefaults > 0) lines.push(`| Changed flag defaults | ${changedDefaults} |`)
2618
+ if (changedFlagTypes > 0) lines.push(`| Changed flag types | ${changedFlagTypes} |`)
2619
+ if (changedFlagRequirements > 0) lines.push(`| Changed flag requirements | ${changedFlagRequirements} |`)
2620
+ if (changedFlagDescriptions > 0) lines.push(`| Changed flag descriptions | ${changedFlagDescriptions} |`)
2621
+ if (descriptionChanges > 0) lines.push(`| Changed command descriptions | ${descriptionChanges} |`)
2622
+ if (diffData.summary?.flagDataBackfilled > 0) lines.push(`| Flag docs backfilled (not new flags) | ${diffData.summary.flagDataBackfilled} commands |`)
1559
2623
  }
1560
2624
  lines.push('')
1561
2625
 
1562
- // List new commands
1563
- if (diffData.details?.newCommands?.length > 0) {
2626
+ // Collapsible list helper: itemized up to a cap, with an overflow note
2627
+ const pushDetailsList = (title, items, renderItem) => {
2628
+ if (!items || items.length === 0) return
1564
2629
  lines.push('<details>')
1565
- lines.push('<summary>New Commands</summary>')
2630
+ lines.push(`<summary>${title}</summary>`)
1566
2631
  lines.push('')
1567
- for (const cmd of diffData.details.newCommands.slice(0, 20)) {
1568
- lines.push(`- \`${cmd.path}\``)
2632
+ for (const item of items.slice(0, 30)) {
2633
+ lines.push(renderItem(item))
1569
2634
  }
1570
- if (diffData.details.newCommands.length > 20) {
1571
- lines.push(`- ... and ${diffData.details.newCommands.length - 20} more`)
2635
+ if (items.length > 30) {
2636
+ lines.push(`- ... and ${items.length - 30} more (see the diff JSON in docs-data/)`)
1572
2637
  }
1573
2638
  lines.push('')
1574
2639
  lines.push('</details>')
1575
2640
  lines.push('')
1576
2641
  }
1577
2642
 
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('')
2643
+ const formatValue = (value) => {
2644
+ if (value === undefined) return 'unset'
2645
+ if (value === null) return 'null'
2646
+ if (typeof value === 'object') return JSON.stringify(value)
2647
+ return String(value)
1592
2648
  }
2649
+
2650
+ pushDetailsList('New Commands', diffData.details?.newCommands,
2651
+ cmd => `- \`${cmd.path}\``)
2652
+ pushDetailsList('Deprecated Commands', diffData.details?.newlyDeprecatedCommands,
2653
+ cmd => `- \`${cmd.path}\`${cmd.message ? ` — ${cmd.message}` : ''}${cmd.hidden ? ' _(hidden from help output)_' : ''}`)
2654
+ pushDetailsList('Removed Commands', diffData.details?.removedCommands,
2655
+ cmd => `- \`${cmd.path}\``)
2656
+ pushDetailsList('New Flags', diffData.details?.newFlags,
2657
+ flag => `- \`${flag.commandPath}\`: \`--${flag.flagName}\` (${flag.type})`)
2658
+ pushDetailsList('Removed Flags', diffData.details?.removedFlags,
2659
+ flag => `- \`${flag.commandPath}\`: \`--${flag.flagName}\``)
2660
+ pushDetailsList('Changed Flag Defaults', diffData.details?.changedDefaults,
2661
+ change => `- \`${change.commandPath}\`: \`--${change.flagName}\` default \`${formatValue(change.oldDefault)}\` → \`${formatValue(change.newDefault)}\``)
2662
+ pushDetailsList('Changed Flag Types', diffData.details?.changedFlagTypes,
2663
+ change => `- \`${change.commandPath}\`: \`--${change.flagName}\` type \`${change.oldType}\` → \`${change.newType}\``)
2664
+ pushDetailsList('Changed Flag Requirements', diffData.details?.changedFlagRequirements,
2665
+ change => `- \`${change.commandPath}\`: \`--${change.flagName}\` required \`${change.oldRequired}\` → \`${change.newRequired}\``)
2666
+ pushDetailsList('Changed Command Descriptions', diffData.details?.descriptionChanges,
2667
+ change => `- \`${change.path}\``)
2668
+ }
2669
+
2670
+ // Override validation: entries referencing commands the tree lacks do
2671
+ // nothing silently, so surface them where they become actionable
2672
+ if (overrideValidation && overrideValidation.errors && overrideValidation.errors.length > 0) {
2673
+ lines.push('### Override Validation')
2674
+ lines.push('')
2675
+ 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):`)
2676
+ lines.push('')
2677
+ for (const err of overrideValidation.errors.slice(0, 10)) {
2678
+ lines.push(`- ${typeof err === 'string' ? err : err.message || JSON.stringify(err)}`)
2679
+ }
2680
+ if (overrideValidation.errors.length > 10) {
2681
+ lines.push(`- ... and ${overrideValidation.errors.length - 10} more`)
2682
+ }
2683
+ lines.push('')
2684
+ }
2685
+
2686
+ // Description overrides that hide most of the source help. Curation is
2687
+ // fine, but this is where stale content hides, so keep it reviewable.
2688
+ if (descriptionCoverage && descriptionCoverage.length > 0) {
2689
+ lines.push('### Curated descriptions replacing source help')
2690
+ lines.push('')
2691
+ 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):')
2692
+ lines.push('')
2693
+ lines.push('| Command | Override | Source help |')
2694
+ lines.push('|---------|----------|-------------|')
2695
+ for (const entry of descriptionCoverage.slice(0, 15)) {
2696
+ lines.push(`| \`${entry.commandPath}\` | ${entry.overrideChars} chars | ${entry.sourceChars} chars |`)
2697
+ }
2698
+ if (descriptionCoverage.length > 15) {
2699
+ lines.push(`| ... and ${descriptionCoverage.length - 15} more | | |`)
2700
+ }
2701
+ lines.push('')
1593
2702
  }
1594
2703
 
1595
2704
  // Validation results
@@ -1686,6 +2795,23 @@ module.exports = {
1686
2795
  handleRpkDocsGeneration,
1687
2796
  fetchRpkTreeFromSource,
1688
2797
  fetchRpkTreeFromLinuxSource,
2798
+ acquireRpkBinary,
2799
+ buildRpkBinary,
2800
+ downloadRpkRelease,
2801
+ fetchPluginSubtree,
2802
+ parseCobraFlags,
2803
+ parseUrfaveFlags,
2804
+ parseHelpFlags,
2805
+ mergeVisibleDeprecationsIntoOverrides,
2806
+ makeLinkablePredicate,
2807
+ enrichPluginTreeWithFlags,
2808
+ fetchLatestPluginVersion,
2809
+ pluginNodeHasRealCommands,
2810
+ splicePluginNode,
2811
+ preserveLinuxOnlyCommands,
2812
+ REFRESHABLE_PLUGINS,
2813
+ PLUGIN_INSTALL_VERSION_FLAGS,
2814
+ PLUGIN_MANIFEST_SLUGS,
1689
2815
  getRequiredGoVersion,
1690
2816
  prepareSourceFromRef,
1691
2817
  detectLinuxOnlyFromSource,
@@ -1701,6 +2827,8 @@ module.exports = {
1701
2827
  getPlatformDescription,
1702
2828
  getCurrentPlatform,
1703
2829
  updateOverridesWithIntroducedVersions,
2830
+ computeDescriptionCoverage,
2831
+ updateWhatsNewFile,
1704
2832
  KNOWN_PLUGINS,
1705
2833
  extractCommandPaths,
1706
2834
  detectLinuxOnlyByComparison,