Skip to content

Commit 14c7a39

Browse files
CLI-3 Add telemetry for CLI
1 parent dd11688 commit 14c7a39

23 files changed

+1715
-261
lines changed

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,35 @@ Scan stdin for hardcoded secrets
268268

269269
---
270270

271+
### `sonar config`
272+
273+
Configure CLI settings
274+
275+
#### `sonar config telemetry`
276+
277+
Configure telemetry settings
278+
279+
**Options:**
280+
281+
| Option | Type | Required | Description | Default |
282+
| ------------ | ------- | -------- | ------------------------------------------------ | ------- |
283+
| `--enabled` | boolean | No | Enable collection of anonymous usage statistics | - |
284+
| `--disabled` | boolean | No | Disable collection of anonymous usage statistics | - |
285+
286+
**Examples:**
287+
288+
```bash
289+
sonar config telemetry --enabled
290+
```
291+
Enable collection of anonymous usage statistics
292+
293+
```bash
294+
sonar config telemetry --disabled
295+
```
296+
Disable collection of anonymous usage statistics
297+
298+
---
299+
271300
## Option Types
272301

273302
- `string` — text value (e.g. `--server https://sonarcloud.io`)

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
@@ -281,3 +281,26 @@ commands:
281281

282282
- command: cat .env | sonar analyze secrets --stdin
283283
description: Scan stdin for hardcoded secrets
284+
285+
# Configure CLI
286+
- name: config
287+
description: Configure CLI settings
288+
subcommands:
289+
- name: telemetry
290+
description: Configure telemetry settings
291+
handler: ./src/commands/config.ts
292+
options:
293+
- name: enabled
294+
type: boolean
295+
description: Enable collection of anonymous usage statistics
296+
297+
- name: disabled
298+
type: boolean
299+
description: Disable collection of anonymous usage statistics
300+
301+
examples:
302+
- command: sonar config telemetry --enabled
303+
description: Enable collection of anonymous usage statistics
304+
305+
- command: sonar config telemetry --disabled
306+
description: Disable collection of anonymous usage statistics

src/commands/auth.ts

Lines changed: 58 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import logger from '../lib/logger.js';
4040
import { warn, success, print, note, textPrompt, confirmPrompt } from '../ui/index.js';
4141
import { green, red, dim } from '../ui/colors.js';
4242
import { SONARCLOUD_URL, SONARCLOUD_HOSTNAME } from '../lib/config-constants.js';
43+
import { InvalidOptionError } from './common/error';
4344

