Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
33 changes: 30 additions & 3 deletions src/cli/command-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import { configureTelemetry, type ConfigureTelemetryOptions } from './commands/c
import { selfUpdate, type SelfUpdateOptions } from './commands/self-update/self-update';
import { parseInteger } from './commands/_common/parsing';
import { MAX_PAGE_SIZE } from '../sonarqube/projects';
import { apiCommand, apiExtraHelpText, type ApiCommandOptions } from './commands/api/api';
import { GENERIC_HTTP_METHODS } from '../sonarqube/client';

const DEFAULT_PAGE_SIZE = MAX_PAGE_SIZE;

Expand All @@ -58,6 +60,31 @@ COMMAND_TREE.name('sonar')
this.outputHelp();
});

const projectOption = new Option(
'-p, --project <project>',
'SonarCloud project key (overrides auto-detected project)',
);

COMMAND_TREE.command('api')
.argument(
'<method>',
`HTTP method (${GENERIC_HTTP_METHODS.map((m) => m.toLowerCase()).join(', ')})`,
)
.argument(
'<endpoint>',
'API endpoint path. Must start with "/", and can contain query parameters.',
)
.option(
'-d, --data <data>',
'JSON string for request body. The tool will automatically format as either form data or JSON body.',
)
.option('-v, --verbose', 'Print request and response details for debugging.')
.description('Make authenticated API requests to SonarQube')
.addHelpText('after', apiExtraHelpText())
.authenticatedAction((auth, method: string, endpoint: string, options: ApiCommandOptions) =>
apiCommand(auth, method, endpoint, options),
);

