|
20 | 20 |
|
21 | 21 | // Self-update logic for the sonarqube-cli binary |
22 | 22 |
|
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'; |
25 | 24 | import { join } from 'node:path'; |
26 | | -import { tmpdir } from 'node:os'; |
| 25 | +import { tmpdir, platform } from 'node:os'; |
27 | 26 | import { spawnProcess } from './process.js'; |
28 | 27 | 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'; |
31 | 28 | import logger from './logger.js'; |
32 | 29 |
|
33 | 30 | const REQUEST_TIMEOUT_MS = 30000; |
34 | | -const DOWNLOAD_TIMEOUT_MS = 120000; |
35 | 31 |
|
36 | 32 | /** |
37 | 33 | * Fetch the latest available CLI version from binaries.sonarsource.com |
@@ -64,169 +60,72 @@ export function compareVersions(a: string, b: string): number { |
64 | 60 | return a.localeCompare(b, undefined, { numeric: true }); |
65 | 61 | } |
66 | 62 |
|
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'; |
75 | 65 | } |
76 | 66 |
|
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; |
83 | 72 |
|
84 | | - logger.debug(`Downloading SonarQubeCLI from: ${url}`); |
| 73 | + logger.debug(`Fetching install script from: ${url}`); |
85 | 74 |
|
86 | 75 | const response = await fetch(url, { |
87 | 76 | headers: { 'User-Agent': `sonarqube-cli/${VERSION}` }, |
88 | | - signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), |
| 77 | + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), |
89 | 78 | }); |
90 | 79 |
|
91 | 80 | 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}`); |
93 | 82 | } |
94 | 83 |
|
95 | | - const buffer = await response.arrayBuffer(); |
96 | | - await writeFile(tmpFile, Buffer.from(buffer)); |
97 | | - |
98 | | - return { tmpDir, tmpFile }; |
| 84 | + return response.text(); |
99 | 85 | } |
100 | 86 |
|
101 | 87 | /** |
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. |
103 | 91 | */ |
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(); |
115 | 95 |
|
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-')); |
123 | 97 |
|
124 | | -/** |
125 | | - * Remove macOS quarantine attribute from the file (no-op if not quarantined). |
126 | | - */ |
127 | | -async function removeQuarantine(filePath: string): Promise<void> { |
128 | 98 | 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'); |
145 | 102 |
|
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 | + }); |
160 | 107 |
|
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}`); |
188 | 110 | } |
189 | | - } |
190 | | - throw error_; |
191 | | - } |
192 | | -} |
| 111 | + } else { |
| 112 | + const scriptFile = join(tmpDir, 'install.sh'); |
| 113 | + await writeFile(scriptFile, scriptContent, 'utf8'); |
193 | 114 |
|
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 | + }); |
210 | 119 |
|
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}`); |
222 | 122 | } |
223 | 123 | } |
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 |
230 | 129 | } |
231 | 130 | } |
232 | 131 | } |
0 commit comments