4445
/**
4546
* Check if server is SonarCloud
@@ -191,6 +192,48 @@ async function validateOrSelectOrganization(
191192
return selectedOrg.trim();
192193
}
193194

195+
async function validateLoginOptions(options: {
196+
server?: string;
197+
org?: string;
198+
withToken?: string;
199+
region?: string;
200+
}) {
201+
if (options.org !== undefined && !options.org.trim()) {
202+
throw new InvalidOptionError(
203+
'--org value cannot be empty. Provide a valid organization key (e.g., --org my-org)',
204+
);
205+
}
206+
207+
if (options.withToken !== undefined && !options.withToken.trim()) {
208+
throw new InvalidOptionError(
209+
'--with-token value cannot be empty. Provide a valid token or omit the flag for browser authentication',
210+
);
211+
}
212+
213+
if (options.server !== undefined && !options.server.trim()) {
214+
throw new InvalidOptionError(
215+
'--server value cannot be empty. Provide a valid URL (e.g., https://sonarcloud.io)',
216+
);
217+
}
218+
219+
let server = options.server;
220+
if (!server) {
221+
const configServer = await findServerInConfigs();
222+
server = configServer || SONARCLOUD_URL;
223+
}
224+
225+
if (options.server !== undefined) {
226+
try {
227+
new URL(server);
228+
} catch {
229+
throw new InvalidOptionError(
230+
`Invalid server URL: '${server}'. Provide a valid URL (e.g., https://sonarcloud.io)`,
231+
);
232+
}
233+
}
234+
return server;
235+
}
236+
194237
/**
195238
* Login command - authenticate and save token with organization
196239
*/
@@ -201,39 +244,7 @@ export async function authLoginCommand(options: {
201244
region?: string;
202245
}): Promise<void> {
203246
await runCommand(async () => {
204-
if (options.org !== undefined && !options.org.trim()) {
205-
throw new Error(
206-
'--org value cannot be empty. Provide a valid organization key (e.g., --org my-org)',
207-
);
208-
}
209-
210-
if (options.withToken !== undefined && !options.withToken.trim()) {
211-
throw new Error(
212-
'--with-token value cannot be empty. Provide a valid token or omit the flag for browser authentication',
213-
);
214-
}
215-
216-
if (options.server !== undefined && !options.server.trim()) {
217-
throw new Error(
218-
'--server value cannot be empty. Provide a valid URL (e.g., https://sonarcloud.io)',
219-
);
220-
}
221-
222-
let server = options.server;
223-
if (!server) {
224-
const configServer = await findServerInConfigs();
225-
server = configServer || SONARCLOUD_URL;
226-
}
227-
228-
if (options.server !== undefined) {
229-
try {
230-
new URL(server);
231-
} catch {
232-
throw new Error(
233-
`Invalid server URL: '${server}'. Provide a valid URL (e.g., https://sonarcloud.io)`,
234-
);
235-
}
236-
}
247+
const server = await validateLoginOptions(options);
237248

238249
const isCloud = isSonarCloud(server);
239250
const region = (options.region || 'eu') as 'eu' | 'us';
@@ -265,12 +276,25 @@ export async function authLoginCommand(options: {
265276
const state = loadState();
266277
const keystoreKey = generateConnectionId(server, org);
267278

268-
addOrUpdateConnection(state, server, isCloud ? 'cloud' : 'on-premise', {
279+
const connection = addOrUpdateConnection(state, server, isCloud ? 'cloud' : 'on-premise', {
269280
orgKey: org,
270281
region: isCloud ? region : undefined,
271282
keystoreKey,
272283
});
273284

285+
// Fetch server-side IDs for telemetry enrichment (best effort, non-blocking on error).
286+
const actualToken = token || (await getKeystoreToken(server, org));
287+
if (actualToken) {
288+
const apiClient = new SonarQubeClient(server, actualToken);
289+
connection.userUuid = (await apiClient.getCurrentUser())?.id ?? null;
290+
if (isCloud && org) {
291+
connection.organizationUuidV4 = await apiClient.getOrganizationId(org);
292+
} else if (!isCloud) {
293+
const status = await apiClient.getSystemStatus();
294+
connection.sqsInstallationId = status.id ?? null;
295+
}
296+
}
297+
274298
saveState(state);
275299

276300
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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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 { info, success } from '../ui';
24+
import { InvalidOptionError } from './common/error.js';
25+
26+
export interface ConfigureTelemetryOptions {
27+
enabled?: boolean;
28+
disabled?: boolean;
29+
}
30+
31+
export function configureTelemetry(options: ConfigureTelemetryOptions): Promise<void> {
32+
if (options.enabled && options.disabled) {
33+
return Promise.reject(new InvalidOptionError('Cannot use both --enabled and --disabled'));
34+
}
35+
if (!options.enabled && !options.disabled) {
36+
const state = loadState();
37+
info(`Telemetry is currently ${state.telemetry.enabled ? 'enabled' : 'disabled'}.`);
38+
return Promise.resolve();
39+
}
40+
const state = loadState();
41+
state.telemetry.enabled = options.enabled ?? false;
42+
saveState(state);
43+
success(`Telemetry ${options.enabled ? 'enabled' : 'disabled'}.`);
44+
return Promise.resolve();
45+
}

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;
@@ -75,13 +76,11 @@ async function setupScanEnvironment(options: {
7576

7677
function validateScanOptions(options: { file?: string; stdin?: boolean }): void {
7778
if (!options.file && !options.stdin) {
78-
error('Either --file or --stdin is required');
79-
process.exit(1);
79+
throw new InvalidOptionError('Either --file or --stdin is required');
8080
}
8181

8282
if (options.file && options.stdin) {
83-
error('Cannot use both --file and --stdin');
84-
process.exit(1);
83+
throw new InvalidOptionError('Cannot use both --file and --stdin');
8584
}
8685
}
8786

@@ -100,15 +99,15 @@ async function setupAuth(): Promise<{ authUrl: string; authToken: string }> {
10099

101100
if (!activeConnection) {
102101
logAuthConfigError();
103-
process.exit(1);
102+
throw new CommandFailedError('sonar-secrets authentication is not configured');
104103
}
105104

106105
const authUrl = activeConnection.serverUrl;
107106
const authToken = await getToken(authUrl, activeConnection.orgKey);
108107

109108
if (!authUrl || !authToken) {
110109
logAuthConfigError();
111-
process.exit(1);
110+
throw new CommandFailedError('sonar-secrets authentication is not configured');
112111
}
113112

114113
return { authUrl, authToken };
@@ -139,13 +138,11 @@ async function performFileScan(
139138
scanStartTime: number,
140139
): Promise<void> {
141140
if (!file) {
142-
error('File path is required');
143-
process.exit(1);
141+
throw new InvalidOptionError('File path is required');
144142
}
145143

146144
if (!existsSync(file)) {
147-
error(`File not found: ${file}`);
148-
process.exit(1);
145+
throw new InvalidOptionError(`File not found: ${file}`);
149146
}
150147

151148
const result = await runScan(binaryPath, file, authUrl, authToken);
@@ -163,7 +160,7 @@ function validateCheckCommandEnvironment(binaryPath: string): void {
163160
if (!existsSync(binaryPath)) {
164161
error('sonar-secrets is not installed');
165162
text(' Install with: sonar install secrets');
166-
process.exit(1);
163+
throw new CommandFailedError('sonar-secrets is not installed');
167164
}
168165
}
169166

@@ -271,15 +268,13 @@ function handleScanSuccess(result: { stdout: string }, scanDurationMs: number):
271268
text(` Duration: ${scanDurationMs}ms`);
272269
displayScanResults(scanResult);
273270
blank();
274-
process.exit(0);
275271
} catch (parseError) {
276272
logger.debug(`Failed to parse JSON output: ${(parseError as Error).message}`);
277273
blank();
278274
success('Scan completed successfully');
279275
blank();
280276
print(result.stdout);
281277
blank();
282-
process.exit(0);
283278
}
284279
}
285280

@@ -332,10 +327,18 @@ function handleScanFailure(
332327
}
333328
blank();
334329
// Binary exit 1 = secrets found — remap to 51 so hooks can distinguish from generic errors
335-
process.exit(exitCode === 1 ? SECRET_SCAN_POSITIVE_EXIT_CODE : exitCode);
330+
process.exitCode = exitCode === 1 ? SECRET_SCAN_POSITIVE_EXIT_CODE : exitCode;
336331
}
337332

338333
function handleScanError(err: unknown): void {
334+
if (err instanceof InvalidOptionError) {
335+
throw err;
336+
}
337+
338+
if (err instanceof CommandFailedError) {
339+
throw err;
340+
}
341+
339342
const errorMessage = (err as Error).message;
340343

341344
blank();
@@ -355,5 +358,5 @@ function handleScanError(err: unknown): void {
355358
}
356359

357360
blank();
358-
process.exit(1);
361+
throw new CommandFailedError(errorMessage);
359362
}

0 commit comments

Comments
 (0)