Skip to content

Commit c0ff0ea

Browse files
CLI-20 CLI update command wip
1 parent 4103cfb commit c0ff0ea

File tree

6 files changed

+589
-0
lines changed

6 files changed

+589
-0
lines changed

src/commands/update.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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+
// CLI self-update command
22+
23+
import { VERSION } from '../version.js';
24+
import {
25+
fetchLatestCliVersion,
26+
compareVersions,
27+
performSelfUpdate,
28+
} from '../lib/self-update.js';
29+
import { text, blank, success, warn, info, withSpinner } from '../ui/index.js';
30+
31+
/**
32+
* Core version check: compare current version against latest, return update info.
33+
*/
34+
async function checkVersion(): Promise<{
35+
current: string;
36+
latest: string;
37+
hasUpdate: boolean;
38+
}> {
39+
const latest = await withSpinner('Checking for updates', fetchLatestCliVersion);
40+
const hasUpdate = compareVersions(latest, VERSION) > 0;
41+
return { current: VERSION, latest, hasUpdate };
42+
}
43+
44+
/**
45+
* sonar update --check
46+
* Check for updates and notify the user, but do not install.
47+
*/
48+
export async function updateCheckCommand(): Promise<void> {
49+
text(`\nCurrent version: v${VERSION}\n`);
50+
51+
const { latest, hasUpdate } = await checkVersion();
52+
53+
if (!hasUpdate) {
54+
blank();
55+
success(`Already on the latest version (v${VERSION})`);
56+
return;
57+
}
58+
59+
blank();
60+
warn(`Update available: v${VERSION} → v${latest}`);
61+
text(` Run: sonar update`);
62+
}
63+
64+
/**
65+
* sonar update
66+
* Check for updates and install if one is available (binary installs only).
67+
* With --force, reinstall even if already on the latest version.
68+
*/
69+
export async function updateCommand(options: { check?: boolean; force?: boolean }): Promise<void> {
70+
if (options.check) {
71+
await updateCheckCommand();
72+
return;
73+
}
74+
75+
text(`\nCurrent version: v${VERSION}\n`);
76+
77+
const { latest, hasUpdate } = await checkVersion();
78+
79+
if (!hasUpdate && !options.force) {
80+
blank();
81+
success(`Already on the latest version (v${VERSION})`);
82+
return;
83+
}
84+
85+
if (!hasUpdate && options.force) {
86+
info(`Forcing reinstall of v${latest}`);
87+
} else {
88+
info(`Update available: v${VERSION} → v${latest}`);
89+
}
90+
91+
blank();
92+
const installedVersion = await withSpinner(
93+
`Downloading and installing v${latest}`,
94+
() => performSelfUpdate(latest)
95+
);
96+
97+
blank();
98+
success(`Updated to v${installedVersion}`);
99+
text(` Path: ${process.execPath}`);
100+
}

src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { authLoginCommand, authLogoutCommand, authPurgeCommand, authStatusComman
4040
import { secretInstallCommand, secretStatusCommand } from './commands/secret.js';
4141
import { analyzeSecretsCommand } from './commands/analyze.js';
4242
import { projectsSearchCommand } from './commands/projects.js';
43+
import { updateCommand } from './commands/update.js';
4344

4445
const program = new Command();
4546

@@ -154,6 +155,16 @@ auth
154155
await authStatusCommand();
155156
});
156157

158+
// Check for and install CLI updates
159+
program
160+
.command('update')
161+
.description('Check for and install CLI updates')
162+
.option('--check', 'Check for updates without installing')
163+
.option('--force', 'Force update even if already on the latest version')
164+
.action(async (options) => {
165+
await runCommand(() => updateCommand(options));
166+
});
167+
157168
// Analyze code for security issues
158169
const analyze = program
159170
.command('analyze')

src/lib/config-constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export const BIN_DIR = join(CLI_DIR, 'bin');
6666

