From ccb531a57a308e362d263000248d991b0e6291f5 Mon Sep 17 00:00:00 2001 From: Sophio Japharidze Date: Wed, 25 Feb 2026 17:03:08 +0100 Subject: [PATCH 1/2] CLI-20 CLI self-update command --- build-scripts/update-version.sh | 88 ---------- spec.yaml | 25 +++ src/commands/self-update.ts | 98 +++++++++++ src/index.ts | 11 ++ src/lib/config-constants.ts | 1 + src/lib/self-update.ts | 232 +++++++++++++++++++++++++ tests/unit/perform-self-update.test.ts | 176 +++++++++++++++++++ tests/unit/self-update-command.test.ts | 162 +++++++++++++++++ tests/unit/self-update.test.ts | 93 ++++++++++ 9 files changed, 798 insertions(+), 88 deletions(-) delete mode 100644 build-scripts/update-version.sh create mode 100644 src/commands/self-update.ts create mode 100644 src/lib/self-update.ts create mode 100644 tests/unit/perform-self-update.test.ts create mode 100644 tests/unit/self-update-command.test.ts create mode 100644 tests/unit/self-update.test.ts 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..013d6174 --- /dev/null +++ b/src/commands/self-update.ts @@ -0,0 +1,98 @@ +/* + * 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(); + const installedVersion = await withSpinner( + `Downloading and installing v${latest}`, + () => performSelfUpdate(latest) + ); + + blank(); + success(`Updated to v${installedVersion}`); + text(` Path: ${process.execPath}`); +} 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..cdf6c191 --- /dev/null +++ b/src/lib/self-update.ts @@ -0,0 +1,232 @@ +/* + * 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 { existsSync } from 'node:fs'; +import { copyFile, chmod, rename, unlink, mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnProcess } from './process.js'; +import { version as VERSION } from '../../package.json'; +import { SONARSOURCE_BINARIES_URL, SONARQUBE_CLI_DIST_PREFIX } from './config-constants.js'; +import { detectPlatform } from './platform-detector.js'; +import logger from './logger.js'; + +const REQUEST_TIMEOUT_MS = 30000; +const DOWNLOAD_TIMEOUT_MS = 120000; + +/** + * 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 }); +} + +/** + * Build the download URL for a given CLI version on the current platform. + * Naming convention: sonarqube-cli---.exe + */ +function buildCliDownloadUrl(version: string): string { + const platform = detectPlatform(); + const filename = `sonarqube-cli-${version}-${platform.os}-${platform.arch}.exe`; + return `${SONARSOURCE_BINARIES_URL}/${SONARQUBE_CLI_DIST_PREFIX}/${filename}`; +} + +/** + * Download the CLI binary to a temporary file and return its path. + */ +async function downloadToTemp(url: string): Promise<{ tmpDir: string; tmpFile: string }> { + const tmpDir = await mkdtemp(join(tmpdir(), 'sonar-update-')); + const tmpFile = join(tmpDir, 'sonar.download'); + + logger.debug(`Downloading SonarQubeCLI from: ${url}`); + + const response = await fetch(url, { + headers: { 'User-Agent': `sonarqube-cli/${VERSION}` }, + signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new Error(`Download failed: ${response.status} ${response.statusText}`); + } + + const buffer = await response.arrayBuffer(); + await writeFile(tmpFile, Buffer.from(buffer)); + + return { tmpDir, tmpFile }; +} + +/** + * Verify a binary responds correctly to --version, returning the version string. + */ +async function verifyBinary(binaryPath: string): Promise { + const result = await spawnProcess(binaryPath, ['--version'], { + stdout: 'pipe', + stderr: 'pipe', + }); + + if (result.exitCode !== 0) { + throw new Error('Downloaded binary failed version check'); + } + + const combined = result.stdout + ' ' + result.stderr; + + const match = /(\d{1,20}(?:\.\d{1,20}){1,3})/.exec(combined); + if (!match) { + throw new Error('Could not parse version from downloaded binary output'); + } + + return match[1]; +} + +/** + * Remove macOS quarantine attribute from the file (no-op if not quarantined). + */ +async function removeQuarantine(filePath: string): Promise { + try { + await spawnProcess('xattr', ['-d', 'com.apple.quarantine', filePath], { + stdout: 'pipe', + stderr: 'pipe', + }); + } catch { + // Binary is not quarantined — this is expected and fine + } +} + +/** + * Download the new binary, set permissions, and stage it at newBinaryPath. + * Returns the temp directory path so the caller can clean it up. + */ +async function prepareNewBinary(downloadUrl: string, newBinaryPath: string): Promise { + const platform = detectPlatform(); + const { tmpDir, tmpFile } = await downloadToTemp(downloadUrl); + + if (platform.os !== 'windows') { + await chmod(tmpFile, 0o755); + } + if (platform.os === 'macos') { + await removeQuarantine(tmpFile); + } + + await copyFile(tmpFile, newBinaryPath); + if (platform.os !== 'windows') { + await chmod(newBinaryPath, 0o755); + } + + return tmpDir; +} + +/** + * Atomically swap newBinaryPath into currentBinaryPath, verify the result, then + * clean up. On verification failure, restores the backup when one was created, or + * removes the failed binary entirely when there was no original to restore. + */ +async function swapAndVerify(currentBinaryPath: string, newBinaryPath: string, backupPath: string): Promise { + const backupCreated = existsSync(currentBinaryPath); + if (backupCreated) { + await rename(currentBinaryPath, backupPath); + } + await rename(newBinaryPath, currentBinaryPath); + + try { + const installedVersion = await verifyBinary(currentBinaryPath); + if (backupCreated && existsSync(backupPath)) { + await unlink(backupPath); + } + return installedVersion; + } catch (error_) { + logger.warn(`Verification failed, rolling back: ${(error_ as Error).message}`); + if (backupCreated && existsSync(backupPath)) { + await rename(backupPath, currentBinaryPath); + } else if (!backupCreated) { + try { + await unlink(currentBinaryPath); + } catch { + // Ignore cleanup errors + } + } + throw error_; + } +} + +/** + * Perform an in-place binary self-update with rollback on failure. + * + * Strategy: + * 1. Download new binary to system tmpdir + * 2. Copy to .new (same FS → avoids cross-device rename) + * 3. Rename current binary to .backup (atomic) + * 4. Rename .new to current path (atomic) + * 5. Verify the new binary works + * 6. On success: remove backup + * 7. On failure: restore backup (or remove failed binary if no backup), throw + */ +export async function performSelfUpdate(version: string): Promise { + const currentBinaryPath = process.execPath; + const newBinaryPath = `${currentBinaryPath}.new`; + const backupPath = `${currentBinaryPath}.backup`; + + let tmpDir: string | null = null; + + try { + tmpDir = await prepareNewBinary(buildCliDownloadUrl(version), newBinaryPath); + return await swapAndVerify(currentBinaryPath, newBinaryPath, backupPath); + } finally { + if (tmpDir) { + try { + await rm(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } + if (existsSync(newBinaryPath)) { + try { + await unlink(newBinaryPath); + } catch { + // Ignore cleanup errors + } + } + } +} diff --git a/tests/unit/perform-self-update.test.ts b/tests/unit/perform-self-update.test.ts new file mode 100644 index 00000000..bfe1efe4 --- /dev/null +++ b/tests/unit/perform-self-update.test.ts @@ -0,0 +1,176 @@ +/* + * 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 performSelfUpdate in src/lib/self-update.ts +// Uses mock.module to intercept filesystem and process calls. + +import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; + +// ── Mutable state read by the module-level mocks below ───────────────────── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type SpawnResult = { exitCode: number; stdout: string; stderr: string }; + +let _existsSyncImpl: (p: string) => boolean = () => false; +let _spawnProcessImpl: () => Promise = async () => { + throw new Error('Binary execution failed'); +}; + +// Track calls to rename and unlink so tests can assert on them. +const _renameCalls: Array<[string, string]> = []; +const _unlinkCalls: string[] = []; + +// ── Module mocks (hoisted by Bun before any import resolves) ──────────────── + +mock.module('node:fs', () => ({ + existsSync: (p: string) => _existsSyncImpl(p), +})); + +mock.module('node:fs/promises', () => ({ + mkdtemp: async () => '/tmp/sonar-test', + writeFile: async () => undefined, + copyFile: async () => undefined, + chmod: async () => undefined, + rename: async (from: string, to: string) => { + _renameCalls.push([from, to]); + }, + unlink: async (p: string) => { + _unlinkCalls.push(p); + }, + rm: async () => undefined, +})); + +mock.module('../../src/lib/process.js', () => ({ + spawnProcess: async (cmd: string, args: string[]) => { + if (cmd === 'xattr') { + // removeQuarantine — let it succeed silently + return { exitCode: 0, stdout: '', stderr: '' }; + } + return _spawnProcessImpl(); + }, +})); + +// ── Subject under test (imported after mocks are in place) ────────────────── + +import { performSelfUpdate } from '../../src/lib/self-update.js'; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function resetCallTrackers() { + _renameCalls.length = 0; + _unlinkCalls.length = 0; +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('performSelfUpdate', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let fetchSpy: any; + + beforeEach(() => { + resetCallTrackers(); + + fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + } as Response); + }); + + afterEach(() => { + fetchSpy?.mockRestore(); + }); + + describe('rollback on verification failure', () => { + beforeEach(() => { + _spawnProcessImpl = async () => { + throw new Error('downloaded binary failed version check'); + }; + }); + + it('removes the failed binary when there was no original binary to restore', async () => { + const currentBinaryPath = process.execPath; + + // No original binary at any path + _existsSyncImpl = () => false; + + await expect(performSelfUpdate('1.0.0')).rejects.toThrow('downloaded binary failed version check'); + + // The failed binary placed at currentBinaryPath must be removed + expect(_unlinkCalls).toContain(currentBinaryPath); + + // No backup-restore rename should have happened + const backupPath = `${currentBinaryPath}.backup`; + const restoredFromBackup = _renameCalls.some(([from, to]) => from === backupPath && to === currentBinaryPath); + expect(restoredFromBackup).toBe(false); + }); + + it('restores the backup when the original binary existed before the update', async () => { + const currentBinaryPath = process.execPath; + const backupPath = `${currentBinaryPath}.backup`; + + // Current binary exists → a backup will be created; backup also exists during rollback + _existsSyncImpl = (p: string) => p === currentBinaryPath || p === backupPath; + + await expect(performSelfUpdate('1.0.0')).rejects.toThrow('downloaded binary failed version check'); + + // Backup must have been created (renamed current → backup) + const backupCreated = _renameCalls.some(([from, to]) => from === currentBinaryPath && to === backupPath); + expect(backupCreated).toBe(true); + + // Backup must have been restored (renamed backup → current) + const backupRestored = _renameCalls.some(([from, to]) => from === backupPath && to === currentBinaryPath); + expect(backupRestored).toBe(true); + + // The failed binary should NOT have been unlinked during rollback + expect(_unlinkCalls).not.toContain(currentBinaryPath); + }); + }); + + describe('successful update', () => { + beforeEach(() => { + _spawnProcessImpl = async () => ({ exitCode: 0, stdout: '1.0.0', stderr: '' }); + }); + + it('removes the backup after a successful update when an original binary existed', async () => { + const currentBinaryPath = process.execPath; + const backupPath = `${currentBinaryPath}.backup`; + + // Current binary exists initially, backup exists after the swap + _existsSyncImpl = (p: string) => p === currentBinaryPath || p === backupPath; + + const version = await performSelfUpdate('1.0.0'); + + expect(version).toBe('1.0.0'); + expect(_unlinkCalls).toContain(backupPath); + }); + + it('does not attempt to remove a backup when no original binary existed', async () => { + const currentBinaryPath = process.execPath; + const backupPath = `${currentBinaryPath}.backup`; + + _existsSyncImpl = () => false; + + const version = await performSelfUpdate('1.0.0'); + + expect(version).toBe('1.0.0'); + expect(_unlinkCalls).not.toContain(backupPath); + }); + }); +}); diff --git a/tests/unit/self-update-command.test.ts b/tests/unit/self-update-command.test.ts new file mode 100644 index 00000000..3e32f12a --- /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(VERSION); + + 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('99.99.99'); + + await selfUpdateCommand({}); + + expect(performUpdateSpy).toHaveBeenCalledWith('99.99.99'); + 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(VERSION); + + await selfUpdateCommand({ force: true }); + + expect(performUpdateSpy).toHaveBeenCalledWith(VERSION); + 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('download failed')); + + expect(selfUpdateCommand({})).rejects.toThrow('download 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..58895051 --- /dev/null +++ b/tests/unit/self-update.test.ts @@ -0,0 +1,93 @@ +/* + * 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 } from 'bun:test'; +import { compareVersions, fetchLatestCliVersion } from '../../src/lib/self-update.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'); + }); +}); From 2a86446077a62da9c9a927723dc90e210dab3092 Mon Sep 17 00:00:00 2001 From: Sophio Japharidze Date: Thu, 26 Feb 2026 16:34:14 +0100 Subject: [PATCH 2/2] CLI-20 call install script during self-update --- src/commands/self-update.ts | 9 +- src/lib/self-update.ts | 187 ++++++------------------- tests/unit/config.test.ts | 6 +- tests/unit/perform-self-update.test.ts | 176 ----------------------- tests/unit/self-update-command.test.ts | 14 +- tests/unit/self-update.test.ts | 68 ++++++++- 6 files changed, 123 insertions(+), 337 deletions(-) delete mode 100644 tests/unit/perform-self-update.test.ts diff --git a/src/commands/self-update.ts b/src/commands/self-update.ts index 013d6174..0acd4a70 100644 --- a/src/commands/self-update.ts +++ b/src/commands/self-update.ts @@ -87,12 +87,11 @@ export async function selfUpdateCommand(options: { check?: boolean; force?: bool } blank(); - const installedVersion = await withSpinner( - `Downloading and installing v${latest}`, - () => performSelfUpdate(latest) + await withSpinner( + `Installing v${latest}`, + performSelfUpdate ); blank(); - success(`Updated to v${installedVersion}`); - text(` Path: ${process.execPath}`); + success(`Updated to v${latest}`); } diff --git a/src/lib/self-update.ts b/src/lib/self-update.ts index cdf6c191..ce852cdd 100644 --- a/src/lib/self-update.ts +++ b/src/lib/self-update.ts @@ -20,18 +20,14 @@ // Self-update logic for the sonarqube-cli binary -import { existsSync } from 'node:fs'; -import { copyFile, chmod, rename, unlink, mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; import { join } from 'node:path'; -import { tmpdir } from 'node:os'; +import { tmpdir, platform } from 'node:os'; import { spawnProcess } from './process.js'; import { version as VERSION } from '../../package.json'; -import { SONARSOURCE_BINARIES_URL, SONARQUBE_CLI_DIST_PREFIX } from './config-constants.js'; -import { detectPlatform } from './platform-detector.js'; import logger from './logger.js'; const REQUEST_TIMEOUT_MS = 30000; -const DOWNLOAD_TIMEOUT_MS = 120000; /** * Fetch the latest available CLI version from binaries.sonarsource.com @@ -64,169 +60,72 @@ export function compareVersions(a: string, b: string): number { return a.localeCompare(b, undefined, { numeric: true }); } -/** - * Build the download URL for a given CLI version on the current platform. - * Naming convention: sonarqube-cli---.exe - */ -function buildCliDownloadUrl(version: string): string { - const platform = detectPlatform(); - const filename = `sonarqube-cli-${version}-${platform.os}-${platform.arch}.exe`; - return `${SONARSOURCE_BINARIES_URL}/${SONARQUBE_CLI_DIST_PREFIX}/${filename}`; +function isWindows(): boolean { + return platform() === 'win32'; } -/** - * Download the CLI binary to a temporary file and return its path. - */ -async function downloadToTemp(url: string): Promise<{ tmpDir: string; tmpFile: string }> { - const tmpDir = await mkdtemp(join(tmpdir(), 'sonar-update-')); - const tmpFile = join(tmpDir, 'sonar.download'); +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(`Downloading SonarQubeCLI from: ${url}`); + logger.debug(`Fetching install script from: ${url}`); const response = await fetch(url, { headers: { 'User-Agent': `sonarqube-cli/${VERSION}` }, - signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); if (!response.ok) { - throw new Error(`Download failed: ${response.status} ${response.statusText}`); + throw new Error(`Failed to fetch install script: ${response.status} ${response.statusText}`); } - const buffer = await response.arrayBuffer(); - await writeFile(tmpFile, Buffer.from(buffer)); - - return { tmpDir, tmpFile }; + return response.text(); } /** - * Verify a binary responds correctly to --version, returning the version string. + * 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. */ -async function verifyBinary(binaryPath: string): Promise { - const result = await spawnProcess(binaryPath, ['--version'], { - stdout: 'pipe', - stderr: 'pipe', - }); - - if (result.exitCode !== 0) { - throw new Error('Downloaded binary failed version check'); - } - - const combined = result.stdout + ' ' + result.stderr; +export async function performSelfUpdate(): Promise { + const windows = isWindows(); + const scriptContent = await fetchInstallScript(); - const match = /(\d{1,20}(?:\.\d{1,20}){1,3})/.exec(combined); - if (!match) { - throw new Error('Could not parse version from downloaded binary output'); - } - - return match[1]; -} + const tmpDir = await mkdtemp(join(tmpdir(), 'sonar-update-')); -/** - * Remove macOS quarantine attribute from the file (no-op if not quarantined). - */ -async function removeQuarantine(filePath: string): Promise { try { - await spawnProcess('xattr', ['-d', 'com.apple.quarantine', filePath], { - stdout: 'pipe', - stderr: 'pipe', - }); - } catch { - // Binary is not quarantined — this is expected and fine - } -} - -/** - * Download the new binary, set permissions, and stage it at newBinaryPath. - * Returns the temp directory path so the caller can clean it up. - */ -async function prepareNewBinary(downloadUrl: string, newBinaryPath: string): Promise { - const platform = detectPlatform(); - const { tmpDir, tmpFile } = await downloadToTemp(downloadUrl); + if (windows) { + const scriptFile = join(tmpDir, 'install.ps1'); + await writeFile(scriptFile, scriptContent, 'utf8'); - if (platform.os !== 'windows') { - await chmod(tmpFile, 0o755); - } - if (platform.os === 'macos') { - await removeQuarantine(tmpFile); - } - - await copyFile(tmpFile, newBinaryPath); - if (platform.os !== 'windows') { - await chmod(newBinaryPath, 0o755); - } - - return tmpDir; -} + const result = await spawnProcess('powershell', ['-ExecutionPolicy', 'Bypass', '-File', scriptFile], { + stdout: 'pipe', + stderr: 'pipe', + }); -/** - * Atomically swap newBinaryPath into currentBinaryPath, verify the result, then - * clean up. On verification failure, restores the backup when one was created, or - * removes the failed binary entirely when there was no original to restore. - */ -async function swapAndVerify(currentBinaryPath: string, newBinaryPath: string, backupPath: string): Promise { - const backupCreated = existsSync(currentBinaryPath); - if (backupCreated) { - await rename(currentBinaryPath, backupPath); - } - await rename(newBinaryPath, currentBinaryPath); - - try { - const installedVersion = await verifyBinary(currentBinaryPath); - if (backupCreated && existsSync(backupPath)) { - await unlink(backupPath); - } - return installedVersion; - } catch (error_) { - logger.warn(`Verification failed, rolling back: ${(error_ as Error).message}`); - if (backupCreated && existsSync(backupPath)) { - await rename(backupPath, currentBinaryPath); - } else if (!backupCreated) { - try { - await unlink(currentBinaryPath); - } catch { - // Ignore cleanup errors + if (result.exitCode !== 0) { + throw new Error(`Install script failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`); } - } - throw error_; - } -} + } else { + const scriptFile = join(tmpDir, 'install.sh'); + await writeFile(scriptFile, scriptContent, 'utf8'); -/** - * Perform an in-place binary self-update with rollback on failure. - * - * Strategy: - * 1. Download new binary to system tmpdir - * 2. Copy to .new (same FS → avoids cross-device rename) - * 3. Rename current binary to .backup (atomic) - * 4. Rename .new to current path (atomic) - * 5. Verify the new binary works - * 6. On success: remove backup - * 7. On failure: restore backup (or remove failed binary if no backup), throw - */ -export async function performSelfUpdate(version: string): Promise { - const currentBinaryPath = process.execPath; - const newBinaryPath = `${currentBinaryPath}.new`; - const backupPath = `${currentBinaryPath}.backup`; + const result = await spawnProcess('bash', [scriptFile], { + stdout: 'pipe', + stderr: 'pipe', + }); - let tmpDir: string | null = null; - - try { - tmpDir = await prepareNewBinary(buildCliDownloadUrl(version), newBinaryPath); - return await swapAndVerify(currentBinaryPath, newBinaryPath, backupPath); - } finally { - if (tmpDir) { - try { - await rm(tmpDir, { recursive: true, force: true }); - } catch { - // Ignore cleanup errors + if (result.exitCode !== 0) { + throw new Error(`Install script failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`); } } - if (existsSync(newBinaryPath)) { - try { - await unlink(newBinaryPath); - } catch { - // Ignore cleanup errors - } + } 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/perform-self-update.test.ts b/tests/unit/perform-self-update.test.ts deleted file mode 100644 index bfe1efe4..00000000 --- a/tests/unit/perform-self-update.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -/* - * 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 performSelfUpdate in src/lib/self-update.ts -// Uses mock.module to intercept filesystem and process calls. - -import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; - -// ── Mutable state read by the module-level mocks below ───────────────────── - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type SpawnResult = { exitCode: number; stdout: string; stderr: string }; - -let _existsSyncImpl: (p: string) => boolean = () => false; -let _spawnProcessImpl: () => Promise = async () => { - throw new Error('Binary execution failed'); -}; - -// Track calls to rename and unlink so tests can assert on them. -const _renameCalls: Array<[string, string]> = []; -const _unlinkCalls: string[] = []; - -// ── Module mocks (hoisted by Bun before any import resolves) ──────────────── - -mock.module('node:fs', () => ({ - existsSync: (p: string) => _existsSyncImpl(p), -})); - -mock.module('node:fs/promises', () => ({ - mkdtemp: async () => '/tmp/sonar-test', - writeFile: async () => undefined, - copyFile: async () => undefined, - chmod: async () => undefined, - rename: async (from: string, to: string) => { - _renameCalls.push([from, to]); - }, - unlink: async (p: string) => { - _unlinkCalls.push(p); - }, - rm: async () => undefined, -})); - -mock.module('../../src/lib/process.js', () => ({ - spawnProcess: async (cmd: string, args: string[]) => { - if (cmd === 'xattr') { - // removeQuarantine — let it succeed silently - return { exitCode: 0, stdout: '', stderr: '' }; - } - return _spawnProcessImpl(); - }, -})); - -// ── Subject under test (imported after mocks are in place) ────────────────── - -import { performSelfUpdate } from '../../src/lib/self-update.js'; - -// ── Helpers ───────────────────────────────────────────────────────────────── - -function resetCallTrackers() { - _renameCalls.length = 0; - _unlinkCalls.length = 0; -} - -// ── Tests ──────────────────────────────────────────────────────────────────── - -describe('performSelfUpdate', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let fetchSpy: any; - - beforeEach(() => { - resetCallTrackers(); - - fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue({ - ok: true, - arrayBuffer: async () => new ArrayBuffer(8), - } as Response); - }); - - afterEach(() => { - fetchSpy?.mockRestore(); - }); - - describe('rollback on verification failure', () => { - beforeEach(() => { - _spawnProcessImpl = async () => { - throw new Error('downloaded binary failed version check'); - }; - }); - - it('removes the failed binary when there was no original binary to restore', async () => { - const currentBinaryPath = process.execPath; - - // No original binary at any path - _existsSyncImpl = () => false; - - await expect(performSelfUpdate('1.0.0')).rejects.toThrow('downloaded binary failed version check'); - - // The failed binary placed at currentBinaryPath must be removed - expect(_unlinkCalls).toContain(currentBinaryPath); - - // No backup-restore rename should have happened - const backupPath = `${currentBinaryPath}.backup`; - const restoredFromBackup = _renameCalls.some(([from, to]) => from === backupPath && to === currentBinaryPath); - expect(restoredFromBackup).toBe(false); - }); - - it('restores the backup when the original binary existed before the update', async () => { - const currentBinaryPath = process.execPath; - const backupPath = `${currentBinaryPath}.backup`; - - // Current binary exists → a backup will be created; backup also exists during rollback - _existsSyncImpl = (p: string) => p === currentBinaryPath || p === backupPath; - - await expect(performSelfUpdate('1.0.0')).rejects.toThrow('downloaded binary failed version check'); - - // Backup must have been created (renamed current → backup) - const backupCreated = _renameCalls.some(([from, to]) => from === currentBinaryPath && to === backupPath); - expect(backupCreated).toBe(true); - - // Backup must have been restored (renamed backup → current) - const backupRestored = _renameCalls.some(([from, to]) => from === backupPath && to === currentBinaryPath); - expect(backupRestored).toBe(true); - - // The failed binary should NOT have been unlinked during rollback - expect(_unlinkCalls).not.toContain(currentBinaryPath); - }); - }); - - describe('successful update', () => { - beforeEach(() => { - _spawnProcessImpl = async () => ({ exitCode: 0, stdout: '1.0.0', stderr: '' }); - }); - - it('removes the backup after a successful update when an original binary existed', async () => { - const currentBinaryPath = process.execPath; - const backupPath = `${currentBinaryPath}.backup`; - - // Current binary exists initially, backup exists after the swap - _existsSyncImpl = (p: string) => p === currentBinaryPath || p === backupPath; - - const version = await performSelfUpdate('1.0.0'); - - expect(version).toBe('1.0.0'); - expect(_unlinkCalls).toContain(backupPath); - }); - - it('does not attempt to remove a backup when no original binary existed', async () => { - const currentBinaryPath = process.execPath; - const backupPath = `${currentBinaryPath}.backup`; - - _existsSyncImpl = () => false; - - const version = await performSelfUpdate('1.0.0'); - - expect(version).toBe('1.0.0'); - expect(_unlinkCalls).not.toContain(backupPath); - }); - }); -}); diff --git a/tests/unit/self-update-command.test.ts b/tests/unit/self-update-command.test.ts index 3e32f12a..a62190d9 100644 --- a/tests/unit/self-update-command.test.ts +++ b/tests/unit/self-update-command.test.ts @@ -116,7 +116,7 @@ describe('selfUpdateCommand', () => { it('does not call performSelfUpdate when already on the latest version', async () => { fetchLatestSpy = spyOn(selfUpdate, 'fetchLatestCliVersion').mockResolvedValue(VERSION); - performUpdateSpy = spyOn(selfUpdate, 'performSelfUpdate').mockResolvedValue(VERSION); + performUpdateSpy = spyOn(selfUpdate, 'performSelfUpdate').mockResolvedValue(undefined); await selfUpdateCommand({}); @@ -125,11 +125,11 @@ describe('selfUpdateCommand', () => { it('calls performSelfUpdate and shows success when update is available', async () => { fetchLatestSpy = spyOn(selfUpdate, 'fetchLatestCliVersion').mockResolvedValue('99.99.99'); - performUpdateSpy = spyOn(selfUpdate, 'performSelfUpdate').mockResolvedValue('99.99.99'); + performUpdateSpy = spyOn(selfUpdate, 'performSelfUpdate').mockResolvedValue(undefined); await selfUpdateCommand({}); - expect(performUpdateSpy).toHaveBeenCalledWith('99.99.99'); + 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); @@ -137,11 +137,11 @@ describe('selfUpdateCommand', () => { it('with --force, calls performSelfUpdate even when already on the latest version', async () => { fetchLatestSpy = spyOn(selfUpdate, 'fetchLatestCliVersion').mockResolvedValue(VERSION); - performUpdateSpy = spyOn(selfUpdate, 'performSelfUpdate').mockResolvedValue(VERSION); + performUpdateSpy = spyOn(selfUpdate, 'performSelfUpdate').mockResolvedValue(undefined); await selfUpdateCommand({ force: true }); - expect(performUpdateSpy).toHaveBeenCalledWith(VERSION); + 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); @@ -149,9 +149,9 @@ describe('selfUpdateCommand', () => { it('propagates error from performSelfUpdate', async () => { fetchLatestSpy = spyOn(selfUpdate, 'fetchLatestCliVersion').mockResolvedValue('99.99.99'); - performUpdateSpy = spyOn(selfUpdate, 'performSelfUpdate').mockRejectedValue(new Error('download failed')); + performUpdateSpy = spyOn(selfUpdate, 'performSelfUpdate').mockRejectedValue(new Error('install script failed')); - expect(selfUpdateCommand({})).rejects.toThrow('download failed'); + expect(selfUpdateCommand({})).rejects.toThrow('install script failed'); }); it('propagates error when version fetch fails', async () => { diff --git a/tests/unit/self-update.test.ts b/tests/unit/self-update.test.ts index 58895051..7ce51998 100644 --- a/tests/unit/self-update.test.ts +++ b/tests/unit/self-update.test.ts @@ -20,8 +20,9 @@ // Tests for src/lib/self-update.ts -import { describe, it, expect, spyOn, afterEach } from 'bun:test'; -import { compareVersions, fetchLatestCliVersion } from '../../src/lib/self-update.js'; +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', () => { @@ -91,3 +92,66 @@ describe('fetchLatestCliVersion', () => { 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'); + }); +});