feat: Add auto update functionality (#4686)

This commit is contained in:
Gal Zahavi 2025-07-28 17:56:52 -07:00 committed by GitHub
parent 83c4dddb7e
commit 871e0dfab8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1023 additions and 20 deletions

View File

@ -101,6 +101,10 @@ export interface Settings {
// Add other settings here.
ideMode?: boolean;
// Setting for disabling auto-update.
disableAutoUpdate?: boolean;
memoryDiscoveryMaxDirs?: number;
}

View File

@ -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;
}

View File

@ -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(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
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(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
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(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
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(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
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(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
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: {

View File

@ -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<string | null>(null);
const [updateInfo, setUpdateInfo] = useState<UpdateObject | null>(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 (
<StreamingContext.Provider value={streamingState}>
<Box flexDirection="column" width="90%">
{/* Move UpdateNotification outside Static so it can re-render when updateMessage changes */}
{updateMessage && <UpdateNotification message={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 && <Help commands={slashCommands} />}
<Box flexDirection="column" ref={mainControlsRef}>
{/* Move UpdateNotification to render update notification above input area */}
{updateInfo && <UpdateNotification message={updateInfo.message} />}
{startupWarnings.length > 0 && (
<Box
borderStyle="round"

View File

@ -50,7 +50,9 @@ describe('checkForUpdates', () => {
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();

View File

@ -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<string | null> {
export interface UpdateObject {
message: string;
update: UpdateInfo;
}
export async function checkForUpdates(): Promise<UpdateObject | null> {
try {
// Skip update check when running from source (development mode)
if (process.env.DEV === 'true') {
@ -30,11 +35,13 @@ export async function checkForUpdates(): Promise<string | null> {
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;

View File

@ -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<typeof vi.fn> };
stdout: { on: ReturnType<typeof vi.fn> };
on: ReturnType<typeof vi.fn>;
unref: ReturnType<typeof vi.fn>;
};
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.',
},
);
});
});

View File

@ -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<HistoryItem, 'id'>, 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);
};
}

View File

@ -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<typeof fs>();
return {
...actualFs,
realpathSync: vi.fn(),
existsSync: vi.fn(),
};
});
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
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');
});
});

View File

@ -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 };
}
}

View File

@ -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();

View File

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