// Setup SonarQube integration for AI coding agent
const integrateCommand = COMMAND_TREE.command('integrate').description(
'Setup SonarQube integration for AI coding agents, git and others.',
Expand All @@ -68,7 +95,7 @@ integrateCommand
.description(
'Setup SonarQube integration for Claude Code. This will install secrets scanning hooks, and configure SonarQube MCP Server.',
)
.option('-p, --project <project>', 'Project key')
.addOption(projectOption)
.option('--non-interactive', 'Non-interactive mode (no prompts)')
.option(
'-g, --global',
Expand Down Expand Up @@ -170,7 +197,7 @@ analyze
.description('Run SQAA server-side analysis on a file (SonarQube Cloud only)')
.requiredOption('--file <file>', 'File path to analyze')
.option('--branch <branch>', 'Branch name for analysis context')
.option('--project <project>', 'SonarCloud project key (overrides auto-detected project)')
.addOption(projectOption)
.authenticatedAction((auth, options: AnalyzeSqaaOptions, cmd: Command) =>
analyzeSqaa(options, auth, cmd),
);
Expand All @@ -179,7 +206,7 @@ COMMAND_TREE.command('verify')
.description('Analyze a file for issues')
.requiredOption('--file <file>', 'File path to analyze')
.option('--branch <branch>', 'Branch name for analysis context')
.option('--project <project>', 'SonarCloud project key (overrides auto-detected project)')
.addOption(projectOption)
.authenticatedAction((auth, options: AnalyzeSqaaOptions, cmd: Command) =>
analyzeSqaa(options, auth, cmd),
);
Expand Down
6 changes: 3 additions & 3 deletions src/cli/commands/_common/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { existsSync, realpathSync, statSync } from 'node:fs';
import { join, dirname, basename, resolve } from 'node:path';
import { spawnProcess } from '../../../lib/process';
import logger from '../../../lib/logger';
import { print, text } from '../../../ui';
import { print } from '../../../ui';

export interface ProjectInfo {
root: string;
Expand Down Expand Up @@ -161,14 +161,14 @@ export async function discoverProject(startDir: string): Promise<DiscoveredProje
};

if (projectInfo.hasSonarProps && projectInfo.sonarPropsData) {
text('Found sonar-project.properties');
print('Found sonar-project.properties', process.stderr);
Comment thread
subdavis marked this conversation as resolved.
config.serverUrl = projectInfo.sonarPropsData.hostURL;
config.projectKey = projectInfo.sonarPropsData.projectKey;
config.organization = projectInfo.sonarPropsData.organization;
}

if (projectInfo.hasSonarLintConfig && projectInfo.sonarLintData) {
text('Found .sonarlint/connectedMode.json');
print('Found .sonarlint/connectedMode.json', process.stderr);
config.serverUrl = config.serverUrl || projectInfo.sonarLintData.serverURL;
config.projectKey = config.projectKey || projectInfo.sonarLintData.projectKey;
config.organization = config.organization || projectInfo.sonarLintData.organization;
Expand Down
122 changes: 122 additions & 0 deletions src/cli/commands/api/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
* SonarQube CLI
* Copyright (C) 2026 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.
*/

import { type ResolvedAuth } from '../../../lib/auth-resolver.js';
import {
GENERIC_HTTP_METHODS,
METHODS_WITH_BODY,
type HttpMethod,
SonarQubeClient,
} from '../../../sonarqube/client';
import { print } from '../../../ui/index.js';
import { InvalidOptionError } from '../_common/error.js';
import { CLOUD_API_DOCS_URL, SERVER_API_DOCS_URL } from '../../../lib/config-constants.js';

const VALID_METHODS = new Set<string>(GENERIC_HTTP_METHODS);

export interface ApiCommandOptions {
data?: string;
verbose?: boolean;
}

export function apiExtraHelpText(): string {
return `
Examples:
# List favorite projects
$ sonar api get "/api/favorites/search"

# Search for rules in an organization
$ sonar api get "/api/rules/search?organization=org-name"

# Generate a new user token
$ sonar api post "/api/user_tokens/generate" --data '{"name":"my-new-token"}'

# Accept an issue
$ sonar api post "/api/issues/do_transition" --data '{"comment":"comment text","issue":"issue-id","transition":"accept"}'

# Get the current analysis engine JAR (V2 api)
$ sonar api get "/analysis/engine"

V1 and V2 routing:
Both cloud and server have V1 and V2 versions of their APIs.
This tool automatically switches its behavior based on the endpoint path you choose.

API Usage Documentation:
SonarQube Cloud: ${CLOUD_API_DOCS_URL}
SonarQube Server: ${SERVER_API_DOCS_URL}
`;
}

export async function apiCommand(
auth: ResolvedAuth,
method: string,
endpoint: string,
options: ApiCommandOptions,
): Promise<void> {
if (!VALID_METHODS.has(method.toUpperCase())) {
const validMethods = Array.from(VALID_METHODS)
.map((m) => m.toLowerCase())
.join(', ');
throw new InvalidOptionError(
`Invalid HTTP method '${method}'. Must be one of: ${validMethods}`,
);
}

const upperMethod = method.toUpperCase() as HttpMethod;

if (!endpoint.startsWith('/')) {
throw new InvalidOptionError(`Endpoint must start with '/'. Got: ${endpoint}`);
}

if (options.data && !METHODS_WITH_BODY.has(upperMethod)) {
const validDataMethods = Array.from(METHODS_WITH_BODY)
.map((m) => m.toLowerCase())
.join(', ');
throw new InvalidOptionError(`--data is only valid for ${validDataMethods} requests`);
}

if (options.data) {
try {
JSON.parse(options.data);
} catch {
throw new InvalidOptionError(`--data must be valid JSON`);
}
}

let contentType: 'json' | 'form' | undefined;

// V2 api is JSON, everything else is form data
if (endpoint.startsWith('/api/v2/') || !endpoint.startsWith('/api/')) {
contentType = 'json';
} else {
contentType = 'form';
}

const client = new SonarQubeClient(auth.serverUrl, auth.token);

const response = await client.genericRequest(
upperMethod,
endpoint,
options.data,
contentType,
options.verbose,
);
print(response);
}
1 change: 1 addition & 0 deletions src/cli/root-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export function getCustomRootHelp(): string {
` ${softBlue('verify --file <file>')} Run a comprehensive scan on a single file`,
` ${softBlue('analyze <secrets|sqaa>')} Run targeted scans for specific workflows (secrets/code quality)`,
` ${softBlue('list')} List SonarQube issues and projects`,
` ${softBlue('api <method> <endpoint>')} Make authenticated requests to any SonarQube API`,
'',
` ${softBlue('auth')} Manage authentication tokens and credentials`,
` ${softBlue('integrate <claude|git>')} Setup SonarQube integration for AI Agents (Claude Code) and Git hooks`,
Expand Down
29 changes: 28 additions & 1 deletion src/lib/auth-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@

// Centralized auth resolver - resolves token + serverUrl from env vars, state, or keychain

import { SONARCLOUD_HOSTNAME, SONARCLOUD_URL, SONARCLOUD_US_HOSTNAME } from './config-constants';
import {
SONARCLOUD_API_URL,
SONARCLOUD_HOSTNAME,
SONARCLOUD_URL,
SONARCLOUD_US_API_URL,
SONARCLOUD_US_HOSTNAME,
SONARCLOUD_US_URL,
} from './config-constants';
import { getToken } from './keychain.js';
import { loadState, getActiveConnection } from './state-manager.js';
import { warn } from '../ui';
Expand Down Expand Up @@ -128,6 +135,26 @@ export async function resolveFromState(): Promise<ResolvedAuth | null> {
return null;
}

/**
* Determine the base URL for a request based on its endpoint.
* SonarCloud uses separate hosts:
* - sonarcloud.io for /api/... endpoints
* - api.sonarcloud.io for all other endpoints
*/
export function resolveFromEndpoint(serverUrl: string, endpoint: string): string {
const normalized = serverUrl.replace(/\/$/, '');
if (isSonarQubeCloud(serverUrl)) {
const isUS = new URL(serverUrl).hostname === SONARCLOUD_US_HOSTNAME;

if (endpoint.startsWith('/api')) {
return isUS ? SONARCLOUD_US_URL : SONARCLOUD_URL;
}

return isUS ? SONARCLOUD_US_API_URL : SONARCLOUD_API_URL;
}
return normalized;
}

export function isSonarQubeCloud(serverUrl: string): boolean {
try {
const url = new URL(serverUrl);
Expand Down
3 changes: 3 additions & 0 deletions src/lib/config-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ export const SONARCLOUD_US_API_URL =
// ---------------------------------------------------------------------------

export const DOCS_URL = 'https://docs.sonarsource.com/sonarqube-cli';
export const CLOUD_API_DOCS_URL = 'https://docs.sonarsource.com/sonarqube-cloud/appendices/web-api';
export const SERVER_API_DOCS_URL =
'https://docs.sonarsource.com/sonarqube-server/extension-guide/web-api';

// ---------------------------------------------------------------------------
// Sentry
Expand Down
Loading
Loading