Skip to content

Commit f2416f6

Browse files
CLI-3 Add telemetry for CLI
1 parent b490468 commit f2416f6

22 files changed

+1612
-233
lines changed

docs/state-management.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ The state file persists configuration across CLI invocations and stores:
1010
- **Installed Hooks**: Pre/Post tool use and session start hooks for agent interactions
1111
- **Installed Skills**: Custom Claude Code skills
1212
- **Tool Metadata**: Installed external tools like sonar-secrets binary
13+
- **Telemetry data**: Anonymous usage statistics
1314

1415
## Location
1516

spec.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,3 +272,26 @@ commands:
272272

273273
- command: cat .env | sonar analyze secrets --stdin
274274
description: Scan stdin for hardcoded secrets
275+
276+
# Configure CLI
277+
- name: config
278+
description: Configure CLI settings
279+
subcommands:
280+
- name: telemetry
281+
description: Configure telemetry settings
282+
handler: ./src/commands/config.ts
283+
options:
284+
- name: enabled
285+
type: boolean
286+
description: Enable collection of anonymous usage statistics
287+
288+
- name: disabled
289+
type: boolean
290+
description: Disable collection of anonymous usage statistics
291+
292+
examples:
293+
- command: sonar config telemetry --enabled
294+
description: Enable collection of anonymous usage statistics
295+
296+
- command: sonar config telemetry --disabled
297+
description: Disable collection of anonymous usage statistics

src/commands/auth.ts

Lines changed: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import logger from '../lib/logger.js';
3535
import { warn, success, print, note, textPrompt, confirmPrompt } from '../ui/index.js';
3636
import { green, red, dim } from '../ui/colors.js';
3737
import { SONARCLOUD_URL, SONARCLOUD_HOSTNAME } from '../lib/config-constants.js';
38+
import {InvalidOptionError} from "./common/error";
3839

