Skip to content

Commit 1262aa7

Browse files
CLI-20 call install script during self-update
1 parent ccb531a commit 1262aa7

File tree

4 files changed

+108
-158
lines changed

4 files changed

+108
-158
lines changed

src/commands/self-update.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,11 @@ export async function selfUpdateCommand(options: { check?: boolean; force?: bool
8787
}
8888

8989
blank();
90-
const installedVersion = await withSpinner(
91-
`Downloading and installing v${latest}`,
92-
() => performSelfUpdate(latest)
90+
await withSpinner(
91+
`Installing v${latest}`,
92+
performSelfUpdate
9393
);
9494

9595
blank();
96-
success(`Updated to v${installedVersion}`);
97-
text(` Path: ${process.execPath}`);
96+
success(`Updated to v${latest}`);
9897
}

src/lib/self-update.ts

Lines changed: 43 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,14 @@
2020

2121
// Self-update logic for the sonarqube-cli binary
2222

23-
import { existsSync } from 'node:fs';
24-
import { copyFile, chmod, rename, unlink, mkdtemp, writeFile, rm } from 'node:fs/promises';
23+
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
2524
import { join } from 'node:path';
26-
import { tmpdir } from 'node:os';
25+
import { tmpdir, platform } from 'node:os';
2726
import { spawnProcess } from './process.js';
2827
import { version as VERSION } from '../../package.json';
29-
import { SONARSOURCE_BINARIES_URL, SONARQUBE_CLI_DIST_PREFIX } from './config-constants.js';
30-
import { detectPlatform } from './platform-detector.js';
3128
import logger from './logger.js';
3229

3330
const REQUEST_TIMEOUT_MS = 30000;
34-
const DOWNLOAD_TIMEOUT_MS = 120000;
3531

3632
/**
3733
* Fetch the latest available CLI version from binaries.sonarsource.com
@@ -64,169 +60,72 @@ export function compareVersions(a: string, b: string): number {
6460
return a.localeCompare(b, undefined, { numeric: true });
6561
}
6662

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}`;
63+
function isWindows(): boolean {
64+
return platform() === 'win32';
7565
}
7666

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');
67+
const INSTALL_SCRIPT_URL_SH = 'https://gist.githubusercontent.com/kirill-knize-sonarsource/663e7735f883c3b624575f27276a6b79/raw/b9e6add7371f16922a6a7a69d56822906b9e5758/install.sh';
68+
const INSTALL_SCRIPT_URL_PS1 = 'https://gist.githubusercontent.com/kirill-knize-sonarsource/d75dd5f99228f5a67bcd11ec7d2ed295/raw/a5237e27b0c7bff9a5c7bdeec5fe4b112299b5d8/install.ps1';
69+
70+
async function fetchInstallScript(): Promise<string> {
71+
const url = isWindows() ? INSTALL_SCRIPT_URL_PS1 : INSTALL_SCRIPT_URL_SH;
8372

84-
logger.debug(`Downloading SonarQubeCLI from: ${url}`);
73+
logger.debug(`Fetching install script from: ${url}`);
8574

8675
const response = await fetch(url, {
8776
headers: { 'User-Agent': `sonarqube-cli/${VERSION}` },
88-
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
77+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
8978
});
9079

9180
if (!response.ok) {
92-
throw new Error(`Download failed: ${response.status} ${response.statusText}`);
81+
throw new Error(`Failed to fetch install script: ${response.status} ${response.statusText}`);
9382
}
9483

95-
const buffer = await response.arrayBuffer();
96-
await writeFile(tmpFile, Buffer.from(buffer));
97-
98-
return { tmpDir, tmpFile };
84+
return response.text();
9985
}
10086

10187
/**
102-
* Verify a binary responds correctly to --version, returning the version string.
88+
* Perform a self-update by fetching and running the platform install script.
89+
* On Unix/macOS: downloads install.sh and runs it with bash.
90+
* On Windows: downloads install.ps1 and runs it with PowerShell.
10391
*/
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;
92+
export async function performSelfUpdate(): Promise<void> {
93+
const windows = isWindows();
94+
const scriptContent = await fetchInstallScript();
11595

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-
}
96+
const tmpDir = await mkdtemp(join(tmpdir(), 'sonar-update-'));
12397

124-
/**
125-
* Remove macOS quarantine attribute from the file (no-op if not quarantined).
126-
*/
127-
async function removeQuarantine(filePath: string): Promise<void> {
12898
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-
* Download the new binary, set permissions, and stage it at newBinaryPath.
140-
* Returns the temp directory path so the caller can clean it up.
141-
*/
142-
async function prepareNewBinary(downloadUrl: string, newBinaryPath: string): Promise<string> {
143-
const platform = detectPlatform();
144-
const { tmpDir, tmpFile } = await downloadToTemp(downloadUrl);
99+
if (windows) {
100+
const scriptFile = join(tmpDir, 'install.ps1');
101+
await writeFile(scriptFile, scriptContent, 'utf8');
145102

146-
if (platform.os !== 'windows') {
147-
await chmod(tmpFile, 0o755);
148-
}
149-
if (platform.os === 'macos') {
150-
await removeQuarantine(tmpFile);
151-
}
152-
153-
await copyFile(tmpFile, newBinaryPath);
154-
if (platform.os !== 'windows') {
155-
await chmod(newBinaryPath, 0o755);
156-
}
157-
158-
return tmpDir;
159-
}
103+
const result = await spawnProcess('powershell', ['-ExecutionPolicy', 'Bypass', '-File', scriptFile], {
104+
stdout: 'pipe',
105+
stderr: 'pipe',
106+
});
160107

161-
/**
162-
* Atomically swap newBinaryPath into currentBinaryPath, verify the result, then
163-
* clean up. On verification failure, restores the backup when one was created, or
164-
* removes the failed binary entirely when there was no original to restore.
165-
*/
166-
async function swapAndVerify(currentBinaryPath: string, newBinaryPath: string, backupPath: string): Promise<string> {
167-
const backupCreated = existsSync(currentBinaryPath);
168-
if (backupCreated) {
169-
await rename(currentBinaryPath, backupPath);
170-
}
171-
await rename(newBinaryPath, currentBinaryPath);
172-
173-
try {
174-
const installedVersion = await verifyBinary(currentBinaryPath);
175-
if (backupCreated && existsSync(backupPath)) {
176-
await unlink(backupPath);
177-
}
178-
return installedVersion;
179-
} catch (error_) {
180-
logger.warn(`Verification failed, rolling back: ${(error_ as Error).message}`);
181-
if (backupCreated && existsSync(backupPath)) {
182-
await rename(backupPath, currentBinaryPath);
183-
} else if (!backupCreated) {
184-
try {
185-
await unlink(currentBinaryPath);
186-
} catch {
187-
// Ignore cleanup errors
108+
if (result.exitCode !== 0) {
109+
throw new Error(`Install script failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`);
188110
}
189-
}
190-
throw error_;
191-
}
192-
}
111+
} else {
112+
const scriptFile = join(tmpDir, 'install.sh');
113+
await writeFile(scriptFile, scriptContent, 'utf8');
193114

194-
/**
195-
* Perform an in-place binary self-update with rollback on failure.
196-
*
197-
* Strategy:
198-
* 1. Download new binary to system tmpdir
199-
* 2. Copy to <binaryPath>.new (same FS → avoids cross-device rename)
200-
* 3. Rename current binary to <binaryPath>.backup (atomic)
201-
* 4. Rename .new to current path (atomic)
202-
* 5. Verify the new binary works
203-
* 6. On success: remove backup
204-
* 7. On failure: restore backup (or remove failed binary if no backup), throw
205-
*/
206-
export async function performSelfUpdate(version: string): Promise<string> {
207-
const currentBinaryPath = process.execPath;
208-
const newBinaryPath = `${currentBinaryPath}.new`;
209-
const backupPath = `${currentBinaryPath}.backup`;
115+
const result = await spawnProcess('bash', [scriptFile], {
116+
stdout: 'pipe',
117+
stderr: 'pipe',
118+
});
210119

211-
let tmpDir: string | null = null;
212-
213-
try {
214-
tmpDir = await prepareNewBinary(buildCliDownloadUrl(version), newBinaryPath);
215-
return await swapAndVerify(currentBinaryPath, newBinaryPath, backupPath);
216-
} finally {
217-
if (tmpDir) {
218-
try {
219-
await rm(tmpDir, { recursive: true, force: true });
220-
} catch {
221-
// Ignore cleanup errors
120+
if (result.exitCode !== 0) {
121+
throw new Error(`Install script failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`);
222122
}
223123
}
224-
if (existsSync(newBinaryPath)) {
225-
try {
226-
await unlink(newBinaryPath);
227-
} catch {
228-
// Ignore cleanup errors
229-
}
124+
} finally {
125+
try {
126+
await rm(tmpDir, { recursive: true, force: true });
127+
} catch {
128+
// Ignore cleanup errors
230129
}
231130
}
232131
}

tests/unit/self-update-command.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ describe('selfUpdateCommand', () => {
116116

117117
it('does not call performSelfUpdate when already on the latest version', async () => {
118118
fetchLatestSpy = spyOn(selfUpdate, 'fetchLatestCliVersion').mockResolvedValue(VERSION);
119-
performUpdateSpy = spyOn(selfUpdate, 'performSelfUpdate').mockResolvedValue(VERSION);
119+
performUpdateSpy = spyOn(selfUpdate, 'performSelfUpdate').mockResolvedValue(undefined);
120120

121121
await selfUpdateCommand({});
122122

@@ -125,33 +125,33 @@ describe('selfUpdateCommand', () => {
125125

126126
it('calls performSelfUpdate and shows success when update is available', async () => {
127127
fetchLatestSpy = spyOn(selfUpdate, 'fetchLatestCliVersion').mockResolvedValue('99.99.99');
128-
performUpdateSpy = spyOn(selfUpdate, 'performSelfUpdate').mockResolvedValue('99.99.99');
128+
performUpdateSpy = spyOn(selfUpdate, 'performSelfUpdate').mockResolvedValue(undefined);
129129

130130
await selfUpdateCommand({});
131131

132-
expect(performUpdateSpy).toHaveBeenCalledWith('99.99.99');
132+
expect(performUpdateSpy).toHaveBeenCalled();
133133
const calls = getMockUiCalls();
134134
const successes = calls.filter(c => c.method === 'success').map(c => String(c.args[0]));
135135
expect(successes.some(m => m.includes('Updated to v99.99.99'))).toBe(true);
136136
});
137137

138138
it('with --force, calls performSelfUpdate even when already on the latest version', async () => {
139139
fetchLatestSpy = spyOn(selfUpdate, 'fetchLatestCliVersion').mockResolvedValue(VERSION);
140-
performUpdateSpy = spyOn(selfUpdate, 'performSelfUpdate').mockResolvedValue(VERSION);
140+
performUpdateSpy = spyOn(selfUpdate, 'performSelfUpdate').mockResolvedValue(undefined);
141141

142142
await selfUpdateCommand({ force: true });
143143

144-
expect(performUpdateSpy).toHaveBeenCalledWith(VERSION);
144+
expect(performUpdateSpy).toHaveBeenCalled();
145145
const calls = getMockUiCalls();
146146
const infos = calls.filter(c => c.method === 'info').map(c => String(c.args[0]));
147147
expect(infos.some(m => m.includes('Forcing reinstall'))).toBe(true);
148148
});
149149

150150
it('propagates error from performSelfUpdate', async () => {
151151
fetchLatestSpy = spyOn(selfUpdate, 'fetchLatestCliVersion').mockResolvedValue('99.99.99');
152-
performUpdateSpy = spyOn(selfUpdate, 'performSelfUpdate').mockRejectedValue(new Error('download failed'));
152+
performUpdateSpy = spyOn(selfUpdate, 'performSelfUpdate').mockRejectedValue(new Error('install script failed'));
153153

154-
expect(selfUpdateCommand({})).rejects.toThrow('download failed');
154+
expect(selfUpdateCommand({})).rejects.toThrow('install script failed');
155155
});
156156

157157
it('propagates error when version fetch fails', async () => {

tests/unit/self-update.test.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@
2020

2121
// Tests for src/lib/self-update.ts
2222

23-
import { describe, it, expect, spyOn, afterEach } from 'bun:test';
24-
import { compareVersions, fetchLatestCliVersion } from '../../src/lib/self-update.js';
23+
import { describe, it, expect, spyOn, afterEach, mock } from 'bun:test';
24+
import { compareVersions, fetchLatestCliVersion, performSelfUpdate } from '../../src/lib/self-update.js';
25+
import * as processLib from '../../src/lib/process.js';
2526

2627
describe('compareVersions', () => {
2728
it('returns 0 for equal versions', () => {
@@ -91,3 +92,54 @@ describe('fetchLatestCliVersion', () => {
9192
expect(fetchLatestCliVersion()).rejects.toThrow('network error');
9293
});
9394
});
95+
96+
describe('performSelfUpdate', () => {
97+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
98+
let fetchSpy: any;
99+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
100+
let spawnSpy: any;
101+
102+
afterEach(() => {
103+
fetchSpy?.mockRestore();
104+
spawnSpy?.mockRestore();
105+
});
106+
107+
it('fetches the install script and runs it successfully', async () => {
108+
fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue({
109+
ok: true,
110+
text: async () => '#!/usr/bin/env bash\necho "done"',
111+
} as Response);
112+
spawnSpy = spyOn(processLib, 'spawnProcess').mockResolvedValue({
113+
exitCode: 0,
114+
stdout: 'Installed sonar to: /usr/local/bin/sonar',
115+
stderr: '',
116+
});
117+
118+
await expect(performSelfUpdate()).resolves.toBeUndefined();
119+
expect(spawnSpy).toHaveBeenCalledTimes(1);
120+
});
121+
122+
it('throws when the install script fetch fails', async () => {
123+
fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue({
124+
ok: false,
125+
status: 404,
126+
statusText: 'Not Found',
127+
} as Response);
128+
129+
await expect(performSelfUpdate()).rejects.toThrow('Failed to fetch install script: 404 Not Found');
130+
});
131+
132+
it('throws when the install script exits with a non-zero code', async () => {
133+
fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue({
134+
ok: true,
135+
text: async () => '#!/usr/bin/env bash\nexit 1',
136+
} as Response);
137+
spawnSpy = spyOn(processLib, 'spawnProcess').mockResolvedValue({
138+
exitCode: 1,
139+
stdout: '',
140+
stderr: 'Download failed',
141+
});
142+
143+
await expect(performSelfUpdate()).rejects.toThrow('Install script failed (exit 1): Download failed');
144+
});
145+
});

0 commit comments

Comments
 (0)