Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions build-scripts/docs/dump-cag-tree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/*
* SonarQube CLI
* Copyright (C) SonarSource Sàrl
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

/**
* Download the pinned sonar-context-augmentation binary (if not already cached),
* invoke `tool dump-cli-tree --pretty`, and return the parsed command tree.
*
* Used by the docs build to merge CAG's subcommand surface into commands.json / llms.txt.
*/

import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { chmod, readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

import { extractFileFromTarGz } from '../../src/cli/commands/_common/install/tar.js';
import { buildCagPlatformSuffix } from '../../src/lib/install-types.js';
import { detectPlatform } from '../../src/lib/platform-detector.js';
import {
SONAR_CONTEXT_AUGMENTATION_SIGNATURES,
SONAR_CONTEXT_AUGMENTATION_VERSION,
SONARSOURCE_PUBLIC_KEY,
} from '../../src/lib/signatures.js';
import {
buildCagDownloadUrl,
downloadBinary,
verifyPgpSignature,
} from '../../src/lib/sonarsource-releases.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..', '..');
const CAG_CACHE_DIR = join(ROOT, 'node_modules', '.cache', 'sonarqube-cli', 'cag-docs');

export interface CagOption {
long: string;
short?: string;
value_name?: string;
help?: string;
required: boolean;
default?: string;
num_args?: string;
}

export interface CagCommand {
name: string;
about?: string;
options?: CagOption[];
subcommands?: CagCommand[];
}

export interface CagCliTree {
version: string;
subcommands: CagCommand[];
}

/**
* Ensure the pinned CAG binary is cached locally and return its path.
* Verifies the PGP signature on first download.
* Throws on download failure, signature mismatch, or missing archive content.
*/
async function resolveCagBinary(): Promise<string> {
const platform = detectPlatform();
const platSuffix = buildCagPlatformSuffix(platform);
const version = SONAR_CONTEXT_AUGMENTATION_VERSION;
const cacheDir = join(CAG_CACHE_DIR, version);

mkdirSync(cacheDir, { recursive: true });

const binaryName = `sonar-context-augmentation-${version}-${platSuffix}${platform.extension}`;
const binaryPath = join(cacheDir, binaryName);

if (existsSync(binaryPath)) {
return binaryPath;
}

const archiveUrl = buildCagDownloadUrl(version, platform);
const archivePath = `${binaryPath}.tar.gz`;
const ascPath = `${archivePath}.asc`;

console.log(` Downloading sonar-context-augmentation ${version} for ${platSuffix}…`);
try {
await downloadBinary(archiveUrl, archivePath);
await downloadBinary(`${archiveUrl}.asc`, ascPath);

const [archiveBytes, armoredSignature] = await Promise.all([
readFile(archivePath),
readFile(ascPath, 'utf-8'),
]);

const expected = SONAR_CONTEXT_AUGMENTATION_SIGNATURES[platSuffix];
if (!expected) {
throw new Error(
`No pinned signature for sonar-context-augmentation on ${platSuffix}. ` +
`Run \`bun run fetch:signatures\` to refresh.`,
);
}
if (expected !== armoredSignature.trim()) {
throw new Error(
`Signature mismatch for sonar-context-augmentation on ${platSuffix}: ` +
`the downloaded .asc does not match the pinned signature.`,
);
}
await verifyPgpSignature(archiveBytes, armoredSignature, SONARSOURCE_PUBLIC_KEY);

const binaryBytes = extractFileFromTarGz(
archiveBytes,
`sonar-context-augmentation${platform.extension}`,
);
if (!binaryBytes) {
throw new Error(
`sonar-context-augmentation binary not found inside ${archiveUrl.split('/').at(-1)}.`,
);
}

writeFileSync(binaryPath, binaryBytes);
if (platform.os !== 'windows') {
await chmod(binaryPath, 0o755);
}
} finally {
// Remove the downloaded archive and signature whether verification succeeded or
// not — failed runs shouldn't leave unverified .tar.gz behind, successful runs
// don't need them once the binary is extracted. Mirrors the install path in
// src/cli/commands/_common/install/context-augmentation.ts.
rmSync(archivePath, { force: true });
rmSync(ascPath, { force: true });
}

return binaryPath;
}