6767
export const SONARSOURCE_BINARIES_URL = 'https://binaries.sonarsource.com';
6868
export const SONAR_SECRETS_DIST_PREFIX = 'CommercialDistribution/sonar-secrets';
69+
export const SONARQUBE_CLI_DIST_PREFIX = 'Distribution/sonarqube-cli';
6970

7071
// ---------------------------------------------------------------------------
7172
// SonarCloud

src/lib/self-update.ts

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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+
// Self-update logic for the sonarqube-cli binary
22+
23+
import { existsSync } from 'node:fs';
24+
import { copyFile, chmod, rename, unlink, mkdtemp, writeFile, rm } from 'node:fs/promises';
25+
import { join } from 'node:path';
26+
import { tmpdir } from 'node:os';
27+
import { spawnProcess } from './process.js';
28+
import { VERSION } from '../version.js';
29+
import { SONARSOURCE_BINARIES_URL, SONARQUBE_CLI_DIST_PREFIX } from './config-constants.js';
30+
import { detectPlatform } from './platform-detector.js';
31+
import logger from './logger.js';
32+
33+
const REQUEST_TIMEOUT_MS = 30000;
34+
const DOWNLOAD_TIMEOUT_MS = 120000;
35+
36+
/**
37+
* Fetch the latest available CLI version from binaries.sonarsource.com
38+
*/
39+
export async function fetchLatestCliVersion(): Promise<string> {
40+
const url = `${SONARSOURCE_BINARIES_URL}/${SONARQUBE_CLI_DIST_PREFIX}/latest-version.txt`;
41+
42+
const response = await fetch(url, {
43+
headers: { 'User-Agent': `sonarqube-cli/${VERSION}` },
44+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
45+
});
46+
47+
if (!response.ok) {
48+
throw new Error(`Failed to fetch latest version: ${response.status} ${response.statusText}`);
49+
}
50+
51+
const version = (await response.text()).trim();
52+
if (!version) {
53+
throw new Error('Could not determine latest version from release server');
54+
}
55+
56+
return version;
57+
}
58+
59+
/**
60+
* Compare two dot-separated version strings numerically.
61+
* Returns negative if a < b, positive if a > b, 0 if equal.
62+
*/
63+
export function compareVersions(a: string, b: string): number {
64+
return a.localeCompare(b, undefined, { numeric: true });
65+
}
66+
67+
/**
68+
* Build the download URL for a given CLI version on the current platform.
69+
* Naming convention: sonarqube-cli-<version>-<os>-<arch>.exe
70+
*/
71+
function buildCliDownloadUrl(version: string): string {
72+
const platform = detectPlatform();
73+
const filename = `sonarqube-cli-${version}-${platform.os}-${platform.arch}.exe`;
74+
return `${SONARSOURCE_BINARIES_URL}/${SONARQUBE_CLI_DIST_PREFIX}/${filename}`;
75+
}
76+
77+
/**
78+
* Download the CLI binary to a temporary file and return its path.
79+
*/
80+
async function downloadToTemp(url: string): Promise<{ tmpDir: string; tmpFile: string }> {
81+
const tmpDir = await mkdtemp(join(tmpdir(), 'sonar-update-'));
82+
const tmpFile = join(tmpDir, 'sonar.download');
83+
84+
logger.debug(`Downloading CLI from: ${url}`);
85+
86+
const response = await fetch(url, {
87+
headers: { 'User-Agent': `sonarqube-cli/${VERSION}` },
88+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
89+
});
90+
91+
if (!response.ok) {
92+
throw new Error(`Download failed: ${response.status} ${response.statusText}`);
93+
}
94+
95+
const buffer = await response.arrayBuffer();
96+
await writeFile(tmpFile, Buffer.from(buffer));
97+
98+
return { tmpDir, tmpFile };
99+
}
100+
101+
/**
102+
* Verify a binary responds correctly to --version, returning the version string.
103+
*/
104+
async function verifyBinary(binaryPath: string): Promise<string> {
105+
const result = await spawnProcess(binaryPath, ['--version'], {
106+
stdout: 'pipe',
107+
stderr: 'pipe',
108+
});
109+
110+
if (result.exitCode !== 0) {
111+
throw new Error('Downloaded binary failed version check');
112+
}
113+
114+
const combined = result.stdout + ' ' + result.stderr;
115+
// eslint-disable-next-line sonarjs/regex-complexity -- bounded quantifiers prevent backtracking
116+
const match = /(\d{1,20}(?:\.\d{1,20}){1,3})/.exec(combined);
117+
if (!match) {
118+
throw new Error('Could not parse version from downloaded binary output');
119+
}
120+
121+
return match[1];
122+
}
123+
124+
/**
125+
* Remove macOS quarantine attribute from the file (no-op if not quarantined).
126+
*/
127+
async function removeQuarantine(filePath: string): Promise<void> {
128+
try {
129+
await spawnProcess('xattr', ['-d', 'com.apple.quarantine', filePath], {
130+
stdout: 'pipe',
131+
stderr: 'pipe',
132+
});
133+
} catch {
134+
// Binary is not quarantined — this is expected and fine
135+
}
136+
}
137+
138+
/**
139+
* Perform an in-place binary self-update with rollback on failure.
140+
*
141+
* Strategy:
142+
* 1. Download new binary to system tmpdir
143+
* 2. Copy to <binaryPath>.new (same FS → avoids cross-device rename)
144+
* 3. Rename current binary to <binaryPath>.backup (atomic)
145+
* 4. Rename .new to current path (atomic)
146+
* 5. Verify the new binary works
147+
* 6. On success: remove backup
148+
* 7. On failure: restore backup, throw
149+
*/
150+
export async function performSelfUpdate(version: string): Promise<string> {
151+
const currentBinaryPath = process.execPath;
152+
const newBinaryPath = `${currentBinaryPath}.new`;
153+
const backupPath = `${currentBinaryPath}.backup`;
154+
const downloadUrl = buildCliDownloadUrl(version);
155+
const platform = detectPlatform();
156+
157+
let tmpDir: string | null = null;
158+
159+
try {
160+
// --- Download ---
161+
const { tmpDir: td, tmpFile } = await downloadToTemp(downloadUrl);
162+
tmpDir = td;
163+
164+
// Set permissions before moving
165+
if (platform.os !== 'windows') {
166+
await chmod(tmpFile, 0o755);
167+
}
168+
169+
if (platform.os === 'macos') {
170+
await removeQuarantine(tmpFile);
171+
}
172+
173+
// --- Copy to binary directory (handles cross-filesystem download) ---
174+
await copyFile(tmpFile, newBinaryPath);
175+
if (platform.os !== 'windows') {
176+
await chmod(newBinaryPath, 0o755);
177+
}
178+
179+
// --- Atomic swap ---
180+
if (existsSync(currentBinaryPath)) {
181+
await rename(currentBinaryPath, backupPath);
182+
}
183+
await rename(newBinaryPath, currentBinaryPath);
184+
185+
// --- Verify ---
186+
try {
187+
const installedVersion = await verifyBinary(currentBinaryPath);
188+
189+
// Success: remove backup
190+
if (existsSync(backupPath)) {
191+
await unlink(backupPath);
192+
}
193+
194+
return installedVersion;
195+
} catch (verifyErr) {
196+
// Rollback: restore backup
197+
logger.warn(`Verification failed, rolling back: ${(verifyErr as Error).message}`);
198+
if (existsSync(backupPath)) {
199+
await rename(backupPath, currentBinaryPath);
200+
}
201+
throw verifyErr;
202+
}
203+
} finally {
204+
// Best-effort cleanup of temp directory
205+
if (tmpDir) {
206+
try {
207+
await rm(tmpDir, { recursive: true, force: true });
208+
} catch {
209+
// Ignore cleanup errors
210+
}
211+
}
212+
213+
// Best-effort cleanup of .new file if it somehow survived
214+
if (existsSync(newBinaryPath)) {
215+
try {
216+
await unlink(newBinaryPath);
217+
} catch {
218+
// Ignore cleanup errors
219+
}
220+
}
221+
}
222+
}

0 commit comments

Comments
 (0)