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';