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