/**
* Download and run `sonar-context-augmentation tool dump-cli-tree --pretty`,
* returning the parsed JSON command tree.
*
* Set `CAG_BINARY_PATH` to skip the download and use a pre-built binary instead
* (useful for local development against an unreleased CAG build).
*
* Throws on download / verification failure or non-zero exit from the binary.
*/
export async function dumpCagTree(): Promise<CagCliTree> {
const overridePath = process.env['CAG_BINARY_PATH'];
let binaryPath: string;
if (overridePath) {
console.log(`Using CAG binary override: ${overridePath}`);
binaryPath = overridePath;
} else {
console.log('Fetching sonar-context-augmentation CLI tree for docs generation…');
binaryPath = await resolveCagBinary();
}

const result = spawnSync(binaryPath, ['tool', 'dump-cli-tree', '--pretty'], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});

if (result.status !== 0 || result.error) {
const detail = result.error?.message ?? result.stderr ?? '(no output)';
throw new Error(
`sonar-context-augmentation tool dump-cli-tree failed (exit ${result.status ?? 'null'}): ${detail}`,
);
}

try {
return JSON.parse(result.stdout) as CagCliTree;
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
const stdoutSnippet = result.stdout.slice(0, 200);
const stderrSnippet = (result.stderr ?? '').slice(0, 200);
throw new Error(
`Failed to parse sonar-context-augmentation tool dump-cli-tree output as JSON: ${reason}\n` +
`stdout (first 200 chars): ${stdoutSnippet}\n` +
`stderr (first 200 chars): ${stderrSnippet}`,
);
}
}
32 changes: 32 additions & 0 deletions build-scripts/docs/examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,38 @@ export const EXAMPLES: Record<string, Example[]> = {
description: 'Scan stdin for hardcoded secrets',
},
],
'sonar context guidelines get': [
{
command: 'sonar context guidelines get --languages rust',
description: 'Get coding guidelines for Rust before writing or editing code',
},
{
command:
'sonar context guidelines get --categories "Auth & Identity" "Exception & Error Handling" --languages java',
description: 'Get guidelines for specific categories in Java',
},
],
'sonar context dependencies check': [
{
command: 'sonar context dependencies check --purl "pkg:npm/lodash@4.17.21"',
description: 'Check an npm package for vulnerabilities and license compliance',
},
{
command:
'sonar context dependencies check --purl "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1"',
description: 'Check a Maven dependency before adding it to the project',
},
],
'sonar context navigation search-signatures': [
{
command: 'sonar context navigation search-signatures --pattern ".*Service" --limit 10',
description: 'Find classes or functions whose name ends with "Service"',
},
{
command: 'sonar context navigation search-signatures --pattern ".*Repository" --output fqns',
description: 'List FQNs of all Repository types for use in follow-up queries',
},
],
'sonar config telemetry': [
{
command: 'sonar config telemetry --enabled',
Expand Down
45 changes: 11 additions & 34 deletions build-scripts/docs/generate-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ import type { Option } from 'commander';
import { version } from '../../package.json';
import { COMMAND_TREE } from '../../src/cli/command-tree';
import type { SonarCommand } from '../../src/cli/commands/_common/sonar-command';
import { dumpCagTree } from './dump-cag-tree';
import { EXAMPLES } from './examples';
import type { ClidocCommand } from './merge-cag-tree';
import { mergeCagTree } from './merge-cag-tree';

const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..', '..');
Expand All @@ -50,40 +53,6 @@ function optionType(
return opt.required || opt.optional ? 'string' : 'boolean';
}

interface ClidocArgument {
name: string;
description: string;
required: boolean;
variadic: boolean;
}

interface ClidocOption {
flags: string;
long: string;
short: string | undefined;
description: string;
type: string;
required: boolean;
defaultValue: unknown;
allowedValues?: string[];
}

interface ClidocCommand {
id: string;
name: string;
fullName: string;
description: string;
isGroup: boolean;
isRoot: boolean;
requiresAuth: boolean;
depth: number;
parentId: string | null;
arguments: ClidocArgument[];
options: ClidocOption[];
examples: { command: string; description: string }[];
children: string[];
}

const allCommands: ClidocCommand[] = [];
const help = COMMAND_TREE.createHelp();

Expand Down Expand Up @@ -163,6 +132,14 @@ for (const cmd of visibleTopLevel) {
serializeCommand(cmd as SonarCommand, 'sonar', 1, rootId);
}

// ── CAG subcommand tree merge ──────────────────────────────────
// sonar context [action] [args...] is a Commander passthrough. The docs
// generator cannot see CAG's clap-based subcommands via Commander, so we
// download the pinned CAG binary, invoke `tool dump-cli-tree --pretty`, and
// stitch the result into allCommands under the existing sonar-context entry.
const cagTree = await dumpCagTree();
mergeCagTree(cagTree, allCommands);

const data = {
version,
commands: allCommands,
Expand Down
Loading
Loading