/** * @license * Copyright 2025 Vybestack LLC * SPDX-License-Identifier: Apache-2.0 */ import type { UpdateObject } from '../ui/utils/updateCheck.js'; import type { LoadedSettings } from '../config/settings.js'; import { getInstallationInfo, PackageManager } from './installationInfo.js'; import { updateEventEmitter } from './updateEventEmitter.js'; import type { HistoryItem } from '../ui/types.js'; import { MessageType } from '../ui/types.js'; import { spawnWrapper } from './spawnWrapper.js'; import type { spawn, ChildProcess } from 'child_process'; import * as fs from 'node:fs'; import { Storage } from '@vybestack/llxprt-code-settings'; import * as path from 'node:path'; import { constants } from 'node:fs'; const LOCK_FILE_NAME = 'cli-update.lock'; const LOCK_STALE_MS = 5 * 60 * 1000; // 5 minutes // Cross-platform match for a `.../node_modules/` path prefix. The leading anchor // keeps matching linear (avoids sonarjs/slow-regex) and the pattern is passed to // RegExp via an identifier so it is not a static literal flagged by // sonarjs/regular-expr. const NODE_MODULES_PATH_PATTERN = '^(.*[\\/]node_modules)[\\/]'; const NODE_MODULES_PATH_REGEX = new RegExp(NODE_MODULES_PATH_PATTERN); function isLockStale(lockFilePath: string): boolean { try { const lockContent = fs.readFileSync(lockFilePath, 'utf-8'); const lockData = JSON.parse(lockContent); const now = Date.now(); // Check if lock is older than 5 minutes if (now - lockData.timestamp >= LOCK_STALE_MS) { return true; } // Check if the locking process is still alive // PID must be a positive number (PID 0 is kernel scheduler, not a valid updater) if (typeof lockData.pid === 'number' && lockData.pid > 0) { try { process.kill(lockData.pid, 0); // Process exists, lock is valid return false; } catch { // Process doesn't exist, lock is stale return true; } } // No valid PID in lock data, treat as stale return true; } catch { // Can't read lock file, treat as stale return true; } } /** * Removes a lock file if it exists, handling errors silently. */ function removeLockFile(lockFilePath: string): void { try { fs.unlinkSync(lockFilePath); } catch { // Ignore errors removing lock file } } function tryAcquireLock(): string | null { const locksDir = path.join(Storage.getGlobalDataDir(), 'locks'); const lockFilePath = path.join(locksDir, LOCK_FILE_NAME); try { fs.mkdirSync(locksDir, { recursive: true }); // Check if lock exists and is stale if (fs.existsSync(lockFilePath)) { if (!isLockStale(lockFilePath)) { // Lock exists and is valid return null; } // Remove stale lock removeLockFile(lockFilePath); } // Try to create lock file atomically using O_EXCL flag const lockData = JSON.stringify({ timestamp: Date.now(), pid: process.pid, }); const fd = fs.openSync( lockFilePath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, ); try { fs.writeSync(fd, lockData); } finally { fs.closeSync(fd); } return lockFilePath; } catch (error) { // If EEXIST, another process created the lock between our check and creation if ((error as NodeJS.ErrnoException).code === 'EEXIST') { return null; } // For other errors, log and proceed without lock return null; } } const releaseLock = removeLockFile; function checkForTempDirectories(): string[] { const cliPath = process.argv[1]; if (!cliPath) { return []; } try { const realPath = fs.realpathSync(cliPath); const nodeModulesMatch = realPath.match(NODE_MODULES_PATH_REGEX); if (!nodeModulesMatch) { return []; } const nodeModulesParent = path.dirname(nodeModulesMatch[1]); const entries = fs.readdirSync(nodeModulesParent); return entries.filter((entry) => entry.startsWith('.llxprt-code-')); } catch { return []; } } function handleTempDirectoryCleanup(): boolean { const tempDirs = checkForTempDirectories(); if (tempDirs.length === 0) { return false; } const cliPath = process.argv[1]; if (!cliPath) { return true; } const realPath = fs.realpathSync(cliPath); const nodeModulesMatch = realPath.match(NODE_MODULES_PATH_REGEX); const cleanupPath = nodeModulesMatch ? path.dirname(nodeModulesMatch[1]) : 'the parent directory of node_modules'; const isWindows = process.platform === 'win32'; const cleanupCommand = isWindows ? `rmdir /s /q ${tempDirs.map((d) => path.join(cleanupPath, d)).join(' ')}` : `rm -rf ${tempDirs.map((d) => path.join(cleanupPath, d)).join(' ')}`; updateEventEmitter.emit('update-info', { message: `Temporary directories from a previous failed update were detected. Please clean them up manually:\n ${cleanupCommand}`, }); return true; } function spawnUpdateProcess( updateCommand: string, lockFilePath: string, cleanupLock: () => void, spawnFn: typeof spawn, ): ChildProcess { const updateProcess = spawnFn(updateCommand, { stdio: 'ignore', shell: true, detached: true, }); updateProcess.unref(); updateProcess.on('close', (code) => { process.off('SIGTERM', cleanupLock); process.off('SIGINT', cleanupLock); releaseLock(lockFilePath); if (code === 0) { updateEventEmitter.emit('update-success', { message: 'Update successful! The new version will be used on your next run.', }); } else { updateEventEmitter.emit('update-failed', { message: `Automatic update failed. Please try updating manually. (command: ${updateCommand})`, }); } }); updateProcess.on('error', (err) => { process.off('SIGTERM', cleanupLock); process.off('SIGINT', cleanupLock); releaseLock(lockFilePath); updateEventEmitter.emit('update-failed', { message: `Automatic update failed. Please try updating manually. (error: ${err.message})`, }); }); return updateProcess; } export function handleAutoUpdate( info: UpdateObject | null, settings: LoadedSettings, projectRoot: string, spawnFn: typeof spawn = spawnWrapper, ): ChildProcess | undefined { if (info == null) { return undefined; } if (settings.merged.enableAutoUpdateNotification !== true) { return undefined; } const installationInfo = getInstallationInfo( projectRoot, settings.merged.enableAutoUpdate ?? true, ); if ( [PackageManager.NPX, PackageManager.PNPX, PackageManager.BUNX].includes( installationInfo.packageManager, ) ) { return undefined; } let combinedMessage = info.message; if (installationInfo.updateMessage) { combinedMessage += `\n${installationInfo.updateMessage}`; } updateEventEmitter.emit('update-received', { message: combinedMessage, }); if ( installationInfo.updateCommand == null || settings.merged.enableAutoUpdate !== true ) { return undefined; } if (handleTempDirectoryCleanup()) { return undefined; } const lockFilePath = tryAcquireLock(); if (!lockFilePath) { updateEventEmitter.emit('update-info', { message: 'Another update is already in progress. Skipping auto-update.', }); return undefined; } const cleanupLock = () => { releaseLock(lockFilePath); }; process.once('SIGTERM', cleanupLock); process.once('SIGINT', cleanupLock); const isNightly = info.update.latest.includes('nightly'); const updateCommand = installationInfo.updateCommand.replace( '@latest', isNightly ? '@nightly' : `@${info.update.latest}`, ); return spawnUpdateProcess(updateCommand, lockFilePath, cleanupLock, spawnFn); } export function setUpdateHandler( addItem: (item: Omit, timestamp: number) => void, setUpdateInfo: (info: UpdateObject | null) => void, ) { let successfullyInstalled = false; const handleUpdateReceived = (info: UpdateObject) => { setUpdateInfo(info); const savedMessage = info.message; setTimeout(() => { if (!successfullyInstalled) { addItem( { type: MessageType.INFO, text: savedMessage, }, Date.now(), ); } setUpdateInfo(null); }, 60000); }; const handleUpdateFailed = () => { setUpdateInfo(null); addItem( { type: MessageType.ERROR, text: `Automatic update failed. Please try updating manually`, }, Date.now(), ); }; const handleUpdateSuccess = () => { successfullyInstalled = true; setUpdateInfo(null); addItem( { type: MessageType.INFO, text: `Update successful! The new version will be used on your next run.`, }, Date.now(), ); }; const handleUpdateInfo = (data: { message: string }) => { addItem( { type: MessageType.INFO, text: data.message, }, Date.now(), ); }; updateEventEmitter.on('update-received', handleUpdateReceived); updateEventEmitter.on('update-failed', handleUpdateFailed); updateEventEmitter.on('update-success', handleUpdateSuccess); updateEventEmitter.on('update-info', handleUpdateInfo); return () => { updateEventEmitter.off('update-received', handleUpdateReceived); updateEventEmitter.off('update-failed', handleUpdateFailed); updateEventEmitter.off('update-success', handleUpdateSuccess); updateEventEmitter.off('update-info', handleUpdateInfo); }; }