mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
feat: Add auto update functionality (#4686)
This commit is contained in:
153
packages/cli/src/utils/handleAutoUpdate.test.ts
Normal file
153
packages/cli/src/utils/handleAutoUpdate.test.ts
Normal 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.',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
139
packages/cli/src/utils/handleAutoUpdate.ts
Normal file
139
packages/cli/src/utils/handleAutoUpdate.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
313
packages/cli/src/utils/installationInfo.test.ts
Normal file
313
packages/cli/src/utils/installationInfo.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
177
packages/cli/src/utils/installationInfo.ts
Normal file
177
packages/cli/src/utils/installationInfo.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
13
packages/cli/src/utils/updateEventEmitter.ts
Normal file
13
packages/cli/src/utils/updateEventEmitter.ts
Normal 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();
|
||||
Reference in New Issue
Block a user