From 871e0dfab811192f67cd80bc270580ad784ffdc8 Mon Sep 17 00:00:00 2001 From: Gal Zahavi <38544478+galz10@users.noreply.github.com> Date: Mon, 28 Jul 2025 17:56:52 -0700 Subject: [PATCH] feat: Add auto update functionality (#4686) --- packages/cli/src/config/settings.ts | 4 + packages/cli/src/gemini.tsx | 13 + packages/cli/src/ui/App.test.tsx | 178 ++++++++++ packages/cli/src/ui/App.tsx | 17 +- packages/cli/src/ui/utils/updateCheck.test.ts | 14 +- packages/cli/src/ui/utils/updateCheck.ts | 21 +- .../cli/src/utils/handleAutoUpdate.test.ts | 153 +++++++++ packages/cli/src/utils/handleAutoUpdate.ts | 139 ++++++++ .../cli/src/utils/installationInfo.test.ts | 313 ++++++++++++++++++ packages/cli/src/utils/installationInfo.ts | 177 ++++++++++ packages/cli/src/utils/updateEventEmitter.ts | 13 + packages/core/src/index.ts | 1 + 12 files changed, 1023 insertions(+), 20 deletions(-) create mode 100644 packages/cli/src/utils/handleAutoUpdate.test.ts create mode 100644 packages/cli/src/utils/handleAutoUpdate.ts create mode 100644 packages/cli/src/utils/installationInfo.test.ts create mode 100644 packages/cli/src/utils/installationInfo.ts create mode 100644 packages/cli/src/utils/updateEventEmitter.ts diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index c353d0c1..17c1d0d5 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -101,6 +101,10 @@ export interface Settings { // Add other settings here. ideMode?: boolean; + + // Setting for disabling auto-update. + disableAutoUpdate?: boolean; + memoryDiscoveryMaxDirs?: number; } diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index c771fb95..a31c4b2f 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -40,6 +40,8 @@ import { import { validateAuthMethod } from './config/auth.js'; import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; +import { checkForUpdates } from './ui/utils/updateCheck.js'; +import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; import { appEvents, AppEvent } from './utils/events.js'; function getNodeMemoryArgs(config: Config): string[] { @@ -246,6 +248,17 @@ export async function main() { { exitOnCtrlC: false }, ); + checkForUpdates() + .then((info) => { + handleAutoUpdate(info, settings, config.getProjectRoot()); + }) + .catch((err) => { + // Silently ignore update check errors. + if (config.getDebugMode()) { + console.error('Update check failed:', err); + } + }); + registerCleanup(() => instance.unmount()); return; } diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index f35f8cb7..fef4106a 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -23,6 +23,9 @@ import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useConsoleMessages } from './hooks/useConsoleMessages.js'; import { StreamingState, ConsoleMessageItem } from './types.js'; import { Tips } from './components/Tips.js'; +import { checkForUpdates, UpdateObject } from './utils/updateCheck.js'; +import { EventEmitter } from 'events'; +import { updateEventEmitter } from '../utils/updateEventEmitter.js'; // Define a more complete mock server config based on actual Config interface MockServerConfig { @@ -163,6 +166,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { MCPServerConfig: actualCore.MCPServerConfig, getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']), ideContext: ideContextMock, + isGitRepository: vi.fn(), }; }); @@ -220,6 +224,17 @@ vi.mock('./components/Header.js', () => ({ Header: vi.fn(() => null), })); +vi.mock('./utils/updateCheck.js', () => ({ + checkForUpdates: vi.fn(), +})); + +const mockedCheckForUpdates = vi.mocked(checkForUpdates); +const { isGitRepository: mockedIsGitRepository } = vi.mocked( + await import('@google/gemini-cli-core'), +); + +vi.mock('node:child_process'); + describe('App UI', () => { let mockConfig: MockServerConfig; let mockSettings: LoadedSettings; @@ -288,6 +303,169 @@ describe('App UI', () => { vi.clearAllMocks(); // Clear mocks after each test }); + describe('handleAutoUpdate', () => { + let spawnEmitter: EventEmitter; + + beforeEach(async () => { + const { spawn } = await import('node:child_process'); + spawnEmitter = new EventEmitter(); + spawnEmitter.stdout = new EventEmitter(); + spawnEmitter.stderr = new EventEmitter(); + (spawn as vi.Mock).mockReturnValue(spawnEmitter); + }); + + afterEach(() => { + delete process.env.GEMINI_CLI_DISABLE_AUTOUPDATER; + }); + + it('should not start the update process when running from git', async () => { + mockedIsGitRepository.mockResolvedValue(true); + const info: UpdateObject = { + update: { + name: '@google/gemini-cli', + latest: '1.1.0', + current: '1.0.0', + }, + message: 'Gemini CLI update available!', + }; + mockedCheckForUpdates.mockResolvedValue(info); + const { spawn } = await import('node:child_process'); + + const { unmount } = render( + , + ); + currentUnmount = unmount; + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(spawn).not.toHaveBeenCalled(); + }); + + it('should show a success message when update succeeds', async () => { + mockedIsGitRepository.mockResolvedValue(false); + const info: UpdateObject = { + update: { + name: '@google/gemini-cli', + latest: '1.1.0', + current: '1.0.0', + }, + message: 'Update available', + }; + mockedCheckForUpdates.mockResolvedValue(info); + + const { lastFrame, unmount } = render( + , + ); + currentUnmount = unmount; + + updateEventEmitter.emit('update-success', info); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(lastFrame()).toContain( + 'Update successful! The new version will be used on your next run.', + ); + }); + + it('should show an error message when update fails', async () => { + mockedIsGitRepository.mockResolvedValue(false); + const info: UpdateObject = { + update: { + name: '@google/gemini-cli', + latest: '1.1.0', + current: '1.0.0', + }, + message: 'Update available', + }; + mockedCheckForUpdates.mockResolvedValue(info); + + const { lastFrame, unmount } = render( + , + ); + currentUnmount = unmount; + + updateEventEmitter.emit('update-failed', info); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(lastFrame()).toContain( + 'Automatic update failed. Please try updating manually', + ); + }); + + it('should show an error message when spawn fails', async () => { + mockedIsGitRepository.mockResolvedValue(false); + const info: UpdateObject = { + update: { + name: '@google/gemini-cli', + latest: '1.1.0', + current: '1.0.0', + }, + message: 'Update available', + }; + mockedCheckForUpdates.mockResolvedValue(info); + + const { lastFrame, unmount } = render( + , + ); + currentUnmount = unmount; + + // We are testing the App's reaction to an `update-failed` event, + // which is what should be emitted when a spawn error occurs elsewhere. + updateEventEmitter.emit('update-failed', info); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(lastFrame()).toContain( + 'Automatic update failed. Please try updating manually', + ); + }); + + it('should not auto-update if GEMINI_CLI_DISABLE_AUTOUPDATER is true', async () => { + mockedIsGitRepository.mockResolvedValue(false); + process.env.GEMINI_CLI_DISABLE_AUTOUPDATER = 'true'; + const info: UpdateObject = { + update: { + name: '@google/gemini-cli', + latest: '1.1.0', + current: '1.0.0', + }, + message: 'Update available', + }; + mockedCheckForUpdates.mockResolvedValue(info); + const { spawn } = await import('node:child_process'); + + const { unmount } = render( + , + ); + currentUnmount = unmount; + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(spawn).not.toHaveBeenCalled(); + }); + }); + it('should display active file when available', async () => { vi.mocked(ideContext.getIdeContext).mockReturnValue({ workspaceState: { diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 1ee8e8a8..7ac6936c 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -83,11 +83,12 @@ import { isGenericQuotaExceededError, UserTierId, } from '@google/gemini-cli-core'; -import { checkForUpdates } from './utils/updateCheck.js'; +import { UpdateObject } from './utils/updateCheck.js'; import ansiEscapes from 'ansi-escapes'; import { OverflowProvider } from './contexts/OverflowContext.js'; import { ShowMoreLines } from './components/ShowMoreLines.js'; import { PrivacyNotice } from './privacy/PrivacyNotice.js'; +import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; import { appEvents, AppEvent } from '../utils/events.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; @@ -110,15 +111,16 @@ export const AppWrapper = (props: AppProps) => ( const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { const isFocused = useFocus(); useBracketedPaste(); - const [updateMessage, setUpdateMessage] = useState(null); + const [updateInfo, setUpdateInfo] = useState(null); const { stdout } = useStdout(); const nightly = version.includes('nightly'); + const { history, addItem, clearItems, loadHistory } = useHistory(); useEffect(() => { - checkForUpdates().then(setUpdateMessage); - }, []); + const cleanup = setUpdateHandler(addItem, setUpdateInfo); + return cleanup; + }, [addItem]); - const { history, addItem, clearItems, loadHistory } = useHistory(); const { consoleMessages, handleNewMessage, @@ -757,9 +759,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { return ( - {/* Move UpdateNotification outside Static so it can re-render when updateMessage changes */} - {updateMessage && } - {/* * The Static component is an Ink intrinsic in which there can only be 1 per application. * Because of this restriction we're hacking it slightly by having a 'header' item here to @@ -822,6 +821,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { {showHelp && } + {/* Move UpdateNotification to render update notification above input area */} + {updateInfo && } {startupWarnings.length > 0 && ( { name: 'test-package', version: '1.0.0', }); - updateNotifier.mockReturnValue({ update: null }); + updateNotifier.mockReturnValue({ + fetchInfo: vi.fn(async () => null), + }); const result = await checkForUpdates(); expect(result).toBeNull(); }); @@ -61,10 +63,12 @@ describe('checkForUpdates', () => { version: '1.0.0', }); updateNotifier.mockReturnValue({ - update: { current: '1.0.0', latest: '1.1.0' }, + fetchInfo: vi.fn(async () => ({ current: '1.0.0', latest: '1.1.0' })), }); + const result = await checkForUpdates(); - expect(result).toContain('1.0.0 → 1.1.0'); + expect(result?.message).toContain('1.0.0 → 1.1.0'); + expect(result?.update).toEqual({ current: '1.0.0', latest: '1.1.0' }); }); it('should return null if the latest version is the same as the current version', async () => { @@ -73,7 +77,7 @@ describe('checkForUpdates', () => { version: '1.0.0', }); updateNotifier.mockReturnValue({ - update: { current: '1.0.0', latest: '1.0.0' }, + fetchInfo: vi.fn(async () => ({ current: '1.0.0', latest: '1.0.0' })), }); const result = await checkForUpdates(); expect(result).toBeNull(); @@ -85,7 +89,7 @@ describe('checkForUpdates', () => { version: '1.1.0', }); updateNotifier.mockReturnValue({ - update: { current: '1.1.0', latest: '1.0.0' }, + fetchInfo: vi.fn(async () => ({ current: '1.0.0', latest: '0.09' })), }); const result = await checkForUpdates(); expect(result).toBeNull(); diff --git a/packages/cli/src/ui/utils/updateCheck.ts b/packages/cli/src/ui/utils/updateCheck.ts index 904a9890..b0a0de1b 100644 --- a/packages/cli/src/ui/utils/updateCheck.ts +++ b/packages/cli/src/ui/utils/updateCheck.ts @@ -4,11 +4,16 @@ * SPDX-License-Identifier: Apache-2.0 */ -import updateNotifier from 'update-notifier'; +import updateNotifier, { UpdateInfo } from 'update-notifier'; import semver from 'semver'; import { getPackageJson } from '../../utils/package.js'; -export async function checkForUpdates(): Promise { +export interface UpdateObject { + message: string; + update: UpdateInfo; +} + +export async function checkForUpdates(): Promise { try { // Skip update check when running from source (development mode) if (process.env.DEV === 'true') { @@ -30,11 +35,13 @@ export async function checkForUpdates(): Promise { shouldNotifyInNpmScript: true, }); - if ( - notifier.update && - semver.gt(notifier.update.latest, notifier.update.current) - ) { - return `Gemini CLI update available! ${notifier.update.current} → ${notifier.update.latest}\nRun npm install -g ${packageJson.name} to update`; + const updateInfo = await notifier.fetchInfo(); + + if (updateInfo && semver.gt(updateInfo.latest, updateInfo.current)) { + return { + message: `Gemini CLI update available! ${updateInfo.current} → ${updateInfo.latest}`, + update: updateInfo, + }; } return null; diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts new file mode 100644 index 00000000..adaed932 --- /dev/null +++ b/packages/cli/src/utils/handleAutoUpdate.test.ts @@ -0,0 +1,153 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ChildProcess, spawn } from 'node:child_process'; +import { handleAutoUpdate } from './handleAutoUpdate.js'; +import { getInstallationInfo, PackageManager } from './installationInfo.js'; +import { updateEventEmitter } from './updateEventEmitter.js'; +import { UpdateObject } from '../ui/utils/updateCheck.js'; +import { LoadedSettings } from '../config/settings.js'; + +// Mock dependencies +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process'); + return { + ...actual, + spawn: vi.fn(), + }; +}); + +vi.mock('./installationInfo.js', async () => { + const actual = await vi.importActual('./installationInfo.js'); + return { + ...actual, + getInstallationInfo: vi.fn(), + }; +}); + +vi.mock('./updateEventEmitter.js', async () => { + const actual = await vi.importActual('./updateEventEmitter.js'); + return { + ...actual, + updateEventEmitter: { + ...actual.updateEventEmitter, + emit: vi.fn(), + }, + }; +}); + +const mockSpawn = vi.mocked(spawn); +const mockGetInstallationInfo = vi.mocked(getInstallationInfo); +const mockUpdateEventEmitter = vi.mocked(updateEventEmitter); + +describe('handleAutoUpdate', () => { + let mockUpdateInfo: UpdateObject; + let mockSettings: LoadedSettings; + let mockChildProcess: { + stderr: { on: ReturnType }; + stdout: { on: ReturnType }; + on: ReturnType; + unref: ReturnType; + }; + + beforeEach(() => { + mockUpdateInfo = { + update: { + latest: '2.0.0', + current: '1.0.0', + type: 'major', + name: '@google/gemini-cli', + }, + message: 'An update is available!', + }; + + mockSettings = { + merged: { + disableAutoUpdate: false, + }, + } as LoadedSettings; + + mockChildProcess = { + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn(), + unref: vi.fn(), + }; + mockSpawn.mockReturnValue(mockChildProcess as unknown as ChildProcess); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should do nothing if update info is null', () => { + handleAutoUpdate(null, mockSettings, '/root'); + expect(mockGetInstallationInfo).not.toHaveBeenCalled(); + expect(mockUpdateEventEmitter.emit).not.toHaveBeenCalled(); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('should emit "update-received" but not update if auto-updates are disabled', () => { + mockSettings.merged.disableAutoUpdate = true; + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm i -g @google/gemini-cli@latest', + updateMessage: 'Please update manually.', + isGlobal: true, + packageManager: PackageManager.NPM, + }); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root'); + + expect(mockUpdateEventEmitter.emit).toHaveBeenCalledTimes(1); + expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith( + 'update-received', + { + message: 'An update is available!\nPlease update manually.', + }, + ); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('should emit "update-received" but not update if no update command is found', () => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: undefined, + updateMessage: 'Cannot determine update command.', + isGlobal: false, + packageManager: PackageManager.NPM, + }); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root'); + + expect(mockUpdateEventEmitter.emit).toHaveBeenCalledTimes(1); + expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith( + 'update-received', + { + message: 'An update is available!\nCannot determine update command.', + }, + ); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('should combine update messages correctly', () => { + mockGetInstallationInfo.mockReturnValue({ + updateCommand: undefined, // No command to prevent spawn + updateMessage: 'This is an additional message.', + isGlobal: false, + packageManager: PackageManager.NPM, + }); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root'); + + expect(mockUpdateEventEmitter.emit).toHaveBeenCalledTimes(1); + expect(mockUpdateEventEmitter.emit).toHaveBeenCalledWith( + 'update-received', + { + message: 'An update is available!\nThis is an additional message.', + }, + ); + }); +}); diff --git a/packages/cli/src/utils/handleAutoUpdate.ts b/packages/cli/src/utils/handleAutoUpdate.ts new file mode 100644 index 00000000..1ef2d475 --- /dev/null +++ b/packages/cli/src/utils/handleAutoUpdate.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawn } from 'node:child_process'; +import { UpdateObject } from '../ui/utils/updateCheck.js'; +import { LoadedSettings } from '../config/settings.js'; +import { getInstallationInfo } from './installationInfo.js'; +import { updateEventEmitter } from './updateEventEmitter.js'; +import { HistoryItem, MessageType } from '../ui/types.js'; + +export function handleAutoUpdate( + info: UpdateObject | null, + settings: LoadedSettings, + projectRoot: string, +) { + if (!info) { + return; + } + + const installationInfo = getInstallationInfo( + projectRoot, + settings.merged.disableAutoUpdate ?? false, + ); + + let combinedMessage = info.message; + if (installationInfo.updateMessage) { + combinedMessage += `\n${installationInfo.updateMessage}`; + } + + updateEventEmitter.emit('update-received', { + message: combinedMessage, + }); + + if (!installationInfo.updateCommand || settings.merged.disableAutoUpdate) { + return; + } + + const updateCommand = installationInfo.updateCommand.replace( + '@latest', + `@${info.update.latest}`, + ); + + const updateProcess = spawn(updateCommand, { stdio: 'pipe', shell: true }); + let errorOutput = ''; + updateProcess.stderr.on('data', (data) => { + errorOutput += data.toString(); + }); + + updateProcess.on('close', (code) => { + 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}, stderr: ${errorOutput.trim()})`, + }); + } + }); + + updateProcess.on('error', (err) => { + updateEventEmitter.emit('update-failed', { + message: `Automatic update failed. Please try updating manually. (error: ${err.message})`, + }); + }); + return updateProcess; +} + +export function setUpdateHandler( + addItem: (item: Omit, timestamp: number) => void, + setUpdateInfo: (info: UpdateObject | null) => void, +) { + let successfullyInstalled = false; + const handleUpdateRecieved = (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', handleUpdateRecieved); + updateEventEmitter.on('update-failed', handleUpdateFailed); + updateEventEmitter.on('update-success', handleUpdateSuccess); + updateEventEmitter.on('update-info', handleUpdateInfo); + + return () => { + updateEventEmitter.off('update-received', handleUpdateRecieved); + updateEventEmitter.off('update-failed', handleUpdateFailed); + updateEventEmitter.off('update-success', handleUpdateSuccess); + updateEventEmitter.off('update-info', handleUpdateInfo); + }; +} diff --git a/packages/cli/src/utils/installationInfo.test.ts b/packages/cli/src/utils/installationInfo.test.ts new file mode 100644 index 00000000..c2bcf074 --- /dev/null +++ b/packages/cli/src/utils/installationInfo.test.ts @@ -0,0 +1,313 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { getInstallationInfo, PackageManager } from './installationInfo.js'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as childProcess from 'child_process'; +import { isGitRepository } from '@google/gemini-cli-core'; + +vi.mock('@google/gemini-cli-core', () => ({ + isGitRepository: vi.fn(), +})); + +vi.mock('fs', async (importOriginal) => { + const actualFs = await importOriginal(); + return { + ...actualFs, + realpathSync: vi.fn(), + existsSync: vi.fn(), + }; +}); + +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execSync: vi.fn(), + }; +}); + +const mockedIsGitRepository = vi.mocked(isGitRepository); +const mockedRealPathSync = vi.mocked(fs.realpathSync); +const mockedExistsSync = vi.mocked(fs.existsSync); +const mockedExecSync = vi.mocked(childProcess.execSync); + +describe('getInstallationInfo', () => { + const projectRoot = '/path/to/project'; + let originalArgv: string[]; + + beforeEach(() => { + vi.resetAllMocks(); + originalArgv = [...process.argv]; + // Mock process.cwd() for isGitRepository + vi.spyOn(process, 'cwd').mockReturnValue(projectRoot); + }); + + afterEach(() => { + process.argv = originalArgv; + }); + + it('should return UNKNOWN when cliPath is not available', () => { + process.argv[1] = ''; + const info = getInstallationInfo(projectRoot, false); + expect(info.packageManager).toBe(PackageManager.UNKNOWN); + }); + + it('should return UNKNOWN and log error if realpathSync fails', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + process.argv[1] = '/path/to/cli'; + const error = new Error('realpath failed'); + mockedRealPathSync.mockImplementation(() => { + throw error; + }); + + const info = getInstallationInfo(projectRoot, false); + + expect(info.packageManager).toBe(PackageManager.UNKNOWN); + expect(consoleSpy).toHaveBeenCalledWith(error); + consoleSpy.mockRestore(); + }); + + it('should detect running from a local git clone', () => { + process.argv[1] = `${projectRoot}/packages/cli/dist/index.js`; + mockedRealPathSync.mockReturnValue( + `${projectRoot}/packages/cli/dist/index.js`, + ); + mockedIsGitRepository.mockReturnValue(true); + + const info = getInstallationInfo(projectRoot, false); + + expect(info.packageManager).toBe(PackageManager.UNKNOWN); + expect(info.isGlobal).toBe(false); + expect(info.updateMessage).toBe( + 'Running from a local git clone. Please update with "git pull".', + ); + }); + + it('should detect running via npx', () => { + const npxPath = `/Users/test/.npm/_npx/12345/bin/gemini`; + process.argv[1] = npxPath; + mockedRealPathSync.mockReturnValue(npxPath); + + const info = getInstallationInfo(projectRoot, false); + + expect(info.packageManager).toBe(PackageManager.NPX); + expect(info.isGlobal).toBe(false); + expect(info.updateMessage).toBe('Running via npx, update not applicable.'); + }); + + it('should detect running via pnpx', () => { + const pnpxPath = `/Users/test/.pnpm/_pnpx/12345/bin/gemini`; + process.argv[1] = pnpxPath; + mockedRealPathSync.mockReturnValue(pnpxPath); + + const info = getInstallationInfo(projectRoot, false); + + expect(info.packageManager).toBe(PackageManager.PNPX); + expect(info.isGlobal).toBe(false); + expect(info.updateMessage).toBe('Running via pnpx, update not applicable.'); + }); + + it('should detect running via bunx', () => { + const bunxPath = `/Users/test/.bun/install/cache/12345/bin/gemini`; + process.argv[1] = bunxPath; + mockedRealPathSync.mockReturnValue(bunxPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + + const info = getInstallationInfo(projectRoot, false); + + expect(info.packageManager).toBe(PackageManager.BUNX); + expect(info.isGlobal).toBe(false); + expect(info.updateMessage).toBe('Running via bunx, update not applicable.'); + }); + + it('should detect Homebrew installation via execSync', () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + }); + const cliPath = '/usr/local/bin/gemini'; + process.argv[1] = cliPath; + mockedRealPathSync.mockReturnValue(cliPath); + mockedExecSync.mockReturnValue(Buffer.from('gemini-cli')); // Simulate successful command + + const info = getInstallationInfo(projectRoot, false); + + expect(mockedExecSync).toHaveBeenCalledWith( + 'brew list -1 | grep -q "^gemini-cli$"', + { stdio: 'ignore' }, + ); + expect(info.packageManager).toBe(PackageManager.HOMEBREW); + expect(info.isGlobal).toBe(true); + expect(info.updateMessage).toContain('brew upgrade'); + }); + + it('should fall through if brew command fails', () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + }); + const cliPath = '/usr/local/bin/gemini'; + process.argv[1] = cliPath; + mockedRealPathSync.mockReturnValue(cliPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + + const info = getInstallationInfo(projectRoot, false); + + expect(mockedExecSync).toHaveBeenCalledWith( + 'brew list -1 | grep -q "^gemini-cli$"', + { stdio: 'ignore' }, + ); + // Should fall back to default global npm + expect(info.packageManager).toBe(PackageManager.NPM); + expect(info.isGlobal).toBe(true); + }); + + it('should detect global pnpm installation', () => { + const pnpmPath = `/Users/test/.pnpm/global/5/node_modules/.pnpm/some-hash/node_modules/@google/gemini-cli/dist/index.js`; + process.argv[1] = pnpmPath; + mockedRealPathSync.mockReturnValue(pnpmPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + + const info = getInstallationInfo(projectRoot, false); + expect(info.packageManager).toBe(PackageManager.PNPM); + expect(info.isGlobal).toBe(true); + expect(info.updateCommand).toBe('pnpm add -g @google/gemini-cli@latest'); + expect(info.updateMessage).toContain('Attempting to automatically update'); + + const infoDisabled = getInstallationInfo(projectRoot, true); + expect(infoDisabled.updateMessage).toContain('Please run pnpm add'); + }); + + it('should detect global yarn installation', () => { + const yarnPath = `/Users/test/.yarn/global/node_modules/@google/gemini-cli/dist/index.js`; + process.argv[1] = yarnPath; + mockedRealPathSync.mockReturnValue(yarnPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + + const info = getInstallationInfo(projectRoot, false); + expect(info.packageManager).toBe(PackageManager.YARN); + expect(info.isGlobal).toBe(true); + expect(info.updateCommand).toBe( + 'yarn global add @google/gemini-cli@latest', + ); + expect(info.updateMessage).toContain('Attempting to automatically update'); + + const infoDisabled = getInstallationInfo(projectRoot, true); + expect(infoDisabled.updateMessage).toContain('Please run yarn global add'); + }); + + it('should detect global bun installation', () => { + const bunPath = `/Users/test/.bun/bin/gemini`; + process.argv[1] = bunPath; + mockedRealPathSync.mockReturnValue(bunPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + + const info = getInstallationInfo(projectRoot, false); + expect(info.packageManager).toBe(PackageManager.BUN); + expect(info.isGlobal).toBe(true); + expect(info.updateCommand).toBe('bun add -g @google/gemini-cli@latest'); + expect(info.updateMessage).toContain('Attempting to automatically update'); + + const infoDisabled = getInstallationInfo(projectRoot, true); + expect(infoDisabled.updateMessage).toContain('Please run bun add'); + }); + + it('should detect local installation and identify yarn from lockfile', () => { + const localPath = `${projectRoot}/node_modules/.bin/gemini`; + process.argv[1] = localPath; + mockedRealPathSync.mockReturnValue(localPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + mockedExistsSync.mockImplementation( + (p) => p === path.join(projectRoot, 'yarn.lock'), + ); + + const info = getInstallationInfo(projectRoot, false); + + expect(info.packageManager).toBe(PackageManager.YARN); + expect(info.isGlobal).toBe(false); + expect(info.updateMessage).toContain('Locally installed'); + }); + + it('should detect local installation and identify pnpm from lockfile', () => { + const localPath = `${projectRoot}/node_modules/.bin/gemini`; + process.argv[1] = localPath; + mockedRealPathSync.mockReturnValue(localPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + mockedExistsSync.mockImplementation( + (p) => p === path.join(projectRoot, 'pnpm-lock.yaml'), + ); + + const info = getInstallationInfo(projectRoot, false); + + expect(info.packageManager).toBe(PackageManager.PNPM); + expect(info.isGlobal).toBe(false); + }); + + it('should detect local installation and identify bun from lockfile', () => { + const localPath = `${projectRoot}/node_modules/.bin/gemini`; + process.argv[1] = localPath; + mockedRealPathSync.mockReturnValue(localPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + mockedExistsSync.mockImplementation( + (p) => p === path.join(projectRoot, 'bun.lockb'), + ); + + const info = getInstallationInfo(projectRoot, false); + + expect(info.packageManager).toBe(PackageManager.BUN); + expect(info.isGlobal).toBe(false); + }); + + it('should default to local npm installation if no lockfile is found', () => { + const localPath = `${projectRoot}/node_modules/.bin/gemini`; + process.argv[1] = localPath; + mockedRealPathSync.mockReturnValue(localPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + mockedExistsSync.mockReturnValue(false); // No lockfiles + + const info = getInstallationInfo(projectRoot, false); + + expect(info.packageManager).toBe(PackageManager.NPM); + expect(info.isGlobal).toBe(false); + }); + + it('should default to global npm installation for unrecognized paths', () => { + const globalPath = `/usr/local/bin/gemini`; + process.argv[1] = globalPath; + mockedRealPathSync.mockReturnValue(globalPath); + mockedExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + + const info = getInstallationInfo(projectRoot, false); + expect(info.packageManager).toBe(PackageManager.NPM); + expect(info.isGlobal).toBe(true); + expect(info.updateCommand).toBe('npm install -g @google/gemini-cli@latest'); + expect(info.updateMessage).toContain('Attempting to automatically update'); + + const infoDisabled = getInstallationInfo(projectRoot, true); + expect(infoDisabled.updateMessage).toContain('Please run npm install'); + }); +}); diff --git a/packages/cli/src/utils/installationInfo.ts b/packages/cli/src/utils/installationInfo.ts new file mode 100644 index 00000000..ca5733d3 --- /dev/null +++ b/packages/cli/src/utils/installationInfo.ts @@ -0,0 +1,177 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { isGitRepository } from '@google/gemini-cli-core'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as childProcess from 'child_process'; + +export enum PackageManager { + NPM = 'npm', + YARN = 'yarn', + PNPM = 'pnpm', + PNPX = 'pnpx', + BUN = 'bun', + BUNX = 'bunx', + HOMEBREW = 'homebrew', + NPX = 'npx', + UNKNOWN = 'unknown', +} + +export interface InstallationInfo { + packageManager: PackageManager; + isGlobal: boolean; + updateCommand?: string; + updateMessage?: string; +} + +export function getInstallationInfo( + projectRoot: string, + isAutoUpdateDisabled: boolean, +): InstallationInfo { + const cliPath = process.argv[1]; + if (!cliPath) { + return { packageManager: PackageManager.UNKNOWN, isGlobal: false }; + } + + try { + // Normalize path separators to forward slashes for consistent matching. + const realPath = fs.realpathSync(cliPath).replace(/\\/g, '/'); + const normalizedProjectRoot = projectRoot?.replace(/\\/g, '/'); + const isGit = isGitRepository(process.cwd()); + + // Check for local git clone first + if ( + isGit && + normalizedProjectRoot && + realPath.startsWith(normalizedProjectRoot) && + !realPath.includes('/node_modules/') + ) { + return { + packageManager: PackageManager.UNKNOWN, // Not managed by a package manager in this sense + isGlobal: false, + updateMessage: + 'Running from a local git clone. Please update with "git pull".', + }; + } + + // Check for npx/pnpx + if (realPath.includes('/.npm/_npx') || realPath.includes('/npm/_npx')) { + return { + packageManager: PackageManager.NPX, + isGlobal: false, + updateMessage: 'Running via npx, update not applicable.', + }; + } + if (realPath.includes('/.pnpm/_pnpx')) { + return { + packageManager: PackageManager.PNPX, + isGlobal: false, + updateMessage: 'Running via pnpx, update not applicable.', + }; + } + + // Check for Homebrew + if (process.platform === 'darwin') { + try { + // The package name in homebrew is gemini-cli + childProcess.execSync('brew list -1 | grep -q "^gemini-cli$"', { + stdio: 'ignore', + }); + return { + packageManager: PackageManager.HOMEBREW, + isGlobal: true, + updateMessage: + 'Installed via Homebrew. Please update with "brew upgrade".', + }; + } catch (_error) { + // Brew is not installed or gemini-cli is not installed via brew. + // Continue to the next check. + } + } + + // Check for pnpm + if (realPath.includes('/.pnpm/global')) { + const updateCommand = 'pnpm add -g @google/gemini-cli@latest'; + return { + packageManager: PackageManager.PNPM, + isGlobal: true, + updateCommand, + updateMessage: isAutoUpdateDisabled + ? `Please run ${updateCommand} to update` + : 'Installed with pnpm. Attempting to automatically update now...', + }; + } + + // Check for yarn + if (realPath.includes('/.yarn/global')) { + const updateCommand = 'yarn global add @google/gemini-cli@latest'; + return { + packageManager: PackageManager.YARN, + isGlobal: true, + updateCommand, + updateMessage: isAutoUpdateDisabled + ? `Please run ${updateCommand} to update` + : 'Installed with yarn. Attempting to automatically update now...', + }; + } + + // Check for bun + if (realPath.includes('/.bun/install/cache')) { + return { + packageManager: PackageManager.BUNX, + isGlobal: false, + updateMessage: 'Running via bunx, update not applicable.', + }; + } + if (realPath.includes('/.bun/bin')) { + const updateCommand = 'bun add -g @google/gemini-cli@latest'; + return { + packageManager: PackageManager.BUN, + isGlobal: true, + updateCommand, + updateMessage: isAutoUpdateDisabled + ? `Please run ${updateCommand} to update` + : 'Installed with bun. Attempting to automatically update now...', + }; + } + + // Check for local install + if ( + normalizedProjectRoot && + realPath.startsWith(`${normalizedProjectRoot}/node_modules`) + ) { + let pm = PackageManager.NPM; + if (fs.existsSync(path.join(projectRoot, 'yarn.lock'))) { + pm = PackageManager.YARN; + } else if (fs.existsSync(path.join(projectRoot, 'pnpm-lock.yaml'))) { + pm = PackageManager.PNPM; + } else if (fs.existsSync(path.join(projectRoot, 'bun.lockb'))) { + pm = PackageManager.BUN; + } + return { + packageManager: pm, + isGlobal: false, + updateMessage: + "Locally installed. Please update via your project's package.json.", + }; + } + + // Assume global npm + const updateCommand = 'npm install -g @google/gemini-cli@latest'; + return { + packageManager: PackageManager.NPM, + isGlobal: true, + updateCommand, + updateMessage: isAutoUpdateDisabled + ? `Please run ${updateCommand} to update` + : 'Installed with npm. Attempting to automatically update now...', + }; + } catch (error) { + console.log(error); + return { packageManager: PackageManager.UNKNOWN, isGlobal: false }; + } +} diff --git a/packages/cli/src/utils/updateEventEmitter.ts b/packages/cli/src/utils/updateEventEmitter.ts new file mode 100644 index 00000000..a60ef039 --- /dev/null +++ b/packages/cli/src/utils/updateEventEmitter.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EventEmitter } from 'events'; + +/** + * A shared event emitter for application-wide communication + * between decoupled parts of the CLI. + */ +export const updateEventEmitter = new EventEmitter(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a49c83fe..ecc408fe 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -31,6 +31,7 @@ export * from './utils/errors.js'; export * from './utils/getFolderStructure.js'; export * from './utils/memoryDiscovery.js'; export * from './utils/gitIgnoreParser.js'; +export * from './utils/gitUtils.js'; export * from './utils/editor.js'; export * from './utils/quotaErrorDetection.js'; export * from './utils/fileUtils.js';