/** * @license * Copyright 2025 Vybestack LLC * SPDX-License-Identifier: Apache-2.0 */ import { ExtensionUpdateState } from '../ui/state/extensions.js'; import { checkForExtensionUpdate } from '../config/extensions/github.js'; import { loadUserExtensions } from '../config/extension.js'; import { updateExtension, type ExtensionUpdateInfo, } from '../config/extensions/update.js'; import { getErrorMessage, type LlxprtExtension, } from '@vybestack/llxprt-code-core'; import { debugLogger } from '@vybestack/llxprt-code-telemetry'; import { Storage } from '@vybestack/llxprt-code-settings'; import * as fs from 'node:fs'; import * as path from 'node:path'; const HOUR_IN_MS = 60 * 60 * 1000; const STATE_FILENAME = 'extension-update-state.json'; export type ExtensionAutoUpdateInstallMode = | 'immediate' | 'on-restart' | 'manual'; export type ExtensionAutoUpdateNotificationLevel = | 'silent' | 'toast' | 'dialog'; export interface ExtensionAutoUpdateSettings { enabled?: boolean; checkIntervalHours?: number; installMode?: ExtensionAutoUpdateInstallMode; notificationLevel?: ExtensionAutoUpdateNotificationLevel; perExtension?: Record; } export interface ExtensionAutoUpdatePerExtensionSetting { enabled?: boolean; installMode?: ExtensionAutoUpdateInstallMode; notificationLevel?: ExtensionAutoUpdateNotificationLevel; checkIntervalHours?: number; } interface EffectiveExtensionAutoUpdateSettings { enabled: boolean; checkIntervalHours: number; installMode: ExtensionAutoUpdateInstallMode; notificationLevel: ExtensionAutoUpdateNotificationLevel; perExtension: Partial>; } export interface ExtensionUpdateHistoryEntry { lastCheck?: number; lastUpdate?: number; lastError?: string; failureCount?: number; pendingInstall?: boolean; state?: ExtensionUpdateState; } type ExtensionUpdateStateFile = Record; export interface ExtensionAutoUpdateStateStore { read(): Promise; write(state: ExtensionUpdateStateFile): Promise; } function clampIntervalHours(value: number | undefined): number { // Preserve old !value behavior: undefined, 0, and NaN all return default of 24 if (value === undefined || value === 0 || Number.isNaN(value)) { return 24; } return Math.max(1, value); } function resolveSettings( raw?: ExtensionAutoUpdateSettings, ): EffectiveExtensionAutoUpdateSettings { return { enabled: raw?.enabled ?? true, checkIntervalHours: clampIntervalHours(raw?.checkIntervalHours ?? 24), installMode: raw?.installMode ?? 'immediate', notificationLevel: raw?.notificationLevel ?? 'toast', perExtension: raw?.perExtension ?? {}, }; } function createFileStateStore(): ExtensionAutoUpdateStateStore { const dir = Storage.getGlobalDataDir(); const filePath = path.join(dir, STATE_FILENAME); // One-time migration: move state file from config dir to data dir const configDirPath = path.join(Storage.getGlobalConfigDir(), STATE_FILENAME); if (!fs.existsSync(filePath) && fs.existsSync(configDirPath)) { try { fs.copyFileSync(configDirPath, filePath); debugLogger.debug( 'Migrated extension auto-update state from config dir to data dir', ); } catch { // Non-critical — fresh state will be created on first write } } return { async read() { try { await fs.promises.mkdir(dir, { recursive: true }); const data = await fs.promises.readFile(filePath, 'utf-8'); return JSON.parse(data) as ExtensionUpdateStateFile; } catch (error) { if ( error !== null && typeof error === 'object' && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT' ) { return {}; } debugLogger.warn( '[extensions] Failed to read extension auto-update state:', getErrorMessage(error), ); return {}; } }, async write(state: ExtensionUpdateStateFile) { try { await fs.promises.mkdir(dir, { recursive: true }); await fs.promises.writeFile( filePath, JSON.stringify(state, null, 2), 'utf-8', ); } catch (error) { debugLogger.warn( '[extensions] Failed to persist extension auto-update state:', getErrorMessage(error), ); } }, }; } interface ExtensionAutoUpdaterOptions { settings?: ExtensionAutoUpdateSettings; workspaceDir?: string; notify?: (message: string, level: 'info' | 'warn' | 'error') => void; stateStore?: ExtensionAutoUpdateStateStore; extensionLoader?: () => Promise; updateExecutor?: ( extension: LlxprtExtension, cwd: string, currentState: ExtensionUpdateState, setExtensionUpdateState: (updateState: ExtensionUpdateState) => void, ) => Promise; updateChecker?: ( extension: LlxprtExtension, setExtensionUpdateState: (updateState: ExtensionUpdateState) => void, ) => Promise; now?: () => number; } interface EffectivePerExtensionSettings { enabled: boolean; checkIntervalHours: number; installMode: ExtensionAutoUpdateInstallMode; notificationLevel: ExtensionAutoUpdateNotificationLevel; } export class ExtensionAutoUpdater { private readonly settings: EffectiveExtensionAutoUpdateSettings; private readonly workspaceDir: string; private readonly notify?: ExtensionAutoUpdaterOptions['notify']; private readonly stateStore: ExtensionAutoUpdateStateStore; private readonly extensionLoader: () => Promise; private readonly updateExecutor: ( extension: LlxprtExtension, cwd: string, currentState: ExtensionUpdateState, setExtensionUpdateState: (updateState: ExtensionUpdateState) => void, ) => Promise; private readonly updateChecker: ( extension: LlxprtExtension, setExtensionUpdateState: (updateState: ExtensionUpdateState) => void, ) => Promise; private readonly now: () => number; private intervalHandle: NodeJS.Timeout | null = null; private isChecking = false; private disposed = false; constructor(options: ExtensionAutoUpdaterOptions = {}) { this.settings = resolveSettings(options.settings); this.workspaceDir = options.workspaceDir ?? process.cwd(); this.notify = options.notify; this.stateStore = options.stateStore ?? createFileStateStore(); this.extensionLoader = options.extensionLoader ?? (async () => loadUserExtensions()); this.updateExecutor = options.updateExecutor ?? ((extension, cwd, currentState, setExtensionUpdateState) => updateExtension( extension, cwd, async () => true, // Auto-approve in background mode currentState, (action) => { if (action.type === 'SET_STATE') { setExtensionUpdateState(action.payload.state); } }, )); this.updateChecker = options.updateChecker ?? checkForExtensionUpdate; this.now = options.now ?? Date.now; } /** * Starts the background auto-update loop. * Returns a cleanup function that stops scheduling additional checks. */ start(): () => void { if (!this.settings.enabled || this.disposed) { return () => {}; } void this.runCycle(); const intervalMs = this.settings.checkIntervalHours * HOUR_IN_MS; this.intervalHandle = setInterval(() => { void this.runCycle(); }, intervalMs); return () => this.stop(); } stop(): void { if (this.intervalHandle) { clearInterval(this.intervalHandle); this.intervalHandle = null; } this.disposed = true; } async checkNow(): Promise { await this.runCycle(); } private async runCycle(): Promise { if (!this.settings.enabled || this.disposed || this.isChecking) { return; } this.isChecking = true; try { const [state, extensions] = await Promise.all([ this.stateStore.read(), this.extensionLoader(), ]); const extensionsByName = new Map( extensions.map((extension) => [extension.name, extension]), ); await this.applyPendingInstalls(state, extensionsByName); for (const extension of extensions) { await this.processExtension(extension, state); } await this.stateStore.write(state); } finally { this.isChecking = false; } } private getEffectiveSettingsForExtension( extensionName: string, ): EffectivePerExtensionSettings { const override = this.settings.perExtension[extensionName]; return { enabled: override?.enabled ?? this.settings.enabled, installMode: override?.installMode ?? this.settings.installMode, notificationLevel: override?.notificationLevel ?? this.settings.notificationLevel, checkIntervalHours: clampIntervalHours( override?.checkIntervalHours ?? this.settings.checkIntervalHours, ), }; } private async applyPendingInstalls( state: ExtensionUpdateStateFile, extensionsByName: Map, ): Promise { const pendingEntries = Object.entries(state).filter( ([, entry]) => entry.pendingInstall === true, ); for (const [name, entry] of pendingEntries) { const extension = extensionsByName.get(name); if (extension?.installMetadata === undefined) { entry.pendingInstall = false; entry.lastError = `Extension "${name}" is no longer installed.`; entry.state = ExtensionUpdateState.ERROR; continue; } const settings = this.getEffectiveSettingsForExtension(name); await this.performUpdate(extension, entry, settings); } } private async processExtension( extension: LlxprtExtension, state: ExtensionUpdateStateFile, ): Promise { if (extension.installMetadata === undefined) { return; } const settings = this.getEffectiveSettingsForExtension(extension.name); if (settings.enabled !== true) { return; } const entry = state[extension.name] ?? (state[extension.name] = {}); const now = this.now(); const intervalMs = settings.checkIntervalHours * HOUR_IN_MS; if ( entry.lastCheck !== undefined && entry.lastCheck !== 0 && now - entry.lastCheck < intervalMs ) { return; } entry.lastCheck = now; entry.state = ExtensionUpdateState.CHECKING_FOR_UPDATES; try { // Convert Extension to LlxprtExtension for the updateChecker const llxprtExtension: LlxprtExtension = { name: extension.name, version: extension.version, isActive: true, path: extension.path, installMetadata: extension.installMetadata, contextFiles: extension.contextFiles, }; // Call the update checker which will update entry.state via callback // Initialize with explicit type to prevent narrowing let resultState: ExtensionUpdateState | undefined; await this.updateChecker(llxprtExtension, (updateState) => { resultState = updateState; entry.state = updateState; }); entry.lastError = undefined; // Check the state after update checker completes if (resultState === ExtensionUpdateState.UPDATE_AVAILABLE) { await this.handleUpdateAvailable(extension, entry, settings); } else if ( resultState === ExtensionUpdateState.UP_TO_DATE || resultState === ExtensionUpdateState.NOT_UPDATABLE ) { entry.failureCount = 0; } } catch (error) { entry.lastError = getErrorMessage(error); entry.failureCount = (entry.failureCount ?? 0) + 1; entry.state = ExtensionUpdateState.ERROR; this.notifyWithLevel( 'error', `Failed to check extension "${extension.name}" for updates: ${entry.lastError}`, settings.notificationLevel, ); } } private async handleUpdateAvailable( extension: LlxprtExtension, entry: ExtensionUpdateHistoryEntry, settings: EffectivePerExtensionSettings, ): Promise { switch (settings.installMode) { case 'immediate': await this.performUpdate(extension, entry, settings); break; case 'on-restart': entry.pendingInstall = true; this.notifyWithLevel( 'info', `Extension "${extension.name}" update queued; it will install on the next restart.`, settings.notificationLevel, ); break; case 'manual': default: this.notifyWithLevel( 'info', `Update available for extension "${extension.name}". Run "llxprt extensions update ${extension.name}" to install.`, settings.notificationLevel, ); break; } } private async performUpdate( extension: LlxprtExtension, entry: ExtensionUpdateHistoryEntry, settings: EffectivePerExtensionSettings, ): Promise { try { entry.state = ExtensionUpdateState.UPDATING; // Convert Extension to LlxprtExtension for the updateExecutor const llxprtExtension: LlxprtExtension = { name: extension.name, version: extension.version, isActive: true, path: extension.path, installMetadata: extension.installMetadata, contextFiles: extension.contextFiles, }; const info = await this.updateExecutor( llxprtExtension, this.workspaceDir, entry.state, (updateState) => { entry.state = updateState; }, ); if (!info) { throw new Error('Update returned undefined'); } entry.lastUpdate = this.now(); entry.pendingInstall = false; entry.state = ExtensionUpdateState.UPDATED_NEEDS_RESTART; entry.failureCount = 0; entry.lastError = undefined; this.notifyWithLevel( 'info', `Extension "${info.name}" updated to ${info.updatedVersion}. Restart llxprt-code to load the new version.`, settings.notificationLevel, ); } catch (error) { entry.lastError = getErrorMessage(error); entry.failureCount = (entry.failureCount ?? 0) + 1; entry.state = ExtensionUpdateState.ERROR; this.notifyWithLevel( 'error', `Failed to update extension "${extension.name}": ${entry.lastError}`, settings.notificationLevel, ); } } private notifyWithLevel( level: 'info' | 'warn' | 'error', message: string, notificationLevel: ExtensionAutoUpdateNotificationLevel, ): void { if (notificationLevel === 'silent' && level !== 'error') { return; } if (this.notify) { this.notify(message, level); return; } const prefix = '[extensions]'; if (level === 'error') { debugLogger.error(prefix, message); } else if (level === 'warn') { debugLogger.warn(prefix, message); } else { debugLogger.log(prefix, message); } } }