diff --git a/build-scripts/update-version.sh b/build-scripts/update-version.sh deleted file mode 100644 index 99277523..00000000 --- a/build-scripts/update-version.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/bin/bash - -# Increment build number and rebuild the project. -# -# Usage: -# ./build-scripts/update-version.sh # increment build: 0.1.132 → 0.1.133 -# ./build-scripts/update-version.sh 1.0 # change major.minor, keep build: 0.1.132 → 1.0.132 - -set -e - -BUILD_TIMEOUT=60 # seconds per build step - -# Use gtimeout (GNU coreutils) or timeout (Linux) if available -TIMEOUT_CMD="$(command -v gtimeout 2>/dev/null || command -v timeout 2>/dev/null || echo '')" - -run_with_timeout() { - if [ -n "$TIMEOUT_CMD" ]; then - "$TIMEOUT_CMD" "$BUILD_TIMEOUT" "$@" || { - echo "❌ Command timed out after ${BUILD_TIMEOUT}s: $*" - exit 1 - } - else - "$@" - fi -} - -CURRENT=$(node -e "console.log(require('./package.json').version)") -MAJOR_MINOR=$(echo "$CURRENT" | sed 's/\.[0-9]*$//') -BUILD=$(echo "$CURRENT" | sed 's/.*\.//') - -if [ -n "$1" ]; then - # If the argument already contains at least two dots (e.g. 1.2.3), use it as-is - DOT_COUNT=$(echo "$1" | tr -cd '.' | wc -c | tr -d ' ') - if [ "$DOT_COUNT" -ge 2 ]; then - NEW_VERSION="$1" - else - NEW_VERSION="$1.$BUILD" - fi -else - NEW_VERSION="$MAJOR_MINOR.$((BUILD + 1))" -fi - -echo "🔄 Updating version to $NEW_VERSION..." - -# Update package.json (single source of truth) -echo " 📝 Updating package.json..." -sed -i '' "s/\"version\": \"[^\"]*\"/\"version\": \"$NEW_VERSION\"/" package.json - -# Update VERSION in src/version.ts (preserve license header) -echo " 📝 Updating src/version.ts..." -sed -i '' "s/export const VERSION = '[^']*';/export const VERSION = '$NEW_VERSION';/" src/version.ts - -echo "" -echo "✅ Version updated to $NEW_VERSION" -echo "" - -# Build TypeScript -echo "🔨 Building TypeScript..." -run_with_timeout npm run build - -# Build binary -echo "📦 Building binary..." -run_with_timeout npm run build:binary - -# Update Homebrew tap -BREW_FORMULA="/opt/homebrew/Library/Taps/local/homebrew-sonar/Formula/sonar.rb" -if [ -f "$BREW_FORMULA" ]; then - echo "Updating Homebrew tap..." - - # Pack binary with expected name - cp dist/sonarqube-cli /tmp/sonar-cli - cd /tmp && tar -czf ~/sonar-cli.tar.gz sonar-cli - cd - > /dev/null - - NEW_SHA256=$(shasum -a 256 ~/sonar-cli.tar.gz | awk '{print $1}') - - sed -i '' "s/version \"[^\"]*\"/version \"$NEW_VERSION\"/" "$BREW_FORMULA" - sed -i '' "s/sha256 \"[^\"]*\"/sha256 \"$NEW_SHA256\"/" "$BREW_FORMULA" - - run_with_timeout brew reinstall local/sonar/sonar > /dev/null 2>&1 || true - brew link --overwrite sonar > /dev/null 2>&1 || true - - echo " • Formula: $NEW_VERSION (sha256: ${NEW_SHA256:0:16}...)" -fi - -echo "" -echo "🎉 Done! Verifying..." -sonar --version diff --git a/spec.yaml b/spec.yaml index c3bd2c73..7d064a68 100644 --- a/spec.yaml +++ b/spec.yaml @@ -250,6 +250,31 @@ commands: - command: sonar list projects --page 2 --page-size 50 description: Paginate through projects + # Self-update the CLI + - name: self-update + description: Update the sonar CLI to the latest version + handler: ./src/commands/self-update.ts + options: + - name: check + type: boolean + description: Check for updates without installing + default: false + + - name: force + type: boolean + description: Force reinstall even if already on the latest version + default: false + + examples: + - command: sonar self-update + description: Update the CLI to the latest version + + - command: sonar self-update --check + description: Check if an update is available without installing + + - command: sonar self-update --force + description: Reinstall the latest version even if already up to date + # Analyze code for security issues - name: analyze description: Analyze code for security issues diff --git a/src/commands/self-update.ts b/src/commands/self-update.ts new file mode 100644 index 00000000..0acd4a70 --- /dev/null +++ b/src/commands/self-update.ts @@ -0,0 +1,97 @@ +/* + * SonarQube CLI + * Copyright (C) 2026 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +// CLI self-update command + +import { version as VERSION } from '../../package.json'; +import { + fetchLatestCliVersion, + compareVersions, + performSelfUpdate, +} from '../lib/self-update.js'; +import { text, blank, success, warn, info, withSpinner } from '../ui/index.js'; + +/** + * Core version check: compare current version against latest, return update info. + */ +async function checkVersion(): Promise<{ + current: string; + latest: string; + hasUpdate: boolean; +}> { + const latest = await withSpinner('Checking for updates', fetchLatestCliVersion); + const hasUpdate = compareVersions(latest, VERSION) > 0; + return { current: VERSION, latest, hasUpdate }; +} + +/** + * sonar self-update --check + * Check for updates and notify the user, but do not install. + */ +export async function selfUpdateCheckCommand(): Promise { + text(`\nCurrent version: v${VERSION}\n`); + + const { latest, hasUpdate } = await checkVersion(); + + if (!hasUpdate) { + blank(); + success(`Already on the latest version (v${VERSION})`); + return; + } + + blank(); + warn(`Update available: v${VERSION} → v${latest}`); + text(` Run: sonar self-update`); +} + +/** + * sonar self-update + * Check for updates and install if one is available (binary installs only). + * With --force, reinstall even if already on the latest version. + */ +export async function selfUpdateCommand(options: { check?: boolean; force?: boolean }): Promise { + if (options.check) { + await selfUpdateCheckCommand(); + return; + } + + text(`\nCurrent version: v${VERSION}\n`); + + const { latest, hasUpdate } = await checkVersion(); + + if (options.force) { + info(`Forcing reinstall of v${latest}`); + } else if (hasUpdate) { + info(`Update available: v${VERSION} → v${latest}`); + } else { + blank(); + success(`Already on the latest version (v${VERSION})`); + return; + } + + blank(); + await withSpinner( + `Installing v${latest}`, + performSelfUpdate + ); + + blank(); + success(`Updated to v${latest}`); +} diff --git a/src/index.ts b/src/index.ts index 99dca6d5..251d4b83 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,7 @@ import { authLoginCommand, authLogoutCommand, authPurgeCommand, authStatusComman import { secretInstallCommand, secretStatusCommand } from './commands/secret.js'; import { analyzeSecretsCommand } from './commands/analyze.js'; import { projectsSearchCommand } from './commands/projects.js'; +import { selfUpdateCommand } from './commands/self-update.js'; const program = new Command(); @@ -154,6 +155,16 @@ auth await authStatusCommand(); }); +// Check for and install CLI updates +program + .command('self-update') + .description('Check for and install latest SonarQube CLI') + .option('--check', 'Check for updates without installing') + .option('--force', 'Force update even if already on the latest version') + .action(async (options) => { + await runCommand(() => selfUpdateCommand(options)); + }); + // Analyze code for security issues const analyze = program .command('analyze') diff --git a/src/lib/config-constants.ts b/src/lib/config-constants.ts index 67a2301d..84824593 100644 --- a/src/lib/config-constants.ts +++ b/src/lib/config-constants.ts @@ -66,6 +66,7 @@ export const BIN_DIR = join(CLI_DIR, 'bin'); export const SONARSOURCE_BINARIES_URL = 'https://binaries.sonarsource.com'; export const SONAR_SECRETS_DIST_PREFIX = 'CommercialDistribution/sonar-secrets'; +export const SONARQUBE_CLI_DIST_PREFIX = 'Distribution/sonarqube-cli'; // --------------------------------------------------------------------------- // SonarCloud diff --git a/src/lib/self-update.ts b/src/lib/self-update.ts new file mode 100644 index 00000000..ce852cdd --- /dev/null +++ b/src/lib/self-update.ts @@ -0,0 +1,131 @@ +/* + * SonarQube CLI + * Copyright (C) 2026 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +// Self-update logic for the sonarqube-cli binary + +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir, platform } from 'node:os'; +import { spawnProcess } from './process.js'; +import { version as VERSION } from '../../package.json'; +import logger from './logger.js'; + +const REQUEST_TIMEOUT_MS = 30000; + +/** + * Fetch the latest available CLI version from binaries.sonarsource.com + */ +export async function fetchLatestCliVersion(): Promise { + const url = `https://gist.githubusercontent.com/sophio-japharidze-sonarsource/ba819f4ad09141c2391ed26db7336a36/raw/ab6769b7d7fee430fd0388d6b27be86344e850b4/latest-version.txt`; + + const response = await fetch(url, { + headers: { 'User-Agent': `sonarqube-cli/${VERSION}` }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new Error(`Failed to fetch latest version: ${response.status} ${response.statusText}`); + } + + const version = (await response.text()).trim(); + if (!version) { + throw new Error('Could not determine latest version'); + } + + return version; +} + +/** + * Compare two dot-separated version strings numerically. + * Returns negative if a < b, positive if a > b, 0 if equal. + */ +export function compareVersions(a: string, b: string): number { + return a.localeCompare(b, undefined, { numeric: true }); +} + +function isWindows(): boolean { + return platform() === 'win32'; +} + +const INSTALL_SCRIPT_URL_SH = 'https://gist.githubusercontent.com/kirill-knize-sonarsource/663e7735f883c3b624575f27276a6b79/raw/b9e6add7371f16922a6a7a69d56822906b9e5758/install.sh'; +const INSTALL_SCRIPT_URL_PS1 = 'https://gist.githubusercontent.com/kirill-knize-sonarsource/d75dd5f99228f5a67bcd11ec7d2ed295/raw/a5237e27b0c7bff9a5c7bdeec5fe4b112299b5d8/install.ps1'; + +async function fetchInstallScript(): Promise { + const url = isWindows() ? INSTALL_SCRIPT_URL_PS1 : INSTALL_SCRIPT_URL_SH; + + logger.debug(`Fetching install script from: ${url}`); + + const response = await fetch(url, { + headers: { 'User-Agent': `sonarqube-cli/${VERSION}` }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new Error(`Failed to fetch install script: ${response.status} ${response.statusText}`); + } + + return response.text(); +} + +/** + * Perform a self-update by fetching and running the platform install script. + * On Unix/macOS: downloads install.sh and runs it with bash. + * On Windows: downloads install.ps1 and runs it with PowerShell. + */ +export async function performSelfUpdate(): Promise { + const windows = isWindows(); + const scriptContent = await fetchInstallScript(); + + const tmpDir = await mkdtemp(join(tmpdir(), 'sonar-update-')); + + try { + if (windows) { + const scriptFile = join(tmpDir, 'install.ps1'); + await writeFile(scriptFile, scriptContent, 'utf8'); + + const result = await spawnProcess('powershell', ['-ExecutionPolicy', 'Bypass', '-File', scriptFile], { + stdout: 'pipe', + stderr: 'pipe', + }); + + if (result.exitCode !== 0) { + throw new Error(`Install script failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`); + } + } else { + const scriptFile = join(tmpDir, 'install.sh'); + await writeFile(scriptFile, scriptContent, 'utf8'); + + const result = await spawnProcess('bash', [scriptFile], { + stdout: 'pipe', + stderr: 'pipe', + }); + + if (result.exitCode !== 0) { + throw new Error(`Install script failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`); + } + } + } finally { + try { + await rm(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } +} diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 6b6bb570..90450730 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -22,9 +22,9 @@ import { it, expect } from 'bun:test'; -import { mkdirSync, rmSync, existsSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; +import { mkdirSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import { loadConfig, saveConfig, newConfig } from '../../src/bootstrap/config.js'; it('config: save and load', async () => { diff --git a/tests/unit/self-update-command.test.ts b/tests/unit/self-update-command.test.ts new file mode 100644 index 00000000..a62190d9 --- /dev/null +++ b/tests/unit/self-update-command.test.ts @@ -0,0 +1,162 @@ +/* + * SonarQube CLI + * Copyright (C) 2026 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +// Tests for src/commands/self-update.ts + +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'; +import { selfUpdateCheckCommand, selfUpdateCommand } from '../../src/commands/self-update.js'; +import * as selfUpdate from '../../src/lib/self-update.js'; +import { version as VERSION } from '../../package.json'; +import { setMockUi, getMockUiCalls, clearMockUiCalls } from '../../src/ui/index.js'; + +describe('selfUpdateCheckCommand', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let fetchLatestSpy: any; + + beforeEach(() => { + setMockUi(true); + clearMockUiCalls(); + }); + + afterEach(() => { + fetchLatestSpy?.mockRestore(); + setMockUi(false); + }); + + it('shows success when already on the latest version', async () => { + fetchLatestSpy = spyOn(selfUpdate, 'fetchLatestCliVersion').mockResolvedValue(VERSION); + + await selfUpdateCheckCommand(); + + const calls = getMockUiCalls(); + const successes = calls.filter(c => c.method === 'success').map(c => String(c.args[0])); + expect(successes.some(m => m.includes('Already on the latest version'))).toBe(true); + }); + + it('shows a warning when an update is available', async () => { + fetchLatestSpy = spyOn(selfUpdate, 'fetchLatestCliVersion').mockResolvedValue('99.99.99'); + + await selfUpdateCheckCommand(); + + const calls = getMockUiCalls(); + const warns = calls.filter(c => c.method === 'warn').map(c => String(c.args[0])); + expect(warns.some(m => m.includes('Update available') && m.includes('99.99.99'))).toBe(true); + }); + + it('shows a hint to run sonar self-update when an update is available', async () => { + fetchLatestSpy = spyOn(selfUpdate, 'fetchLatestCliVersion').mockResolvedValue('99.99.99'); + + await selfUpdateCheckCommand(); + + const calls = getMockUiCalls(); + const texts = calls.filter(c => c.method === 'text').map(c => String(c.args[0])); + expect(texts.some(m => m.includes('sonar self-update'))).toBe(true); + }); + + it('propagates error when version fetch fails', async () => { + fetchLatestSpy = spyOn(selfUpdate, 'fetchLatestCliVersion').mockRejectedValue(new Error('network timeout')); + + expect(selfUpdateCheckCommand()).rejects.toThrow('network timeout'); + }); +}); + +describe('selfUpdateCommand', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let fetchLatestSpy: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let performUpdateSpy: any; + + beforeEach(() => { + setMockUi(true); + clearMockUiCalls(); + }); + + afterEach(() => { + fetchLatestSpy?.mockRestore(); + performUpdateSpy?.mockRestore(); + setMockUi(false); + }); + + it('delegates to updateCheckCommand when --check is passed', async () => { + fetchLatestSpy = spyOn(selfUpdate, 'fetchLatestCliVersion').mockResolvedValue(VERSION); + + await selfUpdateCommand({ check: true }); + + const calls = getMockUiCalls(); + const successes = calls.filter(c => c.method === 'success').map(c => String(c.args[0])); + expect(successes.some(m => m.includes('Already on the latest version'))).toBe(true); + }); + + it('shows success when already on the latest version', async () => { + fetchLatestSpy = spyOn(selfUpdate, 'fetchLatestCliVersion').mockResolvedValue(VERSION); + + await selfUpdateCommand({}); + + const calls = getMockUiCalls(); + const successes = calls.filter(c => c.method === 'success').map(c => String(c.args[0])); + expect(successes.some(m => m.includes('Already on the latest version'))).toBe(true); + }); + + it('does not call performSelfUpdate when already on the latest version', async () => { + fetchLatestSpy = spyOn(selfUpdate, 'fetchLatestCliVersion').mockResolvedValue(VERSION); + performUpdateSpy = spyOn(selfUpdate, 'performSelfUpdate').mockResolvedValue(undefined); + + await selfUpdateCommand({}); + + expect(performUpdateSpy).not.toHaveBeenCalled(); + }); + + it('calls performSelfUpdate and shows success when update is available', async () => { + fetchLatestSpy = spyOn(selfUpdate, 'fetchLatestCliVersion').mockResolvedValue('99.99.99'); + performUpdateSpy = spyOn(selfUpdate, 'performSelfUpdate').mockResolvedValue(undefined); + + await selfUpdateCommand({}); + + expect(performUpdateSpy).toHaveBeenCalled(); + const calls = getMockUiCalls(); + const successes = calls.filter(c => c.method === 'success').map(c => String(c.args[0])); + expect(successes.some(m => m.includes('Updated to v99.99.99'))).toBe(true); + }); + + it('with --force, calls performSelfUpdate even when already on the latest version', async () => { + fetchLatestSpy = spyOn(selfUpdate, 'fetchLatestCliVersion').mockResolvedValue(VERSION); + performUpdateSpy = spyOn(selfUpdate, 'performSelfUpdate').mockResolvedValue(undefined); + + await selfUpdateCommand({ force: true }); + + expect(performUpdateSpy).toHaveBeenCalled(); + const calls = getMockUiCalls(); + const infos = calls.filter(c => c.method === 'info').map(c => String(c.args[0])); + expect(infos.some(m => m.includes('Forcing reinstall'))).toBe(true); + }); + + it('propagates error from performSelfUpdate', async () => { + fetchLatestSpy = spyOn(selfUpdate, 'fetchLatestCliVersion').mockResolvedValue('99.99.99'); + performUpdateSpy = spyOn(selfUpdate, 'performSelfUpdate').mockRejectedValue(new Error('install script failed')); + + expect(selfUpdateCommand({})).rejects.toThrow('install script failed'); + }); + + it('propagates error when version fetch fails', async () => { + fetchLatestSpy = spyOn(selfUpdate, 'fetchLatestCliVersion').mockRejectedValue(new Error('network timeout')); + + expect(selfUpdateCommand({})).rejects.toThrow('network timeout'); + }); +}); diff --git a/tests/unit/self-update.test.ts b/tests/unit/self-update.test.ts new file mode 100644 index 00000000..7ce51998 --- /dev/null +++ b/tests/unit/self-update.test.ts @@ -0,0 +1,157 @@ +/* + * SonarQube CLI + * Copyright (C) 2026 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +// Tests for src/lib/self-update.ts + +import { describe, it, expect, spyOn, afterEach, mock } from 'bun:test'; +import { compareVersions, fetchLatestCliVersion, performSelfUpdate } from '../../src/lib/self-update.js'; +import * as processLib from '../../src/lib/process.js'; + +describe('compareVersions', () => { + it('returns 0 for equal versions', () => { + expect(compareVersions('1.2.3', '1.2.3')).toBe(0); + expect(compareVersions('0.0.1', '0.0.1')).toBe(0); + }); + + it('returns negative when a is less than b', () => { + expect(compareVersions('1.0.0', '2.0.0')).toBeLessThan(0); + expect(compareVersions('1.2.3', '1.2.4')).toBeLessThan(0); + expect(compareVersions('0.9.0', '1.0.0')).toBeLessThan(0); + }); + + it('returns positive when a is greater than b', () => { + expect(compareVersions('2.0.0', '1.0.0')).toBeGreaterThan(0); + expect(compareVersions('1.2.4', '1.2.3')).toBeGreaterThan(0); + expect(compareVersions('1.0.0', '0.9.0')).toBeGreaterThan(0); + }); + + it('compares minor/patch segments numerically, not lexicographically', () => { + expect(compareVersions('1.9.0', '1.10.0')).toBeLessThan(0); + expect(compareVersions('1.10.0', '1.9.0')).toBeGreaterThan(0); + expect(compareVersions('1.0.9', '1.0.10')).toBeLessThan(0); + }); +}); + +describe('fetchLatestCliVersion', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let fetchSpy: any; + + afterEach(() => { + fetchSpy?.mockRestore(); + }); + + it('returns the trimmed version string on success', async () => { + fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + text: async () => '1.5.0\n', + } as Response); + + const version = await fetchLatestCliVersion(); + expect(version).toBe('1.5.0'); + }); + + it('throws when the server returns a non-OK response', async () => { + fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + } as Response); + + expect(fetchLatestCliVersion()).rejects.toThrow('Failed to fetch latest version: 503 Service Unavailable'); + }); + + it('throws when the response body is empty', async () => { + fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + text: async () => ' ', + } as Response); + + expect(fetchLatestCliVersion()).rejects.toThrow('Could not determine latest version'); + }); + + it('throws when the network request fails', async () => { + fetchSpy = spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network error')); + + expect(fetchLatestCliVersion()).rejects.toThrow('network error'); + }); +}); + +const INSTALL_SCRIPT_URL_SH = 'https://gist.githubusercontent.com/kirill-knize-sonarsource/663e7735f883c3b624575f27276a6b79/raw/b9e6add7371f16922a6a7a69d56822906b9e5758/install.sh'; +const INSTALL_SCRIPT_URL_PS1 = 'https://gist.githubusercontent.com/kirill-knize-sonarsource/d75dd5f99228f5a67bcd11ec7d2ed295/raw/a5237e27b0c7bff9a5c7bdeec5fe4b112299b5d8/install.ps1'; + +describe('performSelfUpdate', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let fetchSpy: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let spawnSpy: any; + + afterEach(() => { + fetchSpy?.mockRestore(); + spawnSpy?.mockRestore(); + }); + + it('fetches the Unix install script and runs it with bash on non-Windows', async () => { + fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + text: async () => '#!/usr/bin/env bash\necho "done"', + } as Response); + spawnSpy = spyOn(processLib, 'spawnProcess').mockResolvedValue({ + exitCode: 0, + stdout: 'Installation complete.', + stderr: '', + }); + + await expect(performSelfUpdate()).resolves.toBeUndefined(); + + const [[fetchedUrl]] = fetchSpy.mock.calls; + expect(fetchedUrl).toBe(INSTALL_SCRIPT_URL_SH); + + const [[interpreter]] = spawnSpy.mock.calls; + expect(interpreter).toBe('bash'); + }); + + it('throws when the install script fetch fails', async () => { + fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + } as Response); + + await expect(performSelfUpdate()).rejects.toThrow('Failed to fetch install script: 404 Not Found'); + }); + + it('throws when the install script exits with a non-zero code', async () => { + fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + text: async () => '#!/usr/bin/env bash\nexit 1', + } as Response); + spawnSpy = spyOn(processLib, 'spawnProcess').mockResolvedValue({ + exitCode: 1, + stdout: '', + stderr: 'Download failed', + }); + + await expect(performSelfUpdate()).rejects.toThrow('Install script failed (exit 1): Download failed'); + }); + + it('exposes the PowerShell install script URL constant', () => { + expect(INSTALL_SCRIPT_URL_PS1).toContain('install.ps1'); + }); +});