@redpanda-data/docs-extensions-and-macros 4.4.1 → 4.5.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.js CHANGED
@@ -1,10 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  const { execSync, spawnSync } = require('child_process');
4
+ const os = require('os');
4
5
  const { Command } = require('commander');
5
6
  const path = require('path');
6
7
  const fs = require('fs');
7
-
8
+ const {determineDocsBranch} = require('../cli-utils/self-managed-docs-branch.js')
9
+ const fetchFromGithub = require('../tools/fetch-from-github.js');
10
+ const { urlToXref } = require('../cli-utils/convert-doc-links.js');
11
+
12
+
13
+ /**
14
+ * Searches upward from a starting directory to locate the repository root.
15
+ *
16
+ * Traverses parent directories from the specified start path, returning the first directory containing either a `.git` folder or a `package.json` file. Exits the process with an error if no such directory is found.
17
+ *
18
+ * @param {string} [start] - The directory to begin the search from. Defaults to the current working directory.
19
+ * @returns {string} The absolute path to the repository root directory.
20
+ */
8
21
  function findRepoRoot(start = process.cwd()) {
9
22
  let dir = start;
10
23
  while (dir !== path.parse(dir).root) {
@@ -21,97 +34,221 @@ function findRepoRoot(start = process.cwd()) {
21
34
 
22
35
  // --------------------------------------------------------------------
23
36
  // Dependency check functions
24
- // --------------------------------------------------------------------
25
- function checkDependency(command, versionArg, name, helpURL) {
26
- try {
27
- execSync(`${command} ${versionArg}`, { stdio: 'ignore' });
28
- } catch (error) {
29
- console.error(`Error: ${name} is required but not found or not working properly.
30
- Please install ${name} and try again.
31
- For more info, see: ${helpURL}`);
32
- process.exit(1);
33
- }
37
+ /**
38
+ * Prints an error message to stderr and exits the process with a non-zero status.
39
+ *
40
+ * @param {string} msg - The error message to display before exiting.
41
+ */
42
+ function fail(msg) {
43
+ console.error(`❌ ${msg}`);
44
+ process.exit(1);
34
45
  }
35
46
 
36
- function checkCommandExists(command) {
47
+ /**
48
+ * Ensures that a specified command-line tool is installed and operational.
49
+ *
50
+ * Attempts to execute the tool with a version flag to verify its presence. If the tool is missing or fails to run, the process exits with an error message and optional installation hint.
51
+ *
52
+ * @param {string} cmd - The name of the tool to check (e.g., 'docker', 'helm-docs').
53
+ * @param {object} [opts] - Optional settings.
54
+ * @param {string} [opts.versionFlag='--version'] - The flag used to test the tool's execution.
55
+ * @param {string} [opts.help] - An optional hint or installation instruction shown on failure.
56
+ */
57
+ function requireTool(cmd, { versionFlag = '--version', help = '' } = {}) {
37
58
  try {
38
- execSync(`which ${command}`, { stdio: 'ignore' });
39
- return true;
40
- } catch (error) {
41
- console.error(`Error: \`${command}\` is required but not found. Please install \`${command}\` and try again.`);
42
- return false;
59
+ execSync(`${cmd} ${versionFlag}`, { stdio: 'ignore' });
60
+ } catch {
61
+ const hint = help ? `\n→ ${help}` : '';
62
+ fail(`'${cmd}' is required but not found.${hint}`);
43
63
  }
44
64
  }
45
65
 
46
- function checkMake() {
47
- if (!checkCommandExists('make')) {
48
- console.error('Error: `make` is required but not found. Please install `make` to use the automation Makefile. For help, see: https://www.google.com/search?q=how+to+install+make');
49
- process.exit(1);
50
- }
66
+ /**
67
+ * Ensures that a command-line tool is installed by checking if it responds to a specified flag.
68
+ *
69
+ * @param {string} cmd - The name of the command-line tool to check.
70
+ * @param {string} [help] - Optional help text to display if the tool is not found.
71
+ * @param {string} [versionFlag='--help'] - The flag to use when checking if the tool is installed.
72
+ *
73
+ * @throws {Error} If the specified command is not found or does not respond to the specified flag.
74
+ */
75
+ function requireCmd(cmd, help, versionFlag = '--version') {
76
+ requireTool(cmd, { versionFlag, help });
51
77
  }
52
78
 
53
- function checkPython() {
54
- const candidates = ['python3', 'python'];
55
- let found = false;
56
79
 
57
- for (const cmd of candidates) {
80
+ // --------------------------------------------------------------------
81
+ // Special validators
82
+ /**
83
+ * Ensures that Python with a minimum required version is installed and available in the system PATH.
84
+ *
85
+ * Checks for either `python3` or `python` executables and verifies that the version is at least the specified minimum (default: 3.10). Exits the process with an error message if the requirement is not met.
86
+ *
87
+ * @param {number} [minMajor=3] - Minimum required major version of Python.
88
+ * @param {number} [minMinor=10] - Minimum required minor version of Python.
89
+ */
90
+
91
+ function requirePython(minMajor = 3, minMinor = 10) {
92
+ const candidates = ['python3', 'python'];
93
+ for (const p of candidates) {
58
94
  try {
59
- const versionOutput = execSync(`${cmd} --version`, {
60
- encoding: 'utf8',
61
- stdio: ['pipe', 'pipe', 'ignore']
62
- }).trim();
63
- // versionOutput looks like "Python 3.x.y"
64
- const versionString = versionOutput.split(' ')[1];
65
- const [major, minor] = versionString.split('.').map(Number);
66
- if (major > 3 || (major === 3 && minor >= 10)) {
67
- found = true;
68
- break;
69
- } else {
70
- console.error(`Error: Python 3.10 or higher is required. Detected version: ${versionString}`);
71
- process.exit(1);
95
+ const out = execSync(`${p} --version`, { encoding: 'utf8' }).trim();
96
+ const [maj, min] = out.split(' ')[1].split('.').map(Number);
97
+ if (maj > minMajor || (maj === minMajor && min >= minMinor)) {
98
+ return; // success
72
99
  }
73
100
  } catch {
74
- // this candidate didn’t exist or errored—try the next one
101
+ /* ignore & try next */
75
102
  }
76
103
  }
77
- if (!found) {
78
- console.error('Error: Python 3.10 or higher is required but not found.\nPlease install Python and ensure `python3 --version` or `python --version` returns at least 3.10: https://www.geeksforgeeks.org/how-to-install-python-on-mac/');
79
- process.exit(1);
80
- }
104
+ fail(`Python ${minMajor}.${minMinor}+ not found or too old.
105
+ Install from your package manager or https://python.org`);
81
106
  }
82
107
 
83
- function checkCompiler() {
84
- const gccInstalled = checkCommandExists('gcc');
85
- const clangInstalled = checkCommandExists('clang');
86
- if (!gccInstalled && !clangInstalled) {
87
- console.error('Error: A C++ compiler (such as gcc or clang) is required but not found. Please install one: https://osxdaily.com/2023/05/02/how-install-gcc-mac/');
88
- process.exit(1);
108
+ /**
109
+ * Ensures that the Docker CLI is installed and the Docker daemon is running.
110
+ *
111
+ * @throws {Error} If Docker is not installed or the Docker daemon is not running.
112
+ */
113
+ function requireDockerDaemon() {
114
+ requireTool('docker', { help: 'https://docs.docker.com/get-docker/' });
115
+ try {
116
+ execSync('docker info', { stdio: 'ignore' });
117
+ } catch {
118
+ fail('Docker daemon does not appear to be running. Please start Docker.');
89
119
  }
90
120
  }
91
121
 
92
- function checkDocker() {
93
- checkDependency('docker', '--version', 'Docker', 'https://docs.docker.com/get-docker/');
94
- try {
95
- execSync('docker info', { stdio: 'ignore' });
96
- } catch (error) {
97
- console.error('Error: Docker daemon appears to be not running. Please start Docker.');
98
- process.exit(1);
122
+ // --------------------------------------------------------------------
123
+ // Grouped checks
124
+ /**
125
+ * Ensures that required dependencies for generating CRD documentation are installed.
126
+ *
127
+ * Verifies the presence of the {@link git} and {@link crd-ref-docs} command-line tools, exiting the process with an error message if either is missing.
128
+ */
129
+
130
+ function verifyCrdDependencies() {
131
+ requireCmd('git', 'Install Git: https://git-scm.com/downloads');
132
+ requireCmd(
133
+ 'crd-ref-docs',
134
+ `
135
+ The 'crd-ref-docs' command is required but was not found.
136
+
137
+ To install it, follow these steps (for macOS):
138
+
139
+ 1. Determine your architecture:
140
+ Run: \`uname -m\`
141
+
142
+ 2. Download and install:
143
+
144
+ - For Apple Silicon (M1/M2/M3):
145
+ curl -fLO https://github.com/elastic/crd-ref-docs/releases/download/v0.1.0/crd-ref-docs_0.1.0_Darwin_arm64.tar.gz
146
+ tar -xzf crd-ref-docs_0.1.0_Darwin_arm64.tar.gz
147
+ chmod +x crd-ref-docs
148
+ sudo mv crd-ref-docs /usr/local/bin/
149
+
150
+ - For Intel (x86_64):
151
+ curl -fLO https://github.com/elastic/crd-ref-docs/releases/download/v0.1.0/crd-ref-docs_0.1.0_Darwin_x86_64.tar.gz
152
+ tar -xzf crd-ref-docs_0.1.0_Darwin_x86_64.tar.gz
153
+ chmod +x crd-ref-docs
154
+ sudo mv crd-ref-docs /usr/local/bin/
155
+
156
+ For more details, visit: https://github.com/elastic/crd-ref-docs
157
+ `.trim()
158
+ );
159
+ requireCmd(
160
+ 'go',
161
+ `
162
+ The 'go' command (Golang) is required but was not found.
163
+
164
+ To install it on macOS:
165
+
166
+ Option 1: Install via Homebrew (recommended):
167
+ brew install go
168
+
169
+ Option 2: Download directly from the official site:
170
+ 1. Visit: https://go.dev/dl/
171
+ 2. Download the appropriate installer for macOS.
172
+ 3. Run the installer and follow the instructions.
173
+
174
+ After installation, verify it works:
175
+ go version
176
+
177
+ For more details, see: https://go.dev/doc/install
178
+ `.trim(),
179
+ 'version'
180
+ );
181
+
99
182
  }
183
+
184
+ /**
185
+ * Ensures that all required tools for Helm documentation generation are installed.
186
+ *
187
+ * Checks for the presence of `helm-docs`, `pandoc`, and `git`, exiting the process with an error if any are missing.
188
+ */
189
+ function verifyHelmDependencies() {
190
+ requireCmd(
191
+ 'helm-docs',
192
+ `
193
+ The 'helm-docs' command is required but was not found.
194
+
195
+ To install it, follow these steps (for macOS):
196
+
197
+ 1. Determine your architecture:
198
+ Run: \`uname -m\`
199
+
200
+ 2. Download and install:
201
+
202
+ - For Apple Silicon (M1/M2/M3):
203
+ curl -fLO https://github.com/norwoodj/helm-docs/releases/download/v1.11.0/helm-docs_1.11.0_Darwin_arm64.tar.gz
204
+ tar -xzf helm-docs_1.11.0_Darwin_arm64.tar.gz
205
+ chmod +x helm-docs
206
+ sudo mv helm-docs /usr/local/bin/
207
+
208
+ - For Intel (x86_64):
209
+ curl -fLO https://github.com/norwoodj/helm-docs/releases/download/v1.11.0/helm-docs_1.11.0_Darwin_x86_64.tar.gz
210
+ tar -xzf helm-docs_1.11.0_Darwin_x86_64.tar.gz
211
+ chmod +x helm-docs
212
+ sudo mv helm-docs /usr/local/bin/
213
+
214
+ Alternatively, if you use Homebrew:
215
+ brew install norwoodj/tap/helm-docs
216
+
217
+ For more details, visit: https://github.com/norwoodj/helm-docs
218
+ `.trim()
219
+ );
220
+ requireCmd('pandoc', 'brew install pandoc or https://pandoc.org');
221
+ requireCmd('git', 'install Git: https://git-scm.com/downloads');
100
222
  }
101
223
 
224
+ /**
225
+ * Ensures all dependencies required for generating property documentation are installed.
226
+ *
227
+ * Checks for the presence of `make`, Python 3.10 or newer, and at least one C++ compiler (`gcc` or `clang`). Exits the process with an error message if any dependency is missing.
228
+ */
102
229
  function verifyPropertyDependencies() {
103
- checkMake();
104
- checkPython();
105
- checkCompiler();
230
+ requireCmd('make', 'your OS package manager');
231
+ requirePython();
232
+ // at least one compiler:
233
+ try { execSync('gcc --version', { stdio: 'ignore' }); }
234
+ catch {
235
+ try { execSync('clang --version', { stdio: 'ignore' }); }
236
+ catch { fail('A C++ compiler (gcc or clang) is required.'); }
237
+ }
106
238
  }
107
239
 
240
+ /**
241
+ * Ensures all required dependencies for generating Redpanda metrics documentation are installed.
242
+ *
243
+ * Verifies that Python 3.10+, `curl`, and `tar` are available, and that the Docker daemon is running.
244
+ *
245
+ * @throws {Error} If any required dependency is missing or the Docker daemon is not running.
246
+ */
108
247
  function verifyMetricsDependencies() {
109
- checkPython();
110
- if (!checkCommandExists('curl') || !checkCommandExists('tar')) {
111
- // `checkCommandExists` already prints a helpful message.
112
- process.exit(1);
113
- }
114
- checkDocker();
248
+ requirePython();
249
+ requireCmd('curl');
250
+ requireCmd('tar');
251
+ requireDockerDaemon();
115
252
  }
116
253
  // --------------------------------------------------------------------
117
254
  // Main CLI Definition
@@ -121,7 +258,7 @@ const programCli = new Command();
121
258
  programCli
122
259
  .name('doc-tools')
123
260
  .description('Redpanda Document Automation CLI')
124
- .version('1.0.1');
261
+ .version('1.1.0');
125
262
 
126
263
  // Top-level commands.
127
264
  programCli
@@ -161,17 +298,79 @@ programCli
161
298
  }
162
299
  });
163
300
 
301
+ programCli
302
+ .command('link-readme')
303
+ .description('Symlink a README.adoc into docs/modules/<module>/pages/')
304
+ .requiredOption('-s, --subdir <subdir>', 'Relative path to the lab project subdirectory')
305
+ .requiredOption('-t, --target <filename>', 'Name of the target AsciiDoc file in pages/')
306
+ .action((options) => {
307
+ const repoRoot = findRepoRoot();
308
+ const normalized = options.subdir.replace(/\/+$/, '');
309
+ const moduleName = normalized.split('/')[0];
310
+
311
+ const projectDir = path.join(repoRoot, normalized);
312
+ const pagesDir = path.join(repoRoot, 'docs', 'modules', moduleName, 'pages');
313
+ const sourceFile = path.join(projectDir, 'README.adoc');
314
+ const destLink = path.join(pagesDir, options.target);
315
+
316
+ if (!fs.existsSync(projectDir)) {
317
+ console.error(`❌ Project directory not found: ${projectDir}`);
318
+ process.exit(1);
319
+ }
320
+ if (!fs.existsSync(sourceFile)) {
321
+ console.error(`❌ README.adoc not found in ${projectDir}`);
322
+ process.exit(1);
323
+ }
324
+
325
+ fs.mkdirSync(pagesDir, { recursive: true });
326
+ const relPath = path.relative(pagesDir, sourceFile);
327
+
328
+ try {
329
+ if (fs.existsSync(destLink)) {
330
+ const stat = fs.lstatSync(destLink);
331
+ if (stat.isSymbolicLink()) fs.unlinkSync(destLink);
332
+ else fail(`Destination already exists and is not a symlink: ${destLink}`);
333
+ }
334
+ fs.symlinkSync(relPath, destLink);
335
+ console.log(`✔️ Linked ${relPath} → ${destLink}`);
336
+ } catch (err) {
337
+ fail(`Failed to create symlink: ${err.message}`);
338
+ }
339
+ });
340
+
341
+ programCli
342
+ .command('fetch')
343
+ .description('Fetch a file or directory from GitHub and save it locally')
344
+ .requiredOption('-o, --owner <owner>', 'GitHub repo owner or org')
345
+ .requiredOption('-r, --repo <repo>', 'GitHub repo name')
346
+ .requiredOption('-p, --remote-path <path>', 'Path in the repo to fetch')
347
+ .requiredOption('-d, --save-dir <dir>', 'Local directory to save into')
348
+ .option('-f, --filename <name>', 'Custom filename to save as')
349
+ .action(async (options) => {
350
+ try {
351
+ await fetchFromGithub(
352
+ options.owner,
353
+ options.repo,
354
+ options.remotePath,
355
+ options.saveDir,
356
+ options.filename
357
+ );
358
+ } catch (err) {
359
+ console.error('❌', err.message);
360
+ process.exit(1);
361
+ }
362
+ });
363
+
164
364
  // Create an "automation" subcommand group.
165
365
  const automation = new Command('generate')
166
- .description('Run docs automations (properties, metrics, and rpk docs generation)');
366
+ .description('Run docs automations (properties, metrics, rpk docs, and Kubernetes)');
167
367
 
168
368
  // --------------------------------------------------------------------
169
- // Automation Subcommands: Delegate to a unified Bash script internally.
369
+ // Automation subcommands
170
370
  // --------------------------------------------------------------------
171
371
 
172
372
  // Common options for both automation tasks.
173
373
  const commonOptions = {
174
- tag: 'latest',
175
374
  dockerRepo: 'redpanda',
176
375
  consoleTag: 'latest',
177
376
  consoleDockerRepo: 'console'
@@ -215,11 +414,12 @@ function diffDirs(kind, oldTag, newTag) {
215
414
 
216
415
  automation
217
416
  .command('metrics-docs')
218
- .description('Extract Redpanda metrics and generate JSON/AsciiDoc docs')
219
- .option('--tag <tag>', 'Redpanda tag (default: latest)', commonOptions.tag)
220
- .option('--docker-repo <repo>', '...', commonOptions.dockerRepo)
221
- .option('--console-tag <tag>', '...', commonOptions.consoleTag)
222
- .option('--console-docker-repo <repo>', '...', commonOptions.consoleDockerRepo)
417
+ .description('Generate JSON and AsciiDoc documentation for Redpanda metrics')
418
+ .requiredOption('-t, --tag <tag>',
419
+ 'Redpanda version to use when starting Redpanda in Docker')
420
+ .option('--docker-repo <repo>', 'Docker repository to use when starting Redpanda in Docker', commonOptions.dockerRepo)
421
+ .option('--console-tag <tag>', 'Redpanda Console version to use when starting Redpanda Console in Docker', commonOptions.consoleTag)
422
+ .option('--console-docker-repo <repo>', 'Docker repository to use when starting Redpanda Console in Docker', commonOptions.consoleDockerRepo)
223
423
  .option('--diff <oldTag>', 'Also diff autogenerated metrics from <oldTag> → <tag>')
224
424
  .action((options) => {
225
425
  verifyMetricsDependencies();
@@ -247,8 +447,8 @@ automation
247
447
 
248
448
  automation
249
449
  .command('property-docs')
250
- .description('Extract properties from Redpanda source')
251
- .option('--tag <tag>', 'Git tag or branch to extract from (default: dev)', 'dev')
450
+ .description('Generate JSON and AsciiDoc documentation for Redpanda configuration properties')
451
+ .option('--tag <tag>', 'Git tag or branch to extract from', 'dev')
252
452
  .option('--diff <oldTag>', 'Also diff autogenerated properties from <oldTag> → <tag>')
253
453
  .action((options) => {
254
454
  verifyPropertyDependencies();
@@ -279,11 +479,12 @@ automation
279
479
 
280
480
  automation
281
481
  .command('rpk-docs')
282
- .description('Generate documentation for rpk commands')
283
- .option('--tag <tag>', 'Redpanda tag (default: latest)', commonOptions.tag)
284
- .option('--docker-repo <repo>', '...', commonOptions.dockerRepo)
285
- .option('--console-tag <tag>', '...', commonOptions.consoleTag)
286
- .option('--console-docker-repo <repo>', '...', commonOptions.consoleDockerRepo)
482
+ .description('Generate AsciiDoc documentation for rpk CLI commands')
483
+ .requiredOption('-t, --tag <tag>',
484
+ 'Redpanda version to use when starting Redpanda in Docker')
485
+ .option('--docker-repo <repo>', 'Docker repository to use when starting Redpanda in Docker', commonOptions.dockerRepo)
486
+ .option('--console-tag <tag>', 'Redpanda Console version to use when starting Redpanda Console in Docker', commonOptions.consoleTag)
487
+ .option('--console-docker-repo <repo>', 'Docker repository to use when starting Redpanda Console in Docker', commonOptions.consoleDockerRepo)
287
488
  .option('--diff <oldTag>', 'Also diff autogenerated rpk docs from <oldTag> → <tag>')
288
489
  .action((options) => {
289
490
  verifyMetricsDependencies();
@@ -309,64 +510,317 @@ automation
309
510
  process.exit(0);
310
511
  });
311
512
 
312
- programCli
313
- .command('link-readme <subdir> <targetFilename>')
314
- .description('Symlink a README.adoc into docs/modules/<module>/pages/')
315
- .action((subdir, targetFilename) => {
316
- const repoRoot = findRepoRoot();
317
- const normalized = subdir.replace(/\/+$/, '');
318
- const moduleName = normalized.split('/')[0];
513
+ automation
514
+ .command('helm-spec')
515
+ .description(`Generate AsciiDoc documentation for one or more Helm charts (supports local dirs or GitHub URLs)`)
516
+ .option(
517
+ '--chart-dir <dir|url>',
518
+ 'Chart directory (contains Chart.yaml) or a root containing multiple charts, or a GitHub URL',
519
+ 'https://github.com/redpanda-data/redpanda-operator/charts'
520
+ )
521
+ .requiredOption('-t, --tag <tag>',
522
+ 'Branch or tag to clone when using a GitHub URL for the chart-dir')
523
+ .option(
524
+ '--readme <file>',
525
+ 'Relative README.md path inside each chart dir',
526
+ 'README.md'
527
+ )
528
+ .option(
529
+ '--output-dir <dir>',
530
+ 'Where to write all generated AsciiDoc files',
531
+ 'modules/reference/pages'
532
+ )
533
+ .option(
534
+ '--output-suffix <suffix>',
535
+ 'Suffix to append to each chart name (including extension)',
536
+ '-helm-spec.adoc'
537
+ )
538
+ .action(opts => {
539
+ verifyHelmDependencies()
540
+
541
+ // Prepare chart-root (local or GitHub) ───────────────────────
542
+ let root = opts.chartDir
543
+ let tmpClone = null
544
+
545
+ if (/^https?:\/\/github\.com\//.test(root)) {
546
+ if (!opts.tag) {
547
+ console.error('❌ When using a GitHub URL you must pass --tag')
548
+ process.exit(1)
549
+ }
550
+ const u = new URL(root)
551
+ const parts = u.pathname.replace(/\.git$/, '').split('/').filter(Boolean)
552
+ if (parts.length < 2) {
553
+ console.error(`❌ Invalid GitHub URL: ${root}`)
554
+ process.exit(1)
555
+ }
556
+ const [owner, repo, ...sub] = parts
557
+ const repoUrl = `https://${u.host}/${owner}/${repo}.git`
558
+ const ref = opts.tag
559
+
560
+ console.log(`🔎 Verifying ${repoUrl}@${ref}…`)
561
+ const ok = spawnSync('git', [
562
+ 'ls-remote','--exit-code', repoUrl,
563
+ `refs/heads/${ref}`, `refs/tags/${ref}`
564
+ ], { stdio:'ignore' }).status === 0
565
+ if (!ok) {
566
+ console.error(`❌ ${ref} not found on ${repoUrl}`)
567
+ process.exit(1)
568
+ }
319
569
 
320
- const projectDir = path.join(repoRoot, normalized);
321
- const pagesDir = path.join(repoRoot, 'docs', 'modules', moduleName, 'pages');
322
- const sourceFile = path.join(repoRoot, normalized, 'README.adoc');
323
- const destLink = path.join(pagesDir, targetFilename);
570
+ tmpClone = fs.mkdtempSync(path.join(os.tmpdir(), 'helm-'))
571
+ console.log(`⏳ Cloning ${repoUrl}@${ref} ${tmpClone}`)
572
+ if (spawnSync('git', [
573
+ 'clone','--depth','1','--branch',ref,
574
+ repoUrl, tmpClone
575
+ ], { stdio:'inherit' }).status !== 0) {
576
+ console.error('❌ git clone failed')
577
+ process.exit(1)
578
+ }
579
+ root = sub.length ? path.join(tmpClone, sub.join('/')) : tmpClone
580
+ }
324
581
 
325
- if (!fs.existsSync(projectDir)) {
326
- console.error(`❌ Project directory not found: ${projectDir}`);
327
- process.exit(1);
582
+ // Discover charts ─────────────────────────────────────────────
583
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
584
+ console.error(`❌ Chart root not found: ${root}`)
585
+ process.exit(1)
328
586
  }
329
- if (!fs.existsSync(sourceFile)) {
330
- console.error(`❌ README.adoc not found in ${normalized}`);
331
- process.exit(1);
587
+ // single-chart?
588
+ let charts = []
589
+ if (fs.existsSync(path.join(root,'Chart.yaml'))) {
590
+ charts = [root]
591
+ } else {
592
+ charts = fs.readdirSync(root)
593
+ .map(n => path.join(root,n))
594
+ .filter(p => fs.existsSync(path.join(p,'Chart.yaml')))
595
+ }
596
+ if (charts.length === 0) {
597
+ console.error(`❌ No charts found under: ${root}`)
598
+ process.exit(1)
332
599
  }
333
600
 
334
- fs.mkdirSync(pagesDir, { recursive: true });
335
- const relPath = path.relative(pagesDir, sourceFile);
336
-
337
- try {
338
- fs.symlinkSync(relPath, destLink);
339
- console.log(`✔️ Linked ${relPath} ${destLink}`);
340
- } catch (err) {
341
- console.error(`❌ Failed to create symlink: ${err.message}`);
342
- process.exit(1);
601
+ // Ensure output-dir exists ────────────────────────────────────
602
+ const outDir = path.resolve(opts.outputDir)
603
+ fs.mkdirSync(outDir, { recursive: true })
604
+
605
+ // Process each chart ─────────────────────────────────────────
606
+ for (const chartPath of charts) {
607
+ const name = path.basename(chartPath)
608
+ console.log(`\n🔨 Processing chart "${name}"…`)
609
+
610
+ // Regenerate README.md
611
+ console.log(` ⏳ helm-docs in ${chartPath}`)
612
+ let r = spawnSync('helm-docs', { cwd: chartPath, stdio: 'inherit' })
613
+ if (r.status !== 0) process.exit(r.status)
614
+
615
+ // Convert Markdown → AsciiDoc
616
+ const md = path.join(chartPath, opts.readme)
617
+ if (!fs.existsSync(md)) {
618
+ console.error(`❌ README not found: ${md}`)
619
+ process.exit(1)
620
+ }
621
+ const outFile = path.join(outDir, `k-${name}${opts.outputSuffix}`)
622
+ console.log(` ⏳ pandoc ${md} → ${outFile}`)
623
+ fs.mkdirSync(path.dirname(outFile), { recursive: true })
624
+ r = spawnSync('pandoc', [ md, '-t', 'asciidoc', '-o', outFile ], { stdio:'inherit' })
625
+ if (r.status !== 0) process.exit(r.status)
626
+
627
+ // Post-process tweaks
628
+ let doc = fs.readFileSync(outFile, 'utf8')
629
+ const xrefRe = /https:\/\/docs\.redpanda\.com[^\s\]\[\)"]+(?:\[[^\]]*\])?/g;
630
+ doc = doc
631
+ .replace(/(\[\d+\])\]\./g, '$1\\].')
632
+ .replace(/^== # (.*)$/gm, '= $1')
633
+ .replace(/^== description: (.*)$/gm, ':description: $1')
634
+ .replace(xrefRe, match => {
635
+ // split off any [bracketed text]
636
+ let urlPart = match;
637
+ let bracketPart = '';
638
+ const m = match.match(/^([^\[]+)(\[[^\]]*\])$/);
639
+ if (m) {
640
+ urlPart = m[1]; // the pure URL
641
+ bracketPart = m[2]; // the “[text]”
642
+ }
643
+ // if it ends in “#” we leave it alone
644
+ if (urlPart.endsWith('#')) {
645
+ return match;
646
+ }
647
+ try {
648
+ const xref = urlToXref(urlPart);
649
+ // re-attach the bracket text, or append empty [] for bare URLs
650
+ return bracketPart
651
+ ? `${xref}${bracketPart}`
652
+ : `${xref}[]`;
653
+ } catch (err) {
654
+ console.warn(`⚠️ urlToXref failed on ${urlPart}: ${err.message}`);
655
+ return match;
656
+ }
657
+ });
658
+ fs.writeFileSync(outFile, doc, 'utf8')
659
+
660
+ console.log(`✅ Wrote ${outFile}`)
343
661
  }
344
- });
345
662
 
346
- programCli
347
- .command('fetch')
348
- .description('Fetch a file or directory from GitHub and save locally')
349
- .requiredOption('-o, --owner <owner>', 'GitHub repo owner or org')
350
- .requiredOption('-r, --repo <repo>', 'GitHub repo name')
351
- .requiredOption('-p, --remote-path <path>', 'Path in the repo to fetch')
352
- .requiredOption('-d, --save-dir <dir>', 'Local directory to save into')
353
- .option('-f, --filename <name>', 'Custom filename to save as')
354
- .action(async (options) => {
355
- try {
356
- const fetchFromGithub = await require('../tools/fetch-from-github.js');
357
- // options.owner, options.repo, options.remotePath, options.saveDir, options.filename
663
+ // Cleanup ───────────────────────────────────────────────────
664
+ if (tmpClone) fs.rmSync(tmpClone, { recursive: true, force: true })
665
+ })
666
+
667
+ automation
668
+ .command('crd-spec')
669
+ .description('Generate Asciidoc documentation for Kubernetes CRD references')
670
+ .requiredOption('-t, --tag <operatorTag>',
671
+ 'Operator release tag or branch, such as operator/v25.1.2')
672
+ .option('-s, --source-path <src>',
673
+ 'CRD Go types dir or GitHub URL',
674
+ 'https://github.com/redpanda-data/redpanda-operator/operator/api/redpanda/v1alpha2')
675
+ .option('-d, --depth <n>',
676
+ 'How many levels deep',
677
+ '10')
678
+ .option('--templates-dir <dir>',
679
+ 'Asciidoctor templates dir',
680
+ '.github/crd-config/templates/asciidoctor/operator')
681
+ .option('--output <file>',
682
+ 'Where to write the generated AsciiDoc file',
683
+ 'modules/reference/pages/k-crd.adoc')
684
+ .action(async opts => {
685
+ verifyCrdDependencies();
686
+
687
+ // Fetch upstream config ──────────────────────────────────────────
688
+ const configTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'crd-config-'));
689
+ console.log('🔧 Fetching crd-ref-docs-config.yaml from redpanda-operator@main…');
358
690
  await fetchFromGithub(
359
- options.owner,
360
- options.repo,
361
- options.remotePath,
362
- options.saveDir,
363
- options.filename
691
+ 'redpanda-data',
692
+ 'redpanda-operator',
693
+ 'operator/crd-ref-docs-config.yaml',
694
+ configTmp,
695
+ 'crd-ref-docs-config.yaml'
364
696
  );
365
- } catch (err) {
366
- console.error('❌', err.message);
367
- process.exit(1);
368
- }
369
- });
697
+ const configPath = path.join(configTmp, 'crd-ref-docs-config.yaml');
698
+
699
+ // Detect docs repo context ───────────────────────────────────────
700
+ const repoRoot = findRepoRoot();
701
+ const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot,'package.json'),'utf8'));
702
+ const inDocs = pkg.name === 'redpanda-docs-playbook'
703
+ || (pkg.repository && pkg.repository.url.includes('redpanda-data/docs'));
704
+ let docsBranch = null;
705
+
706
+ if (!inDocs) {
707
+ console.warn('⚠️ Not inside redpanda-data/docs; skipping branch suggestion.');
708
+ } else {
709
+ try {
710
+ docsBranch = await determineDocsBranch(opts.tag);
711
+ console.log(`Detected docs repo; you should commit to branch '${docsBranch}'.`);
712
+ } catch (err) {
713
+ console.error(`❌ Unable to determine docs branch: ${err.message}`);
714
+ process.exit(1);
715
+ }
716
+ }
717
+
718
+ // Validate templates ─────────────────────────────────────────────
719
+ if (!fs.existsSync(opts.templatesDir)) {
720
+ console.error(`❌ Templates directory not found: ${opts.templatesDir}`);
721
+ process.exit(1);
722
+ }
723
+
724
+ // Prepare source (local folder or GitHub URL) ───────────────────
725
+ let localSrc = opts.sourcePath;
726
+ let tmpSrc;
727
+ if (/^https?:\/\/github\.com\//.test(opts.sourcePath)) {
728
+ const u = new URL(opts.sourcePath);
729
+ const parts = u.pathname.split('/').filter(Boolean);
730
+ if (parts.length < 2) {
731
+ console.error(`❌ Invalid GitHub URL: ${opts.sourcePath}`);
732
+ process.exit(1);
733
+ }
734
+ const [owner, repo, ...subpathParts] = parts;
735
+ const repoUrl = `https://${u.host}/${owner}/${repo}`;
736
+ const subpath = subpathParts.join('/');
737
+ // Verify tag/branch exists
738
+ console.log(`🔎 Verifying "${opts.tag}" in ${repoUrl}…`);
739
+ const ok = spawnSync('git', [
740
+ 'ls-remote','--exit-code', repoUrl,
741
+ `refs/tags/${opts.tag}`, `refs/heads/${opts.tag}`
742
+ ], { stdio:'ignore' }).status === 0;
743
+ if (!ok) {
744
+ console.error(`❌ Tag or branch "${opts.tag}" not found on ${repoUrl}`);
745
+ process.exit(1);
746
+ }
747
+ // Clone
748
+ tmpSrc = fs.mkdtempSync(path.join(os.tmpdir(), 'crd-src-'));
749
+ console.log(`⏳ Cloning ${repoUrl}@${opts.tag} → ${tmpSrc}`);
750
+ if (spawnSync('git', ['clone','--depth','1','--branch',opts.tag,repoUrl,tmpSrc],{stdio:'inherit'}).status !== 0) {
751
+ console.error('❌ git clone failed'); process.exit(1);
752
+ }
753
+ // Point at subfolder if any
754
+ localSrc = subpath ? path.join(tmpSrc, subpath) : tmpSrc;
755
+ if (!fs.existsSync(localSrc)) {
756
+ console.error(`❌ Subdirectory not found in repo: ${subpath}`); process.exit(1);
757
+ }
758
+ }
759
+
760
+ // Ensure output directory exists ────────────────────────────────
761
+ const outputDir = path.dirname(opts.output);
762
+ if (!fs.existsSync(outputDir)) {
763
+ fs.mkdirSync(outputDir, { recursive: true });
764
+ }
765
+
766
+ // Run crd-ref-docs ───────────────────────────────────────────────
767
+ const args = [
768
+ '--source-path', localSrc,
769
+ '--max-depth', opts.depth,
770
+ '--templates-dir', opts.templatesDir,
771
+ '--config', configPath,
772
+ '--renderer', 'asciidoctor',
773
+ '--output-path', opts.output
774
+ ];
775
+ console.log(`⏳ Running crd-ref-docs ${args.join(' ')}`);
776
+ if (spawnSync('crd-ref-docs', args, { stdio:'inherit' }).status !== 0) {
777
+ console.error('❌ crd-ref-docs failed'); process.exit(1);
778
+ }
779
+
780
+ let doc = fs.readFileSync(opts.output, 'utf8')
781
+ // this regex matches:
782
+ // - a docs.redpanda.com URL
783
+ // - optionally followed immediately by [some text]
784
+ // It will not include trailing ] in the URL match itself.
785
+ const xrefRe = /https:\/\/docs\.redpanda\.com[^\s\]\[\)"]+(?:\[[^\]]*\])?/g;
786
+
787
+ doc = doc.replace(xrefRe, match => {
788
+ // split off any [bracketed text]
789
+ let urlPart = match;
790
+ let bracketPart = '';
791
+ const m = match.match(/^([^\[]+)(\[[^\]]*\])$/);
792
+ if (m) {
793
+ urlPart = m[1]; // the pure URL
794
+ bracketPart = m[2]; // the “[text]”
795
+ }
796
+
797
+ // if it ends in “#” we leave it alone
798
+ if (urlPart.endsWith('#')) {
799
+ return match;
800
+ }
801
+
802
+ try {
803
+ const xref = urlToXref(urlPart);
804
+ // re-attach the bracket text, or append empty [] for bare URLs
805
+ return bracketPart
806
+ ? `${xref}${bracketPart}`
807
+ : `${xref}[]`;
808
+ } catch (err) {
809
+ console.warn(`⚠️ urlToXref failed on ${urlPart}: ${err.message}`);
810
+ return match;
811
+ }
812
+ });
813
+ fs.writeFileSync(opts.output, doc, 'utf8')
814
+
815
+ // Cleanup ────────────────────────────────────────────────────────
816
+ if (tmpSrc) fs.rmSync(tmpSrc, { recursive: true, force: true });
817
+ fs.rmSync(configTmp, { recursive: true, force: true });
818
+
819
+ console.log(`✅ CRD docs generated at ${opts.output}`);
820
+ if (inDocs) {
821
+ console.log(`➡️ Don't forget to commit your changes on branch '${docsBranch}'.`);
822
+ }
823
+ });
370
824
 
371
825
 
372
826
  // Attach the automation group to the main program.