@redpanda-data/docs-extensions-and-macros 5.3.0 → 5.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,566 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Static (source-based) detection of Linux-only rpk commands.
5
+ *
6
+ * Works on any platform (including Linux CI runners, where the dynamic
7
+ * "build on Linux and Darwin and diff the trees" approach is impossible
8
+ * because a cross-compiled darwin rpk cannot be executed).
9
+ *
10
+ * How it works
11
+ * ------------
12
+ * rpk gates platform-specific commands with Go build constraints:
13
+ *
14
+ * 1. Whole-package gating: every file in a command's package carries a
15
+ * Linux constraint (explicit `//go:build linux` tag or a `_linux.go`
16
+ * filename suffix) and no darwin-buildable counterpart exists.
17
+ * Example: pkg/cli/iotune, pkg/cli/redpanda/tune.
18
+ *
19
+ * 2. Dual registration: a package provides two variants of the same
20
+ * command constructor, one per platform, registering different
21
+ * subcommand sets. Example: pkg/cli/redpanda/redpanda.go
22
+ * (`//go:build linux`, registers start/stop/check/mode/config/tune)
23
+ * vs pkg/cli/redpanda/redpanda_darwin.go (`//go:build darwin`,
24
+ * registers only the hidden admin command). Same pattern at the root:
25
+ * pkg/cli/root_linux.go vs pkg/cli/root_darwin.go.
26
+ *
27
+ * This module detects both patterns:
28
+ *
29
+ * - For every scanned package, it computes which files build on linux
30
+ * and which build on darwin (from `//go:build` / `// +build` tags AND
31
+ * filename-implied constraints like `_linux.go`).
32
+ * - Packages whose files all fail to build on darwin (but build on
33
+ * linux) and that define a cobra command constructor are Linux-only.
34
+ * - Packages containing platform-differential files get a registration
35
+ * diff: subcommand constructors referenced from linux-buildable files
36
+ * but not from darwin-buildable files are Linux-only. Deprecated and
37
+ * hidden constructors are ignored because `rpk --print-tree` (the
38
+ * basis of the generated docs) excludes hidden commands.
39
+ *
40
+ * Scope: built-in commands only. Plugin commands (rpk connect, rpk ai, ...)
41
+ * are not part of the rpk source tree, so their platform availability
42
+ * cannot be derived here. Plugins keep whatever platform behavior the
43
+ * caller's other detection paths provide (for example, dynamic detection
44
+ * marks plugin-only commands when plugins are only installed in the Linux
45
+ * build container).
46
+ */
47
+
48
+ const fs = require('fs')
49
+ const path = require('path')
50
+
51
+ /** GOOS values recognized in filename suffixes and build tags */
52
+ const KNOWN_GOOS = new Set([
53
+ 'aix', 'android', 'darwin', 'dragonfly', 'freebsd', 'hurd', 'illumos',
54
+ 'ios', 'js', 'linux', 'nacl', 'netbsd', 'openbsd', 'plan9', 'solaris',
55
+ 'wasip1', 'windows', 'zos'
56
+ ])
57
+
58
+ /** GOOS values that satisfy the `unix` build tag */
59
+ const UNIX_GOOS = new Set([
60
+ 'aix', 'android', 'darwin', 'dragonfly', 'freebsd', 'hurd', 'illumos',
61
+ 'ios', 'linux', 'netbsd', 'openbsd', 'solaris'
62
+ ])
63
+
64
+ /** GOARCH values recognized in filename suffixes */
65
+ const KNOWN_GOARCH = new Set([
66
+ '386', 'amd64', 'amd64p32', 'arm', 'arm64', 'arm64be', 'armbe', 'loong64',
67
+ 'mips', 'mips64', 'mips64le', 'mips64p32', 'mips64p32le', 'mipsle',
68
+ 'ppc', 'ppc64', 'ppc64le', 'riscv', 'riscv64', 's390', 's390x',
69
+ 'sparc', 'sparc64', 'wasm'
70
+ ])
71
+
72
+ /**
73
+ * Directories (relative to the rpk source root) scanned for command
74
+ * implementations. Modern rpk keeps commands in pkg/cli/<command>/;
75
+ * the other roots cover older source layouts.
76
+ */
77
+ const SCAN_ROOTS = ['pkg/cli', 'pkg/cli/cmd', 'cmd/rpk']
78
+
79
+ /** Directory names that never correspond to a command path segment */
80
+ const NON_COMMAND_SEGMENTS = new Set(['internal', 'common', 'testdata'])
81
+
82
+ /**
83
+ * Determine the GOOS constraint implied by a Go filename.
84
+ * Follows Go's rules: *_GOOS.go, *_GOOS_GOARCH.go constrain the OS;
85
+ * *_GOARCH.go alone does not.
86
+ * @param {string} fileName - e.g. 'redpanda_darwin.go', 'bundle_k8s_linux.go'
87
+ * @returns {string|null} GOOS name or null when unconstrained
88
+ */
89
+ function goosFromFileName(fileName) {
90
+ const base = fileName.replace(/\.go$/, '')
91
+ const parts = base.split('_')
92
+ if (parts.length < 2) return null
93
+
94
+ const last = parts[parts.length - 1]
95
+ if (KNOWN_GOOS.has(last)) return last
96
+ if (KNOWN_GOARCH.has(last) && parts.length >= 3) {
97
+ const secondLast = parts[parts.length - 2]
98
+ if (KNOWN_GOOS.has(secondLast)) return secondLast
99
+ }
100
+ return null
101
+ }
102
+
103
+ /**
104
+ * Evaluate a `//go:build` constraint expression for a target GOOS.
105
+ * Supports identifiers, !, &&, || and parentheses.
106
+ * Unknown identifiers (custom build tags) evaluate to false, matching a
107
+ * default `go build` with no -tags flag. GOARCH identifiers evaluate to
108
+ * true because we only care about OS-level availability.
109
+ * @param {string} expr - Expression after `//go:build`
110
+ * @param {string} goos - Target GOOS ('linux' or 'darwin')
111
+ * @returns {boolean}
112
+ */
113
+ function evaluateBuildExpr(expr, goos) {
114
+ const tokens = expr.match(/[A-Za-z0-9_.]+|&&|\|\||!|\(|\)/g) || []
115
+ let pos = 0
116
+
117
+ const peek = () => tokens[pos]
118
+ const next = () => tokens[pos++]
119
+
120
+ function evalIdent(ident) {
121
+ if (KNOWN_GOOS.has(ident)) return ident === goos
122
+ if (ident === 'unix') return UNIX_GOOS.has(goos)
123
+ if (KNOWN_GOARCH.has(ident)) return true
124
+ if (ident === 'cgo') return true
125
+ // Custom build tags (e.g. withasan, integration) are unset by default
126
+ return false
127
+ }
128
+
129
+ function parsePrimary() {
130
+ const tok = next()
131
+ if (tok === '(') {
132
+ const val = parseOr()
133
+ if (peek() === ')') next()
134
+ return val
135
+ }
136
+ if (tok === '!') return !parsePrimary()
137
+ if (tok === undefined) return false
138
+ return evalIdent(tok)
139
+ }
140
+
141
+ function parseAnd() {
142
+ let val = parsePrimary()
143
+ while (peek() === '&&') {
144
+ next()
145
+ val = parsePrimary() && val
146
+ }
147
+ return val
148
+ }
149
+
150
+ function parseOr() {
151
+ let val = parseAnd()
152
+ while (peek() === '||') {
153
+ next()
154
+ val = parseAnd() || val
155
+ }
156
+ return val
157
+ }
158
+
159
+ return parseOr()
160
+ }
161
+
162
+ /**
163
+ * Evaluate legacy `// +build` lines for a target GOOS.
164
+ * Multiple lines AND together; within a line, space-separated options OR
165
+ * and comma-separated terms AND.
166
+ * @param {string[]} lines - Contents after `// +build` (one per line)
167
+ * @param {string} goos - Target GOOS
168
+ * @returns {boolean}
169
+ */
170
+ function evaluatePlusBuildLines(lines, goos) {
171
+ const evalTerm = (term) => {
172
+ let negate = false
173
+ while (term.startsWith('!')) {
174
+ negate = !negate
175
+ term = term.slice(1)
176
+ }
177
+ let val
178
+ if (KNOWN_GOOS.has(term)) val = term === goos
179
+ else if (term === 'unix') val = UNIX_GOOS.has(goos)
180
+ else if (KNOWN_GOARCH.has(term) || term === 'cgo') val = true
181
+ else val = false
182
+ return negate ? !val : val
183
+ }
184
+
185
+ return lines.every(line => {
186
+ const options = line.trim().split(/\s+/).filter(Boolean)
187
+ if (options.length === 0) return true
188
+ return options.some(opt => opt.split(',').every(evalTerm))
189
+ })
190
+ }
191
+
192
+ /**
193
+ * Extract the build constraint (if any) from a Go file's header.
194
+ * Only lines before the `package` declaration are considered.
195
+ * @param {string} content - Go file content
196
+ * @returns {{goBuildExpr: string|null, plusBuildLines: string[]}}
197
+ */
198
+ function extractBuildConstraint(content) {
199
+ const header = content.split(/^package\s/m)[0]
200
+ const goBuildMatch = header.match(/^\/\/go:build\s+(.+)$/m)
201
+ const plusBuildLines = []
202
+ const plusBuildRe = /^\/\/\s*\+build\s+(.+)$/gm
203
+ let m
204
+ while ((m = plusBuildRe.exec(header)) !== null) {
205
+ plusBuildLines.push(m[1])
206
+ }
207
+ return {
208
+ goBuildExpr: goBuildMatch ? goBuildMatch[1].trim() : null,
209
+ plusBuildLines
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Determine whether a Go file builds for a target GOOS, taking both the
215
+ * filename-implied constraint and explicit build tags into account.
216
+ * @param {string} fileName - Base name of the file
217
+ * @param {string} content - File content
218
+ * @param {string} goos - Target GOOS ('linux' or 'darwin')
219
+ * @returns {boolean}
220
+ */
221
+ function fileBuildsOn(fileName, content, goos) {
222
+ const fileGoos = goosFromFileName(fileName)
223
+ if (fileGoos && fileGoos !== goos) return false
224
+
225
+ const { goBuildExpr, plusBuildLines } = extractBuildConstraint(content)
226
+ if (goBuildExpr) return evaluateBuildExpr(goBuildExpr, goos)
227
+ if (plusBuildLines.length > 0) return evaluatePlusBuildLines(plusBuildLines, goos)
228
+ return true
229
+ }
230
+
231
+ /**
232
+ * Derive a command name from a cobra constructor function name.
233
+ * NewStartCommand -> start, newTuneCommand -> tune, NewCommand -> null.
234
+ * @param {string} funcName
235
+ * @returns {string|null}
236
+ */
237
+ function commandNameFromConstructor(funcName) {
238
+ const m = funcName.match(/^(?:New|new)(\w+?)(?:Command|Cmd)$/)
239
+ if (m && m[1]) return m[1].toLowerCase()
240
+ return null
241
+ }
242
+
243
+ /**
244
+ * Parse cobra command constructors defined in a Go file.
245
+ * A constructor is any `func XxxYyy(...) *cobra.Command`. For each one we
246
+ * record the cobra `Use:` name (first token) and whether the command is
247
+ * deprecated or hidden (such commands never appear in `rpk --print-tree`).
248
+ * @param {string} content - Go file content
249
+ * @returns {Object<string, {useName: string|null, excluded: boolean}>}
250
+ */
251
+ function parseConstructors(content) {
252
+ const constructors = {}
253
+ const funcRe = /^func\s+(\w+)\s*\([^)]*\)\s*\*cobra\.Command\s*\{/gm
254
+ const matches = []
255
+ let m
256
+ while ((m = funcRe.exec(content)) !== null) {
257
+ matches.push({ name: m[1], start: m.index })
258
+ }
259
+
260
+ for (let i = 0; i < matches.length; i++) {
261
+ const bodyEnd = i + 1 < matches.length ? matches[i + 1].start : content.length
262
+ const body = content.slice(matches[i].start, bodyEnd)
263
+
264
+ const useMatch = body.match(/\bUse:\s*"([^"]+)"/)
265
+ const useName = useMatch ? useMatch[1].trim().split(/\s+/)[0] : null
266
+
267
+ // Deprecated or hidden commands are excluded from `rpk --print-tree`
268
+ const excluded = /\bHidden:\s*true\b/.test(body) ||
269
+ /\bDeprecated:\s*"/.test(body) ||
270
+ /\bDeprecateCmd\s*\(/.test(body)
271
+
272
+ constructors[matches[i].name] = { useName, excluded }
273
+ }
274
+
275
+ return constructors
276
+ }
277
+
278
+ /**
279
+ * Extract constructor references from all AddCommand(...) calls in a file.
280
+ * Handles both same-package (`NewStartCommand(...)`) and cross-package
281
+ * (`tune.NewCommand(...)`) references. Only functions starting with
282
+ * New/new are considered, which skips wrappers like cobraext.DeprecateCmd.
283
+ * @param {string} content - Go file content
284
+ * @returns {Array<{qualifier: string|null, funcName: string}>}
285
+ */
286
+ function parseAddCommandRefs(content) {
287
+ const refs = []
288
+ const addRe = /\bAddCommand\s*\(/g
289
+ let m
290
+ while ((m = addRe.exec(content)) !== null) {
291
+ // Find the span of this AddCommand(...) call by balancing parentheses
292
+ let depth = 1
293
+ let i = m.index + m[0].length
294
+ while (i < content.length && depth > 0) {
295
+ if (content[i] === '(') depth++
296
+ else if (content[i] === ')') depth--
297
+ i++
298
+ }
299
+ const span = content.slice(m.index + m[0].length, i - 1)
300
+
301
+ const callRe = /(?:(\w+)\.)?((?:New|new)\w*)\s*\(/g
302
+ let c
303
+ while ((c = callRe.exec(span)) !== null) {
304
+ refs.push({ qualifier: c[1] || null, funcName: c[2] })
305
+ }
306
+ }
307
+ return refs
308
+ }
309
+
310
+ /**
311
+ * Recursively collect non-test Go files under a directory.
312
+ * @param {string} dir - Absolute directory path
313
+ * @param {Array} out - Accumulator of {absPath, dir, name}
314
+ */
315
+ function collectGoFiles(dir, out) {
316
+ let entries
317
+ try {
318
+ entries = fs.readdirSync(dir, { withFileTypes: true })
319
+ } catch (err) {
320
+ return
321
+ }
322
+ for (const entry of entries) {
323
+ const fullPath = path.join(dir, entry.name)
324
+ if (entry.isDirectory()) {
325
+ if (entry.name === 'testdata' || entry.name === 'vendor') continue
326
+ collectGoFiles(fullPath, out)
327
+ } else if (entry.name.endsWith('.go') && !entry.name.endsWith('_test.go')) {
328
+ out.push({ absPath: fullPath, dir, name: entry.name })
329
+ }
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Build the command path prefix for a package directory.
335
+ * pkg/cli -> 'rpk'; pkg/cli/redpanda -> 'rpk redpanda';
336
+ * pkg/cli/cmd/redpanda (legacy) -> 'rpk redpanda'.
337
+ * @param {string} scanRoot - Absolute path of the scan root
338
+ * @param {string} dir - Absolute directory path
339
+ * @returns {string|null} Command path prefix, or null for non-command dirs
340
+ */
341
+ function commandPrefixForDir(scanRoot, dir) {
342
+ const rel = path.relative(scanRoot, dir)
343
+ let segments = rel === '' ? [] : rel.split(path.sep)
344
+ // Legacy layouts nested commands under an extra cmd/ level
345
+ if (segments[0] === 'cmd') segments = segments.slice(1)
346
+ if (segments.some(s => NON_COMMAND_SEGMENTS.has(s))) return null
347
+ return ['rpk', ...segments].join(' ')
348
+ }
349
+
350
+ /**
351
+ * Analyze one scan root and add detected Linux-only command paths.
352
+ * @param {string} scanRoot - Absolute path of the scan root
353
+ * @param {Set<string>} linuxOnlyCommands - Accumulator
354
+ */
355
+ function scanRootForLinuxOnly(scanRoot, linuxOnlyCommands) {
356
+ const files = []
357
+ collectGoFiles(scanRoot, files)
358
+ if (files.length === 0) return
359
+
360
+ // Group files (with parsed metadata) by directory
361
+ const dirs = new Map()
362
+ for (const file of files) {
363
+ let content
364
+ try {
365
+ content = fs.readFileSync(file.absPath, 'utf8')
366
+ } catch (err) {
367
+ continue
368
+ }
369
+ const info = {
370
+ name: file.name,
371
+ content,
372
+ buildsOnLinux: fileBuildsOn(file.name, content, 'linux'),
373
+ buildsOnDarwin: fileBuildsOn(file.name, content, 'darwin')
374
+ }
375
+ if (!dirs.has(file.dir)) dirs.set(file.dir, [])
376
+ dirs.get(file.dir).push(info)
377
+ }
378
+
379
+ // Per-directory constructor index: dir -> {funcName: {useName, excluded}}
380
+ const constructorIndex = new Map()
381
+ for (const [dir, dirFiles] of dirs) {
382
+ const index = {}
383
+ for (const f of dirFiles) {
384
+ Object.assign(index, parseConstructors(f.content))
385
+ }
386
+ constructorIndex.set(dir, index)
387
+ }
388
+
389
+ /**
390
+ * Resolve an AddCommand constructor reference to a visible command name.
391
+ * Returns null for deprecated/hidden/unresolvable commands.
392
+ */
393
+ const resolveRef = (dir, ref) => {
394
+ let target = null
395
+ let fallback = commandNameFromConstructor(ref.funcName)
396
+
397
+ if (ref.qualifier) {
398
+ const subDir = path.join(dir, ref.qualifier)
399
+ target = constructorIndex.get(subDir) || null
400
+ if (!fallback) fallback = ref.qualifier
401
+ } else {
402
+ target = constructorIndex.get(dir) || null
403
+ }
404
+
405
+ if (target && target[ref.funcName]) {
406
+ const ctor = target[ref.funcName]
407
+ if (ctor.excluded) return null
408
+ return ctor.useName || fallback
409
+ }
410
+
411
+ // Unresolvable reference: only trust it if the function name follows
412
+ // the cobra constructor convention (New[Xxx]Command). This skips
413
+ // helper arguments captured inside AddCommand spans, such as the
414
+ // rp.NewLauncher() argument in redpanda.NewCommand(fs, p, rp.NewLauncher()).
415
+ if (!/^(?:New|new)(?:\w*(?:Command|Cmd))?$/.test(ref.funcName)) return null
416
+ return fallback
417
+ }
418
+
419
+ for (const [dir, dirFiles] of dirs) {
420
+ const prefix = commandPrefixForDir(scanRoot, dir)
421
+ if (!prefix) continue
422
+
423
+ const hasDifferentialFile = dirFiles.some(f => f.buildsOnLinux !== f.buildsOnDarwin)
424
+
425
+ // Pattern 1: whole package is Linux-gated (and defines a command)
426
+ if (prefix !== 'rpk') {
427
+ const anyLinux = dirFiles.some(f => f.buildsOnLinux)
428
+ const anyDarwin = dirFiles.some(f => f.buildsOnDarwin)
429
+ const definesCommand = Object.keys(constructorIndex.get(dir)).length > 0
430
+ if (anyLinux && !anyDarwin && definesCommand) {
431
+ linuxOnlyCommands.add(prefix)
432
+ continue
433
+ }
434
+ }
435
+
436
+ // Pattern 2: dual registration - diff subcommands registered from
437
+ // linux-buildable files vs darwin-buildable files
438
+ if (!hasDifferentialFile) continue
439
+
440
+ const refsFor = (goos) => {
441
+ const names = new Set()
442
+ for (const f of dirFiles) {
443
+ const buildable = goos === 'linux' ? f.buildsOnLinux : f.buildsOnDarwin
444
+ if (!buildable) continue
445
+ for (const ref of parseAddCommandRefs(f.content)) {
446
+ const name = resolveRef(dir, ref)
447
+ if (name) names.add(name)
448
+ }
449
+ }
450
+ return names
451
+ }
452
+
453
+ const linuxRefs = refsFor('linux')
454
+ const darwinRefs = refsFor('darwin')
455
+
456
+ for (const name of linuxRefs) {
457
+ if (!darwinRefs.has(name)) {
458
+ linuxOnlyCommands.add(`${prefix} ${name}`)
459
+ }
460
+ }
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Detect Linux-only rpk commands by statically analyzing Go source.
466
+ * Works on any platform, including Linux CI runners.
467
+ * @param {string} sourcePath - Path to the rpk Go source root (src/go/rpk)
468
+ * @returns {Set<string>} Linux-only command paths (e.g. 'rpk iotune').
469
+ * Descendants of a returned path are implicitly Linux-only too.
470
+ */
471
+ function detectLinuxOnlyFromSource(sourcePath) {
472
+ const linuxOnlyCommands = new Set()
473
+
474
+ const scannedRoots = new Set()
475
+ for (const root of SCAN_ROOTS) {
476
+ const absRoot = path.join(sourcePath, root)
477
+ if (!fs.existsSync(absRoot)) continue
478
+ // pkg/cli/cmd is nested inside pkg/cli; avoid scanning it twice
479
+ if ([...scannedRoots].some(r => absRoot.startsWith(r + path.sep))) continue
480
+ scannedRoots.add(absRoot)
481
+ scanRootForLinuxOnly(absRoot, linuxOnlyCommands)
482
+ }
483
+
484
+ // Drop any detected path that is a descendant of another detected path;
485
+ // platform markers already propagate to descendants.
486
+ for (const cmd of [...linuxOnlyCommands]) {
487
+ if ([...linuxOnlyCommands].some(other => other !== cmd && cmd.startsWith(other + ' '))) {
488
+ linuxOnlyCommands.delete(cmd)
489
+ }
490
+ }
491
+
492
+ return linuxOnlyCommands
493
+ }
494
+
495
+ /**
496
+ * List Go files under the scanned roots that carry a Linux-only build
497
+ * constraint (explicit tag or filename suffix). Used as a tripwire: if
498
+ * detection returns an empty set while this list is non-empty, the scan
499
+ * is almost certainly broken (wrong directory layout, changed tag style).
500
+ * @param {string} sourcePath - Path to the rpk Go source root
501
+ * @returns {string[]} Relative paths of Linux-constrained files
502
+ */
503
+ function findLinuxConstrainedFiles(sourcePath) {
504
+ const results = []
505
+ for (const root of SCAN_ROOTS) {
506
+ const absRoot = path.join(sourcePath, root)
507
+ if (!fs.existsSync(absRoot)) continue
508
+ const files = []
509
+ collectGoFiles(absRoot, files)
510
+ for (const file of files) {
511
+ let content
512
+ try {
513
+ content = fs.readFileSync(file.absPath, 'utf8')
514
+ } catch (err) {
515
+ continue
516
+ }
517
+ if (fileBuildsOn(file.name, content, 'linux') && !fileBuildsOn(file.name, content, 'darwin')) {
518
+ results.push(path.relative(sourcePath, file.absPath))
519
+ }
520
+ }
521
+ }
522
+ return [...new Set(results)]
523
+ }
524
+
525
+ /**
526
+ * Emit a loud warning when platform detection came back empty even though
527
+ * the source demonstrably contains Linux-gated files. Never let a scan
528
+ * failure silently mark every command as cross-platform.
529
+ * @param {string} sourcePath - Path to the rpk Go source root
530
+ * @param {Set<string>} linuxOnlyCommands - Combined detection result
531
+ * @returns {boolean} True when the tripwire fired
532
+ */
533
+ function warnIfDetectionLooksBroken(sourcePath, linuxOnlyCommands) {
534
+ if (linuxOnlyCommands.size > 0) return false
535
+
536
+ const constrained = findLinuxConstrainedFiles(sourcePath)
537
+ if (constrained.length === 0) return false
538
+
539
+ console.warn('\n' + '!'.repeat(70))
540
+ console.warn('⚠ PLATFORM DETECTION TRIPWIRE: no Linux-only commands were detected,')
541
+ console.warn(`⚠ but the rpk source contains ${constrained.length} Linux-constrained file(s), e.g.:`)
542
+ for (const file of constrained.slice(0, 5)) {
543
+ console.warn(`⚠ - ${file}`)
544
+ }
545
+ console.warn('⚠ This usually means the static source scan failed (unexpected')
546
+ console.warn('⚠ directory layout or build-tag style) and every command is about')
547
+ console.warn('⚠ to be marked as available on both Linux and macOS, which is wrong.')
548
+ console.warn('⚠ Fix tools/rpk-docs/detect-platform-commands.js before publishing')
549
+ console.warn('⚠ these docs. See redpanda-data/docs#1831 for the impact of this.')
550
+ console.warn('!'.repeat(70) + '\n')
551
+ return true
552
+ }
553
+
554
+ module.exports = {
555
+ detectLinuxOnlyFromSource,
556
+ findLinuxConstrainedFiles,
557
+ warnIfDetectionLooksBroken,
558
+ // Exported for unit tests
559
+ fileBuildsOn,
560
+ goosFromFileName,
561
+ evaluateBuildExpr,
562
+ evaluatePlusBuildLines,
563
+ parseConstructors,
564
+ parseAddCommandRefs,
565
+ commandNameFromConstructor
566
+ }
@@ -291,6 +291,24 @@ function shouldUsePartialDir(overrides, commandPath) {
291
291
  return false
292
292
  }
293
293
 
294
+ /**
295
+ * Check if a command belongs to the rpk cloud or rpk security secret families.
296
+ * Their pages are routed to the cloud partials directory (cloudSecretDir) and
297
+ * published by cloud-docs, so links to them from regular pages must cross to
298
+ * the cloud component.
299
+ * @param {string} commandPath - Full command path (e.g., "rpk cloud login")
300
+ * @returns {boolean}
301
+ */
302
+ function isCloudSecretCommand(commandPath) {
303
+ return commandPath === 'rpk cloud' || commandPath.startsWith('rpk cloud ') ||
304
+ commandPath === 'rpk security secret' || commandPath.startsWith('rpk security secret ')
305
+ }
306
+
307
+ /**
308
+ * Antora component that publishes the rpk cloud and rpk security secret pages.
309
+ */
310
+ const CLOUD_DOCS_COMPONENT = 'cloud-data-platform'
311
+
294
312
  /**
295
313
  * Get command metadata from overrides
296
314
  * @param {Object} overrides - Overrides object (resolved)
@@ -2420,8 +2438,7 @@ function updateNavFile(navFile, commands, resolvedOverrides, topLevelWithSubcomm
2420
2438
  if (isProtectedCommandPath(commandPath, protectedPlugins)) continue
2421
2439
  if (shouldExcludeCommand(resolvedOverrides, commandPath)) continue
2422
2440
  if (shouldUsePartialDir(resolvedOverrides, commandPath)) continue
2423
- if (commandPath.startsWith('rpk cloud')) continue
2424
- if (commandPath.startsWith('rpk security secret')) continue
2441
+ if (isCloudSecretCommand(commandPath)) continue
2425
2442
 
2426
2443
  const parts = commandPath.split(' ')
2427
2444
  const stars = '*'.repeat(parts.length + 1)
@@ -2725,16 +2742,13 @@ async function generateRpkDocs(options = {}) {
2725
2742
  // Build subcommands with correct xref paths
2726
2743
  // Filter out excluded and asPartial subcommands — excluded have no file,
2727
2744
  // asPartial ones live in the partials directory with no linkable xref.
2728
- // rpk cloud and rpk security secret are hardcoded-routed to partials
2729
- // (single-sourced into cloud docs), so they have no linkable pages
2730
- // either: rpk-security.adoc linking rpk-security-secret.adoc was a
2731
- // broken xref in the published site.
2745
+ // rpk cloud and rpk security secret rows stay in the table: their pages
2746
+ // are routed to the cloud partials directory and published by
2747
+ // cloud-docs, so the xref below links across to the cloud component
2748
+ // instead of dropping the row (which hid the subcommand entirely).
2732
2749
  const subcommands = (command.commands || [])
2733
2750
  .filter(sub => {
2734
2751
  const subPath = `${commandPath} ${sub.name}`
2735
- if (subPath.startsWith('rpk cloud') || subPath.startsWith('rpk security secret')) {
2736
- return false
2737
- }
2738
2752
  return !shouldExcludeCommand(resolvedOverrides, subPath) && !shouldUsePartialDir(resolvedOverrides, subPath)
2739
2753
  })
2740
2754
  .map(sub => {
@@ -2756,6 +2770,13 @@ async function generateRpkDocs(options = {}) {
2756
2770
  }
2757
2771
  }
2758
2772
 
2773
+ // rpk cloud and rpk security secret pages are routed to the cloud
2774
+ // partials directory and published by cloud-docs, so rows in regular
2775
+ // pages must link across to the cloud component.
2776
+ if (cloudSecretDir && isCloudSecretCommand(subPath) && !isCloudSecretCommand(commandPath)) {
2777
+ xrefPath = `${CLOUD_DOCS_COMPONENT}:${xrefPath}`
2778
+ }
2779
+
2759
2780
  // Use overridden description if available
2760
2781
  const subOverride = resolvedOverrides?.commands?.[subPath]
2761
2782
  const subDescription = subOverride?.description || sub.description || ''
@@ -2765,6 +2786,8 @@ async function generateRpkDocs(options = {}) {
2765
2786
  fullPath: subPath,
2766
2787
  dashifiedPath: dashifiedSubPath,
2767
2788
  xrefPath,
2789
+ cloudOnly: subOverride?.cloudOnly === true,
2790
+ selfHostedOnly: subOverride?.selfHostedOnly === true,
2768
2791
  shortDesc: ensurePeriod(capToTwoSentences(formatDescription(subDescription, textTransformations, { skipTableConversion: true, skipListConversion: true })))
2769
2792
  }
2770
2793
  })
@@ -2949,10 +2972,8 @@ async function generateRpkDocs(options = {}) {
2949
2972
 
2950
2973
  // Determine output path
2951
2974
  // Check if this command should go to cloudSecretDir
2952
- const isCloudCommand = commandPath.startsWith('rpk cloud')
2953
- const isSecuritySecretCommand = commandPath.startsWith('rpk security secret')
2954
2975
  const useCloudSecretDir = cloudSecretDir && (
2955
- isCloudCommand || isSecuritySecretCommand || shouldUsePartialDir(resolvedOverrides, commandPath)
2976
+ isCloudSecretCommand(commandPath) || shouldUsePartialDir(resolvedOverrides, commandPath)
2956
2977
  )
2957
2978
  const effectiveOutputDir = useCloudSecretDir ? cloudSecretDir : outputDir
2958
2979