3940
/**
4041
* Check if server is SonarCloud
@@ -184,6 +185,35 @@ async function validateOrSelectOrganization(
184185
return selectedOrg.trim();
185186
}
186187

188+
async function validateLoginOptions(options: { server?: string; org?: string; withToken?: string; region?: string }) {
189+
if (options.org !== undefined && !options.org.trim()) {
190+
throw new InvalidOptionError('--org value cannot be empty. Provide a valid organization key (e.g., --org my-org)');
191+
}
192+
193+
if (options.withToken !== undefined && !options.withToken.trim()) {
194+
throw new InvalidOptionError('--with-token value cannot be empty. Provide a valid token or omit the flag for browser authentication');
195+
}
196+
197+
if (options.server !== undefined && !options.server.trim()) {
198+
throw new InvalidOptionError('--server value cannot be empty. Provide a valid URL (e.g., https://sonarcloud.io)');
199+
}
200+
201+
let server = options.server;
202+
if (!server) {
203+
const configServer = await findServerInConfigs();
204+
server = configServer || SONARCLOUD_URL;
205+
}
206+
207+
if (options.server !== undefined) {
208+
try {
209+
new URL(server);
210+
} catch {
211+
throw new InvalidOptionError(`Invalid server URL: '${server}'. Provide a valid URL (e.g., https://sonarcloud.io)`);
212+
}
213+
}
214+
return server;
215+
}
216+
187217
/**
188218
* Login command - authenticate and save token with organization
189219
*/
@@ -194,31 +224,7 @@ export async function authLoginCommand(options: {
194224
region?: string;
195225
}): Promise<void> {
196226
await runCommand(async () => {
197-
if (options.org !== undefined && !options.org.trim()) {
198-
throw new Error('--org value cannot be empty. Provide a valid organization key (e.g., --org my-org)');
199-
}
200-
201-
if (options.withToken !== undefined && !options.withToken.trim()) {
202-
throw new Error('--with-token value cannot be empty. Provide a valid token or omit the flag for browser authentication');
203-
}
204-
205-
if (options.server !== undefined && !options.server.trim()) {
206-
throw new Error('--server value cannot be empty. Provide a valid URL (e.g., https://sonarcloud.io)');
207-
}
208-
209-
let server = options.server;
210-
if (!server) {
211-
const configServer = await findServerInConfigs();
212-
server = configServer || SONARCLOUD_URL;
213-
}
214-
215-
if (options.server !== undefined) {
216-
try {
217-
new URL(server);
218-
} catch {
219-
throw new Error(`Invalid server URL: '${server}'. Provide a valid URL (e.g., https://sonarcloud.io)`);
220-
}
221-
}
227+
let server = await validateLoginOptions(options);
222228

223229
const isCloud = isSonarCloud(server);
224230
const region = (options.region || 'eu') as 'eu' | 'us';
@@ -243,12 +249,25 @@ export async function authLoginCommand(options: {
243249
const state = loadState();
244250
const keystoreKey = generateConnectionId(server, org);
245251

246-
addOrUpdateConnection(state, server, isCloud ? 'cloud' : 'on-premise', {
252+
const connection = addOrUpdateConnection(state, server, isCloud ? 'cloud' : 'on-premise', {
247253
orgKey: org,
248254
region: isCloud ? region : undefined,
249255
keystoreKey,
250256
});
251257

258+
// Fetch server-side IDs for telemetry enrichment (best effort, non-blocking on error).
259+
const actualToken = token || (await getKeystoreToken(server, org));
260+
if (actualToken) {
261+
const apiClient = new SonarQubeClient(server, actualToken);
262+
connection.userUuid = (await apiClient.getCurrentUser())?.id ?? null;
263+
if (isCloud && org) {
264+
connection.organizationUuidV4 = await apiClient.getOrganizationId(org);
265+
} else if (!isCloud) {
266+
const status = await apiClient.getSystemStatus();
267+
connection.sqsInstallationId = status.id ?? null;
268+
}
269+
}
270+
252271
saveState(state);
253272

254273
const displayServer = isSonarCloud(server) ? `${server} (${org})` : server;

src/commands/common/error.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* SonarQube CLI
3+
* Copyright (C) 2026 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
21+
/**
22+
* Thrown when the user provides invalid or conflicting command options.
23+
*/
24+
export class InvalidOptionError extends Error {
25+
constructor(reason: string) {
26+
super(reason);
27+
this.name = 'InvalidOptionError';
28+
}
29+
}
30+
31+
/**
32+
* Thrown when the command (and options if any defined) are valid, but it failed to execute.
33+
*/
34+
export class CommandFailedError extends Error {
35+
constructor(message: string) {
36+
super(message);
37+
this.name = 'CommandFailedError';
38+
}
39+
}

src/commands/config.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* SonarQube CLI
3+
* Copyright (C) 2026 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
// Configure CLI settings
21+
22+
import {loadState, saveState} from '../lib/state-manager.js';
23+
import {version as VERSION} from '../../package.json';
24+
import {success} from '../ui';
25+
import {InvalidOptionError} from './common/error.js';
26+
27+
export interface ConfigureTelemetryOptions {
28+
enabled?: boolean;
29+
disabled?: boolean
30+
}
31+
32+
export async function configureTelemetry(options: ConfigureTelemetryOptions): Promise<void> {
33+
if (!options.enabled && !options.disabled) {
34+
throw new InvalidOptionError('Either --enabled or --disabled is required');
35+
}
36+
if (options.enabled && options.disabled) {
37+
throw new InvalidOptionError('Cannot use both --enabled and --disabled');
38+
}
39+
const state = loadState(VERSION);
40+
state.telemetry.enabled = options.enabled ?? false;
41+
saveState(state);
42+
success(`Telemetry ${options.enabled ? 'enabled' : 'disabled'}.`);
43+
}

src/commands/secret-scan.ts

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ import { buildLocalBinaryName, detectPlatform } from '../lib/platform-detector.j
2828
import { getActiveConnection, loadState } from '../lib/state-manager.js';
2929
import { getToken } from '../lib/keychain.js';
3030
import logger from '../lib/logger.js';
31-
import { text, blank, success, error, print } from '../ui/index.js';
31+
import { text, blank, success, error, print } from '../ui';
32+
import { CommandFailedError, InvalidOptionError } from './common/error.js';
3233

3334
const SCAN_TIMEOUT_MS = 30000;
3435
const STDIN_READ_TIMEOUT_MS = 5000;
@@ -78,13 +79,11 @@ async function setupScanEnvironment(options: { file?: string; stdin?: boolean })
7879

7980
function validateScanOptions(options: { file?: string; stdin?: boolean }): void {
8081
if (!options.file && !options.stdin) {
81-
error('Either --file or --stdin is required');
82-
process.exit(1);
82+
throw new InvalidOptionError('Either --file or --stdin is required');
8383
}
8484

8585
if (options.file && options.stdin) {
86-
error('Cannot use both --file and --stdin');
87-
process.exit(1);
86+
throw new InvalidOptionError('Cannot use both --file and --stdin');
8887
}
8988
}
9089

@@ -103,15 +102,15 @@ async function setupAuth(): Promise<{ authUrl: string; authToken: string }> {
103102

104103
if (!activeConnection) {
105104
logAuthConfigError();
106-
process.exit(1);
105+
throw new CommandFailedError('sonar-secrets authentication is not configured');
107106
}
108107

109108
const authUrl = activeConnection.serverUrl;
110109
const authToken = await getToken(authUrl, activeConnection.orgKey);
111110

112111
if (!authUrl || !authToken) {
113112
logAuthConfigError();
114-
process.exit(1);
113+
throw new CommandFailedError('sonar-secrets authentication is not configured');
115114
}
116115

117116
return { authUrl, authToken };
@@ -142,13 +141,11 @@ async function performFileScan(
142141
scanStartTime: number
143142
): Promise<void> {
144143
if (!file) {
145-
error('File path is required');
146-
process.exit(1);
144+
throw new InvalidOptionError('File path is required');
147145
}
148146

149147
if (!existsSync(file)) {
150-
error(`File not found: ${file}`);
151-
process.exit(1);
148+
throw new InvalidOptionError(`File not found: ${file}`);
152149
}
153150

154151
const result = await runScan(binaryPath, file, authUrl, authToken);
@@ -166,7 +163,7 @@ function validateCheckCommandEnvironment(binaryPath: string): void {
166163
if (!existsSync(binaryPath)) {
167164
error('sonar-secrets is not installed');
168165
text(' Install with: sonar secret install');
169-
process.exit(1);
166+
throw new CommandFailedError('sonar-secrets is not installed');
170167
}
171168
}
172169

@@ -284,15 +281,13 @@ function handleScanSuccess(result: { stdout: string }, scanDurationMs: number):
284281
text(` Duration: ${scanDurationMs}ms`);
285282
displayScanResults(scanResult);
286283
blank();
287-
process.exit(0);
288284
} catch (parseError) {
289285
logger.debug(`Failed to parse JSON output: ${(parseError as Error).message}`);
290286
blank();
291287
success('Scan completed successfully');
292288
blank();
293289
print(result.stdout);
294290
blank();
295-
process.exit(0);
296291
}
297292
}
298293

@@ -345,10 +340,18 @@ function handleScanFailure(
345340
}
346341
blank();
347342
// Binary exit 1 = secrets found — remap to 51 so hooks can distinguish from generic errors
348-
process.exit(exitCode === 1 ? SECRET_SCAN_POSITIVE_EXIT_CODE : exitCode);
343+
process.exitCode = exitCode === 1 ? SECRET_SCAN_POSITIVE_EXIT_CODE : exitCode;
349344
}
350345

351346
function handleScanError(err: unknown): void {
347+
if (err instanceof InvalidOptionError) {
348+
throw err;
349+
}
350+
351+
if (err instanceof CommandFailedError) {
352+
throw err;
353+
}
354+
352355
const errorMessage = (err as Error).message;
353356

354357
blank();
@@ -364,5 +367,5 @@ function handleScanError(err: unknown): void {
364367
}
365368

366369
blank();
367-
process.exit(1);
370+
throw new CommandFailedError(errorMessage);
368371
}

0 commit comments

Comments
 (0)