mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-02-01 22:48:03 +00:00
init
This commit is contained in:
@@ -389,5 +389,5 @@ See the [Uninstall Guide](docs/cli/uninstall.md) for removal instructions.
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
Built with ❤️ by Google and the open source community
|
||||
Built with ❤️ by Google, shreya, and the open source community
|
||||
</p>
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
StartSessionEvent,
|
||||
logCliConfiguration,
|
||||
startupProfiler,
|
||||
type ConnectionConfig,
|
||||
type IdeInfo,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type LoadedSettings } from '../config/settings.js';
|
||||
import { performInitialAuth } from './auth.js';
|
||||
@@ -23,6 +25,9 @@ export interface InitializationResult {
|
||||
themeError: string | null;
|
||||
shouldOpenAuthDialog: boolean;
|
||||
geminiMdFileCount: number;
|
||||
availableIdeConnections?: Array<
|
||||
ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,10 +57,30 @@ export async function initializeApp(
|
||||
new StartSessionEvent(config, config.getToolRegistry()),
|
||||
);
|
||||
|
||||
let availableIdeConnections:
|
||||
| Array<ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }>
|
||||
| undefined;
|
||||
|
||||
if (config.getIdeMode()) {
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START));
|
||||
// Try to auto-connect if possible (legacy behavior or single match)
|
||||
// We attempt to connect. If it requires selection, we get a list back?
|
||||
// No, I need to implement the logic here.
|
||||
const connections = await ideClient.discoverAvailableConnections();
|
||||
|
||||
// Heuristic: If we have a PID match, prioritize it.
|
||||
// Ideally IdeClient.getInstance() already did some detection but didn't connect.
|
||||
// Actually IdeClient.connect() (without args) tries to find "the one" config.
|
||||
// If I want to support multiple, I should check here.
|
||||
|
||||
if (connections.length > 1) {
|
||||
// Multiple connections found, let the UI handle selection
|
||||
availableIdeConnections = connections;
|
||||
} else {
|
||||
// 0 or 1 connection, or let connect() handle the "best guess" fallback
|
||||
await ideClient.connect();
|
||||
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -63,5 +88,6 @@ export async function initializeApp(
|
||||
themeError,
|
||||
shouldOpenAuthDialog,
|
||||
geminiMdFileCount: config.getGeminiMdFileCount(),
|
||||
availableIdeConnections,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,7 +65,12 @@ import {
|
||||
generateSummary,
|
||||
type AgentsDiscoveredPayload,
|
||||
ChangeAuthRequestedError,
|
||||
IdeConnectionEvent,
|
||||
IdeConnectionType,
|
||||
logIdeConnection,
|
||||
type ConnectionConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { IdeConnectionSelector } from './components/IdeConnectionSelector.js';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
import process from 'node:process';
|
||||
import { useHistory } from './hooks/useHistoryManager.js';
|
||||
@@ -750,7 +755,40 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
dispatchExtensionStateUpdate,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
setText: (text: string) => buffer.setText(text),
|
||||
promptIdeConnection: async () => {
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.disconnect();
|
||||
const connections = await ideClient.discoverAvailableConnections();
|
||||
|
||||
if (connections.length > 1) {
|
||||
setAvailableIdeConnections(connections);
|
||||
} else if (connections.length === 1) {
|
||||
await ideClient.connect();
|
||||
logIdeConnection(
|
||||
config,
|
||||
new IdeConnectionEvent(IdeConnectionType.START),
|
||||
);
|
||||
// Show success message
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Connected to IDE: ${connections[0].ideInfo?.displayName || 'Unknown IDE'}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} else {
|
||||
// Show error message
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'No IDE connections found.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
setAuthState,
|
||||
openThemeDialog,
|
||||
@@ -1965,6 +2003,43 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
],
|
||||
);
|
||||
|
||||
// ... (existing imports)
|
||||
|
||||
// Inside AppContainer function:
|
||||
|
||||
// ... (existing state)
|
||||
const [availableIdeConnections, setAvailableIdeConnections] = useState<
|
||||
| Array<ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }>
|
||||
| undefined
|
||||
>(initializationResult.availableIdeConnections);
|
||||
|
||||
// ... (existing effects/hooks)
|
||||
|
||||
if (availableIdeConnections && availableIdeConnections.length > 0) {
|
||||
return (
|
||||
<IdeConnectionSelector
|
||||
connections={availableIdeConnections}
|
||||
onSelect={async (
|
||||
conn: ConnectionConfig & {
|
||||
workspacePath?: string;
|
||||
ideInfo?: IdeInfo;
|
||||
},
|
||||
) => {
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect({ connectionConfig: conn });
|
||||
logIdeConnection(
|
||||
config,
|
||||
new IdeConnectionEvent(IdeConnectionType.START),
|
||||
);
|
||||
setAvailableIdeConnections(undefined);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setAvailableIdeConnections(undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (authState === AuthState.AwaitingGoogleLoginRestart) {
|
||||
return (
|
||||
<LoginWithGoogleRestartDialog
|
||||
|
||||
@@ -136,20 +136,6 @@ async function setIdeModeAndSyncConnection(
|
||||
export const ideCommand = async (): Promise<SlashCommand> => {
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const currentIDE = ideClient.getCurrentIde();
|
||||
if (!currentIDE) {
|
||||
return {
|
||||
name: 'ide',
|
||||
description: 'Manage IDE integration',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
action: (): SlashCommandActionReturn =>
|
||||
({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: Antigravity, VS Code, or VS Code forks.`,
|
||||
}) as const,
|
||||
};
|
||||
}
|
||||
|
||||
const ideSlashCommand: SlashCommand = {
|
||||
name: 'ide',
|
||||
@@ -181,6 +167,16 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (context) => {
|
||||
if (!currentIDE) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'error',
|
||||
text: 'No IDE detected. Cannot run installer.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const installer = getIdeInstaller(currentIDE);
|
||||
if (!installer) {
|
||||
context.ui.addItem(
|
||||
@@ -297,15 +293,30 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
||||
},
|
||||
};
|
||||
|
||||
const switchCommand: SlashCommand = {
|
||||
name: 'switch',
|
||||
description: 'Switch to a different IDE connection',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (context: CommandContext) => {
|
||||
await context.ui.promptIdeConnection();
|
||||
},
|
||||
};
|
||||
|
||||
const { status } = ideClient.getConnectionStatus();
|
||||
const isConnected = status === IDEConnectionStatus.Connected;
|
||||
|
||||
if (isConnected) {
|
||||
ideSlashCommand.subCommands = [statusCommand, disableCommand];
|
||||
ideSlashCommand.subCommands = [
|
||||
statusCommand,
|
||||
switchCommand,
|
||||
disableCommand,
|
||||
];
|
||||
} else {
|
||||
ideSlashCommand.subCommands = [
|
||||
enableCommand,
|
||||
statusCommand,
|
||||
switchCommand,
|
||||
installCommand,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -83,7 +83,9 @@ export interface CommandContext {
|
||||
extensionsUpdateState: Map<string, ExtensionUpdateStatus>;
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
|
||||
|
||||
removeComponent: () => void;
|
||||
promptIdeConnection: () => Promise<void>;
|
||||
};
|
||||
// Session-specific data
|
||||
session: {
|
||||
|
||||
74
packages/cli/src/ui/components/IdeConnectionSelector.tsx
Normal file
74
packages/cli/src/ui/components/IdeConnectionSelector.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { type ConnectionConfig, type IdeInfo } from '@google/gemini-cli-core';
|
||||
import {
|
||||
RadioButtonSelect,
|
||||
type RadioSelectItem,
|
||||
} from './shared/RadioButtonSelect.js';
|
||||
|
||||
interface IdeConnectionSelectorProps {
|
||||
connections: Array<
|
||||
ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }
|
||||
>;
|
||||
onSelect: (
|
||||
connection: ConnectionConfig & {
|
||||
workspacePath?: string;
|
||||
ideInfo?: IdeInfo;
|
||||
},
|
||||
) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const IdeConnectionSelector = ({
|
||||
connections,
|
||||
onSelect,
|
||||
onCancel,
|
||||
}: IdeConnectionSelectorProps) => {
|
||||
const items: Array<RadioSelectItem<number>> = connections.map(
|
||||
(conn, index) => {
|
||||
const label = `${conn.ideInfo?.displayName || 'Unknown IDE'} (${conn.workspacePath || 'No workspace'})`;
|
||||
return {
|
||||
label,
|
||||
value: index,
|
||||
key: index.toString(),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Add an option to skip/cancel
|
||||
items.push({
|
||||
label: 'Do not connect to an IDE',
|
||||
value: -1,
|
||||
key: 'cancel',
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
borderStyle="round"
|
||||
borderColor="cyan"
|
||||
>
|
||||
<Text bold color="cyan">
|
||||
Multiple IDE connections found. Please select one:
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect
|
||||
items={items}
|
||||
onSelect={(value: number) => {
|
||||
if (value === -1) {
|
||||
onCancel();
|
||||
} else {
|
||||
onSelect(connections[value]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -213,7 +213,9 @@ describe('useSlashCommandProcessor', () => {
|
||||
toggleDebugProfiler: vi.fn(),
|
||||
dispatchExtensionStateUpdate: vi.fn(),
|
||||
addConfirmUpdateExtensionRequest: vi.fn(),
|
||||
|
||||
setText: vi.fn(),
|
||||
promptIdeConnection: vi.fn(),
|
||||
},
|
||||
new Map(), // extensionsUpdateState
|
||||
true, // isConfigInitialized
|
||||
|
||||
@@ -83,6 +83,7 @@ interface SlashCommandProcessorActions {
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
|
||||
setText: (text: string) => void;
|
||||
promptIdeConnection: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -237,6 +238,7 @@ export const useSlashCommandProcessor = (
|
||||
addConfirmUpdateExtensionRequest:
|
||||
actions.addConfirmUpdateExtensionRequest,
|
||||
removeComponent: () => setCustomDialog(null),
|
||||
promptIdeConnection: actions.promptIdeConnection,
|
||||
},
|
||||
session: {
|
||||
stats: session.stats,
|
||||
|
||||
@@ -29,5 +29,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
|
||||
dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {},
|
||||
addConfirmUpdateExtensionRequest: (_request) => {},
|
||||
removeComponent: () => {},
|
||||
promptIdeConnection: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,9 +13,6 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('detectIde', () => {
|
||||
const ideProcessInfo = { pid: 123, command: 'some/path/to/code' };
|
||||
const ideProcessInfoNoCode = { pid: 123, command: 'some/path/to/fork' };
|
||||
|
||||
beforeEach(() => {
|
||||
// Ensure these env vars don't leak from the host environment
|
||||
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
|
||||
@@ -40,147 +37,93 @@ describe('detectIde', () => {
|
||||
|
||||
it('should return undefined if TERM_PROGRAM is not vscode', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', '');
|
||||
expect(detectIde(ideProcessInfo)).toBeUndefined();
|
||||
expect(detectIde()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should detect Devin', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('__COG_BASHRC_SOURCED', '1');
|
||||
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.devin);
|
||||
expect(detectIde()).toBe(IDE_DEFINITIONS.devin);
|
||||
});
|
||||
|
||||
it('should detect Replit', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('REPLIT_USER', 'testuser');
|
||||
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.replit);
|
||||
expect(detectIde()).toBe(IDE_DEFINITIONS.replit);
|
||||
});
|
||||
|
||||
it('should detect Cursor', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('CURSOR_TRACE_ID', 'some-id');
|
||||
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.cursor);
|
||||
expect(detectIde()).toBe(IDE_DEFINITIONS.cursor);
|
||||
});
|
||||
|
||||
it('should detect Codespaces', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('CODESPACES', 'true');
|
||||
vi.stubEnv('CURSOR_TRACE_ID', '');
|
||||
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.codespaces);
|
||||
expect(detectIde()).toBe(IDE_DEFINITIONS.codespaces);
|
||||
});
|
||||
|
||||
it('should detect Cloud Shell via EDITOR_IN_CLOUD_SHELL', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('EDITOR_IN_CLOUD_SHELL', 'true');
|
||||
vi.stubEnv('CURSOR_TRACE_ID', '');
|
||||
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.cloudshell);
|
||||
expect(detectIde()).toBe(IDE_DEFINITIONS.cloudshell);
|
||||
});
|
||||
|
||||
it('should detect Cloud Shell via CLOUD_SHELL', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('CLOUD_SHELL', 'true');
|
||||
vi.stubEnv('CURSOR_TRACE_ID', '');
|
||||
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.cloudshell);
|
||||
expect(detectIde()).toBe(IDE_DEFINITIONS.cloudshell);
|
||||
});
|
||||
|
||||
it('should detect Trae', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('TERM_PRODUCT', 'Trae');
|
||||
vi.stubEnv('CURSOR_TRACE_ID', '');
|
||||
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.trae);
|
||||
expect(detectIde()).toBe(IDE_DEFINITIONS.trae);
|
||||
});
|
||||
|
||||
it('should detect Firebase Studio via MONOSPACE_ENV', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('MONOSPACE_ENV', 'true');
|
||||
vi.stubEnv('CURSOR_TRACE_ID', '');
|
||||
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.firebasestudio);
|
||||
});
|
||||
|
||||
it('should detect VSCode when no other IDE is detected and command includes "code"', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('MONOSPACE_ENV', '');
|
||||
vi.stubEnv('CURSOR_TRACE_ID', '');
|
||||
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.vscode);
|
||||
});
|
||||
|
||||
it('should detect VSCodeFork when no other IDE is detected and command does not include "code"', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('MONOSPACE_ENV', '');
|
||||
vi.stubEnv('CURSOR_TRACE_ID', '');
|
||||
expect(detectIde(ideProcessInfoNoCode)).toBe(IDE_DEFINITIONS.vscodefork);
|
||||
expect(detectIde()).toBe(IDE_DEFINITIONS.firebasestudio);
|
||||
});
|
||||
|
||||
it('should detect AntiGravity', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', 'agy');
|
||||
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.antigravity);
|
||||
expect(detectIde()).toBe(IDE_DEFINITIONS.antigravity);
|
||||
});
|
||||
|
||||
it('should detect Sublime Text', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'sublime');
|
||||
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
|
||||
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.sublimetext);
|
||||
expect(detectIde()).toBe(IDE_DEFINITIONS.sublimetext);
|
||||
});
|
||||
|
||||
it('should prioritize Antigravity over Sublime Text', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'sublime');
|
||||
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', 'agy');
|
||||
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.antigravity);
|
||||
expect(detectIde()).toBe(IDE_DEFINITIONS.antigravity);
|
||||
});
|
||||
|
||||
it('should detect JetBrains IDE via TERMINAL_EMULATOR', () => {
|
||||
vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm');
|
||||
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.jetbrains);
|
||||
});
|
||||
|
||||
describe('JetBrains IDE detection via command', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'IntelliJ IDEA',
|
||||
'/Applications/IntelliJ IDEA.app',
|
||||
IDE_DEFINITIONS.intellijidea,
|
||||
],
|
||||
['WebStorm', '/Applications/WebStorm.app', IDE_DEFINITIONS.webstorm],
|
||||
['PyCharm', '/Applications/PyCharm.app', IDE_DEFINITIONS.pycharm],
|
||||
['GoLand', '/Applications/GoLand.app', IDE_DEFINITIONS.goland],
|
||||
[
|
||||
'Android Studio',
|
||||
'/Applications/Android Studio.app',
|
||||
IDE_DEFINITIONS.androidstudio,
|
||||
],
|
||||
['CLion', '/Applications/CLion.app', IDE_DEFINITIONS.clion],
|
||||
['RustRover', '/Applications/RustRover.app', IDE_DEFINITIONS.rustrover],
|
||||
['DataGrip', '/Applications/DataGrip.app', IDE_DEFINITIONS.datagrip],
|
||||
['PhpStorm', '/Applications/PhpStorm.app', IDE_DEFINITIONS.phpstorm],
|
||||
])('should detect %s via command', (_name, command, expectedIde) => {
|
||||
const processInfo = { pid: 123, command };
|
||||
expect(detectIde(processInfo)).toBe(expectedIde);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return generic JetBrains when command does not match specific IDE', () => {
|
||||
vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm');
|
||||
const genericProcessInfo = {
|
||||
pid: 123,
|
||||
command: '/Applications/SomeJetBrainsApp.app',
|
||||
};
|
||||
expect(detectIde(genericProcessInfo)).toBe(IDE_DEFINITIONS.jetbrains);
|
||||
expect(detectIde()).toBe(IDE_DEFINITIONS.jetbrains);
|
||||
});
|
||||
|
||||
it('should prioritize JetBrains detection over VS Code when TERMINAL_EMULATOR is set', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm');
|
||||
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.jetbrains);
|
||||
expect(detectIde()).toBe(IDE_DEFINITIONS.jetbrains);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectIde with ideInfoFromFile', () => {
|
||||
const ideProcessInfo = { pid: 123, command: 'some/path/to/code' };
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
@@ -205,24 +148,20 @@ describe('detectIde with ideInfoFromFile', () => {
|
||||
name: 'custom-ide',
|
||||
displayName: 'Custom IDE',
|
||||
};
|
||||
expect(detectIde(ideProcessInfo, ideInfoFromFile)).toEqual(ideInfoFromFile);
|
||||
expect(detectIde(ideInfoFromFile)).toEqual(ideInfoFromFile);
|
||||
});
|
||||
|
||||
it('should fall back to env detection if name is missing', () => {
|
||||
const ideInfoFromFile = { displayName: 'Custom IDE' };
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('CURSOR_TRACE_ID', '');
|
||||
expect(detectIde(ideProcessInfo, ideInfoFromFile)).toBe(
|
||||
IDE_DEFINITIONS.vscode,
|
||||
);
|
||||
expect(detectIde(ideInfoFromFile)).toBe(IDE_DEFINITIONS.vscode);
|
||||
});
|
||||
|
||||
it('should fall back to env detection if displayName is missing', () => {
|
||||
const ideInfoFromFile = { name: 'custom-ide' };
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('CURSOR_TRACE_ID', '');
|
||||
expect(detectIde(ideProcessInfo, ideInfoFromFile)).toBe(
|
||||
IDE_DEFINITIONS.vscode,
|
||||
);
|
||||
expect(detectIde(ideInfoFromFile)).toBe(IDE_DEFINITIONS.vscode);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,65 +77,10 @@ export function detectIdeFromEnv(): IdeInfo {
|
||||
return IDE_DEFINITIONS.vscode;
|
||||
}
|
||||
|
||||
function verifyVSCode(
|
||||
ide: IdeInfo,
|
||||
ideProcessInfo: {
|
||||
pid: number;
|
||||
command: string;
|
||||
},
|
||||
): IdeInfo {
|
||||
if (ide.name !== IDE_DEFINITIONS.vscode.name) {
|
||||
return ide;
|
||||
}
|
||||
if (
|
||||
!ideProcessInfo.command ||
|
||||
ideProcessInfo.command.toLowerCase().includes('code')
|
||||
) {
|
||||
return IDE_DEFINITIONS.vscode;
|
||||
}
|
||||
return IDE_DEFINITIONS.vscodefork;
|
||||
}
|
||||
|
||||
function verifyJetBrains(
|
||||
ide: IdeInfo,
|
||||
ideProcessInfo: {
|
||||
pid: number;
|
||||
command: string;
|
||||
},
|
||||
): IdeInfo {
|
||||
if (ide.name !== IDE_DEFINITIONS.jetbrains.name || !ideProcessInfo.command) {
|
||||
return ide;
|
||||
}
|
||||
|
||||
const command = ideProcessInfo.command.toLowerCase();
|
||||
const jetbrainsProducts: Array<[string, IdeInfo]> = [
|
||||
['idea', IDE_DEFINITIONS.intellijidea],
|
||||
['webstorm', IDE_DEFINITIONS.webstorm],
|
||||
['pycharm', IDE_DEFINITIONS.pycharm],
|
||||
['goland', IDE_DEFINITIONS.goland],
|
||||
['studio', IDE_DEFINITIONS.androidstudio],
|
||||
['clion', IDE_DEFINITIONS.clion],
|
||||
['rustrover', IDE_DEFINITIONS.rustrover],
|
||||
['datagrip', IDE_DEFINITIONS.datagrip],
|
||||
['phpstorm', IDE_DEFINITIONS.phpstorm],
|
||||
];
|
||||
|
||||
for (const [product, ideInfo] of jetbrainsProducts) {
|
||||
if (command.includes(product)) {
|
||||
return ideInfo;
|
||||
}
|
||||
}
|
||||
|
||||
return ide;
|
||||
}
|
||||
|
||||
export function detectIde(
|
||||
ideProcessInfo: {
|
||||
pid: number;
|
||||
command: string;
|
||||
},
|
||||
ideInfoFromFile?: { name?: string; displayName?: string },
|
||||
): IdeInfo | undefined {
|
||||
export function detectIde(ideInfoFromFile?: {
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
}): IdeInfo | undefined {
|
||||
if (ideInfoFromFile?.name && ideInfoFromFile.displayName) {
|
||||
return {
|
||||
name: ideInfoFromFile.name,
|
||||
@@ -152,8 +97,5 @@ export function detectIde(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const ide = detectIdeFromEnv();
|
||||
return isJetBrains()
|
||||
? verifyJetBrains(ide, ideProcessInfo)
|
||||
: verifyVSCode(ide, ideProcessInfo);
|
||||
return detectIdeFromEnv();
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from 'vitest';
|
||||
import { IdeClient, IDEConnectionStatus } from './ide-client.js';
|
||||
import * as fs from 'node:fs';
|
||||
import { getIdeProcessInfo } from './process-utils.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
@@ -43,7 +42,6 @@ vi.mock('node:fs', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./process-utils.js');
|
||||
vi.mock('@modelcontextprotocol/sdk/client/index.js');
|
||||
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js');
|
||||
vi.mock('@modelcontextprotocol/sdk/client/stdio.js');
|
||||
@@ -81,10 +79,6 @@ describe('IdeClient', () => {
|
||||
// Mock dependencies
|
||||
vi.spyOn(process, 'cwd').mockReturnValue('/test/workspace/sub-dir');
|
||||
vi.mocked(detectIde).mockReturnValue(IDE_DEFINITIONS.vscode);
|
||||
vi.mocked(getIdeProcessInfo).mockResolvedValue({
|
||||
pid: 12345,
|
||||
command: 'test-ide',
|
||||
});
|
||||
|
||||
// Mock MCP client and transports
|
||||
mockClient = {
|
||||
@@ -118,10 +112,10 @@ describe('IdeClient', () => {
|
||||
|
||||
describe('connect', () => {
|
||||
it('should connect using HTTP when port is provided in config file', async () => {
|
||||
const config = { port: '8080' };
|
||||
const config = { port: '8080', workspacePath: '/test/workspace' };
|
||||
const configPath = path.join(
|
||||
ideConfigDir,
|
||||
'gemini-ide-server-12345.json',
|
||||
'gemini-ide-server-12345-123.json',
|
||||
);
|
||||
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
|
||||
|
||||
@@ -139,10 +133,13 @@ describe('IdeClient', () => {
|
||||
});
|
||||
|
||||
it('should connect using stdio when stdio config is provided in file', async () => {
|
||||
const config = { stdio: { command: 'test-cmd', args: ['--foo'] } };
|
||||
const config = {
|
||||
stdio: { command: 'test-cmd', args: ['--foo'] },
|
||||
workspacePath: '/test/workspace',
|
||||
};
|
||||
const configPath = path.join(
|
||||
ideConfigDir,
|
||||
'gemini-ide-server-12345.json',
|
||||
'gemini-ide-server-12345-123.json',
|
||||
);
|
||||
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
|
||||
|
||||
@@ -163,10 +160,11 @@ describe('IdeClient', () => {
|
||||
const config = {
|
||||
port: '8080',
|
||||
stdio: { command: 'test-cmd', args: ['--foo'] },
|
||||
workspacePath: '/test/workspace',
|
||||
};
|
||||
const configPath = path.join(
|
||||
ideConfigDir,
|
||||
'gemini-ide-server-12345.json',
|
||||
'gemini-ide-server-12345-123.json',
|
||||
);
|
||||
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
|
||||
|
||||
@@ -214,10 +212,10 @@ describe('IdeClient', () => {
|
||||
});
|
||||
|
||||
it('should prioritize file config over environment variables', async () => {
|
||||
const config = { port: '8080' };
|
||||
const config = { port: '8080', workspacePath: '/test/workspace' };
|
||||
const configPath = path.join(
|
||||
ideConfigDir,
|
||||
'gemini-ide-server-12345.json',
|
||||
'gemini-ide-server-12345-123.json',
|
||||
);
|
||||
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
|
||||
|
||||
@@ -255,7 +253,7 @@ describe('IdeClient', () => {
|
||||
const config = { port: '1234', workspacePath: '/test/workspace' };
|
||||
const configPath = path.join(
|
||||
ideConfigDir,
|
||||
'gemini-ide-server-12345.json',
|
||||
'gemini-ide-server-12345-123.json',
|
||||
);
|
||||
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
|
||||
|
||||
@@ -509,10 +507,10 @@ describe('IdeClient', () => {
|
||||
});
|
||||
|
||||
it('should return false if tool discovery fails', async () => {
|
||||
const config = { port: '8080' };
|
||||
const config = { port: '8080', workspacePath: '/test/workspace' };
|
||||
const configPath = path.join(
|
||||
ideConfigDir,
|
||||
'gemini-ide-server-12345.json',
|
||||
'gemini-ide-server-12345-123.json',
|
||||
);
|
||||
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
|
||||
|
||||
@@ -528,10 +526,10 @@ describe('IdeClient', () => {
|
||||
});
|
||||
|
||||
it('should return false if diffing tools are not available', async () => {
|
||||
const config = { port: '8080' };
|
||||
const config = { port: '8080', workspacePath: '/test/workspace' };
|
||||
const configPath = path.join(
|
||||
ideConfigDir,
|
||||
'gemini-ide-server-12345.json',
|
||||
'gemini-ide-server-12345-123.json',
|
||||
);
|
||||
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
|
||||
|
||||
@@ -549,10 +547,10 @@ describe('IdeClient', () => {
|
||||
});
|
||||
|
||||
it('should return false if only openDiff tool is available', async () => {
|
||||
const config = { port: '8080' };
|
||||
const config = { port: '8080', workspacePath: '/test/workspace' };
|
||||
const configPath = path.join(
|
||||
ideConfigDir,
|
||||
'gemini-ide-server-12345.json',
|
||||
'gemini-ide-server-12345-123.json',
|
||||
);
|
||||
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
|
||||
|
||||
@@ -570,10 +568,10 @@ describe('IdeClient', () => {
|
||||
});
|
||||
|
||||
it('should return true if connected and diffing tools are available', async () => {
|
||||
const config = { port: '8080' };
|
||||
const config = { port: '8080', workspacePath: '/test/workspace' };
|
||||
const configPath = path.join(
|
||||
ideConfigDir,
|
||||
'gemini-ide-server-12345.json',
|
||||
'gemini-ide-server-12345-123.json',
|
||||
);
|
||||
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
|
||||
|
||||
@@ -842,10 +840,14 @@ describe('IdeClient', () => {
|
||||
describe('authentication', () => {
|
||||
it('should connect with an auth token if provided in the discovery file', async () => {
|
||||
const authToken = 'test-auth-token';
|
||||
const config = { port: '8080', authToken };
|
||||
const config = {
|
||||
port: '8080',
|
||||
authToken,
|
||||
workspacePath: '/test/workspace',
|
||||
};
|
||||
const configPath = path.join(
|
||||
ideConfigDir,
|
||||
'gemini-ide-server-12345.json',
|
||||
'gemini-ide-server-12345-123.json',
|
||||
);
|
||||
await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf8');
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
IdeDiffClosedNotificationSchema,
|
||||
IdeDiffRejectedNotificationSchema,
|
||||
} from './types.js';
|
||||
import { getIdeProcessInfo } from './process-utils.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
@@ -59,7 +58,7 @@ type StdioConfig = {
|
||||
args: string[];
|
||||
};
|
||||
|
||||
type ConnectionConfig = {
|
||||
export type ConnectionConfig = {
|
||||
port?: string;
|
||||
authToken?: string;
|
||||
stdio?: StdioConfig;
|
||||
@@ -77,7 +76,6 @@ export class IdeClient {
|
||||
'IDE integration is currently disabled. To enable it, run /ide enable.',
|
||||
};
|
||||
private currentIde: IdeInfo | undefined;
|
||||
private ideProcessInfo: { pid: number; command: string } | undefined;
|
||||
private connectionConfig:
|
||||
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
|
||||
| undefined;
|
||||
@@ -99,12 +97,8 @@ export class IdeClient {
|
||||
if (!IdeClient.instancePromise) {
|
||||
IdeClient.instancePromise = (async () => {
|
||||
const client = new IdeClient();
|
||||
client.ideProcessInfo = await getIdeProcessInfo();
|
||||
client.connectionConfig = await client.getConnectionConfigFromFile();
|
||||
client.currentIde = detectIde(
|
||||
client.ideProcessInfo,
|
||||
client.connectionConfig?.ideInfo,
|
||||
);
|
||||
client.currentIde = detectIde(client.connectionConfig?.ideInfo);
|
||||
return client;
|
||||
})();
|
||||
}
|
||||
@@ -127,7 +121,15 @@ export class IdeClient {
|
||||
this.trustChangeListeners.delete(listener);
|
||||
}
|
||||
|
||||
async connect(options: { logToConsole?: boolean } = {}): Promise<void> {
|
||||
async connect(
|
||||
options: {
|
||||
logToConsole?: boolean;
|
||||
connectionConfig?: ConnectionConfig & {
|
||||
workspacePath?: string;
|
||||
ideInfo?: IdeInfo;
|
||||
};
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const logError = options.logToConsole ?? true;
|
||||
if (!this.currentIde) {
|
||||
this.setState(
|
||||
@@ -140,7 +142,12 @@ export class IdeClient {
|
||||
|
||||
this.setState(IDEConnectionStatus.Connecting);
|
||||
|
||||
this.connectionConfig = await this.getConnectionConfigFromFile();
|
||||
if (options.connectionConfig) {
|
||||
this.connectionConfig = options.connectionConfig;
|
||||
} else {
|
||||
this.connectionConfig = await this.getConnectionConfigFromFile();
|
||||
}
|
||||
|
||||
this.authToken =
|
||||
this.connectionConfig?.authToken ??
|
||||
process.env['GEMINI_CLI_IDE_AUTH_TOKEN'];
|
||||
@@ -565,104 +572,81 @@ export class IdeClient {
|
||||
return { command, args };
|
||||
}
|
||||
|
||||
private async getConnectionConfigFromFile(): Promise<
|
||||
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
|
||||
| undefined
|
||||
async discoverAvailableConnections(): Promise<
|
||||
Array<ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }>
|
||||
> {
|
||||
if (!this.ideProcessInfo) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// For backwards compatibility
|
||||
try {
|
||||
const portFile = path.join(
|
||||
os.tmpdir(),
|
||||
'gemini',
|
||||
'ide',
|
||||
`gemini-ide-server-${this.ideProcessInfo.pid}.json`,
|
||||
);
|
||||
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
|
||||
return JSON.parse(portFileContents);
|
||||
} catch (_) {
|
||||
// For newer extension versions, the file name matches the pattern
|
||||
// /^gemini-ide-server-${pid}-\d+\.json$/. If multiple IDE
|
||||
// windows are open, multiple files matching the pattern are expected to
|
||||
// exist.
|
||||
}
|
||||
|
||||
const portFileDir = path.join(os.tmpdir(), 'gemini', 'ide');
|
||||
let portFiles;
|
||||
try {
|
||||
portFiles = await fs.promises.readdir(portFileDir);
|
||||
} catch (e) {
|
||||
logger.debug('Failed to read IDE connection directory:', e);
|
||||
return undefined;
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!portFiles) {
|
||||
return undefined;
|
||||
return [];
|
||||
}
|
||||
|
||||
const fileRegex = new RegExp(
|
||||
`^gemini-ide-server-${this.ideProcessInfo.pid}-\\d+\\.json$`,
|
||||
);
|
||||
const matchingFiles = portFiles
|
||||
.filter((file) => fileRegex.test(file))
|
||||
.sort();
|
||||
if (matchingFiles.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const fileRegex = /^gemini-ide-server-(\d+)-(\d+)\.json$/;
|
||||
const allFiles = portFiles.filter((file) => fileRegex.test(file)).sort();
|
||||
|
||||
let fileContents: string[];
|
||||
try {
|
||||
fileContents = await Promise.all(
|
||||
matchingFiles.map((file) =>
|
||||
allFiles.map((file) =>
|
||||
fs.promises.readFile(path.join(portFileDir, file), 'utf8'),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
logger.debug('Failed to read IDE connection config file(s):', e);
|
||||
return undefined;
|
||||
}
|
||||
const parsedContents = fileContents.map((content) => {
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch (e) {
|
||||
logger.debug('Failed to parse JSON from config file: ', e);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const validWorkspaces = parsedContents.filter((content) => {
|
||||
if (!content) {
|
||||
return false;
|
||||
}
|
||||
const { isValid } = IdeClient.validateWorkspacePath(
|
||||
content.workspacePath,
|
||||
process.cwd(),
|
||||
);
|
||||
return isValid;
|
||||
});
|
||||
|
||||
if (validWorkspaces.length === 0) {
|
||||
return undefined;
|
||||
return [];
|
||||
}
|
||||
|
||||
if (validWorkspaces.length === 1) {
|
||||
return validWorkspaces[0];
|
||||
}
|
||||
const configs = fileContents
|
||||
.map((content) => {
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch (e) {
|
||||
logger.debug('Failed to parse JSON from config file: ', e);
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
.filter((config) => {
|
||||
if (!config) {
|
||||
return false;
|
||||
}
|
||||
// Basic validation
|
||||
const { isValid } = IdeClient.validateWorkspacePath(
|
||||
config.workspacePath,
|
||||
process.cwd(),
|
||||
);
|
||||
return isValid;
|
||||
});
|
||||
|
||||
const portFromEnv = this.getPortFromEnv();
|
||||
if (portFromEnv) {
|
||||
const matchingPort = validWorkspaces.find(
|
||||
(content) => String(content.port) === portFromEnv,
|
||||
);
|
||||
if (matchingPort) {
|
||||
return matchingPort;
|
||||
return configs;
|
||||
}
|
||||
|
||||
private async getConnectionConfigFromFile(): Promise<
|
||||
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
|
||||
| undefined
|
||||
> {
|
||||
const available = await this.discoverAvailableConnections();
|
||||
const portEnv = process.env['GEMINI_CLI_IDE_SERVER_PORT'];
|
||||
if (available.length > 1 && portEnv) {
|
||||
const match = available.find((c) => c.port?.toString() === portEnv);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
return validWorkspaces[0];
|
||||
if (available.length > 0) {
|
||||
// Return the first available connection if specific port match isn't found
|
||||
// This maintains backward compatibility with tests expecting a return value.
|
||||
return available[0];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async createProxyAwareFetch(ideServerHost: string) {
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
afterEach,
|
||||
beforeEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { getIdeProcessInfo } from './process-utils.js';
|
||||
import os from 'node:os';
|
||||
|
||||
const mockedExec = vi.hoisted(() => vi.fn());
|
||||
vi.mock('node:util', () => ({
|
||||
promisify: vi.fn().mockReturnValue(mockedExec),
|
||||
}));
|
||||
vi.mock('node:os', () => ({
|
||||
default: {
|
||||
platform: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('getIdeProcessInfo', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'pid', { value: 1000, configurable: true });
|
||||
mockedExec.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('on Unix', () => {
|
||||
it('should traverse up to find the shell and return grandparent process info', async () => {
|
||||
(os.platform as Mock).mockReturnValue('linux');
|
||||
// process (1000) -> shell (800) -> IDE (700)
|
||||
mockedExec
|
||||
.mockResolvedValueOnce({ stdout: '800 /bin/bash' }) // pid 1000 -> ppid 800 (shell)
|
||||
.mockResolvedValueOnce({ stdout: '700 /usr/lib/vscode/code' }) // pid 800 -> ppid 700 (IDE)
|
||||
.mockResolvedValueOnce({ stdout: '700 /usr/lib/vscode/code' }); // get command for pid 700
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
|
||||
expect(result).toEqual({ pid: 700, command: '/usr/lib/vscode/code' });
|
||||
});
|
||||
|
||||
it('should return parent process info if grandparent lookup fails', async () => {
|
||||
(os.platform as Mock).mockReturnValue('linux');
|
||||
mockedExec
|
||||
.mockResolvedValueOnce({ stdout: '800 /bin/bash' }) // pid 1000 -> ppid 800 (shell)
|
||||
.mockRejectedValueOnce(new Error('ps failed')) // lookup for ppid of 800 fails
|
||||
.mockResolvedValueOnce({ stdout: '800 /bin/bash' }); // get command for pid 800
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
expect(result).toEqual({ pid: 800, command: '/bin/bash' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('on Windows', () => {
|
||||
it('should traverse up and find the great-grandchild of the root process', async () => {
|
||||
(os.platform as Mock).mockReturnValue('win32');
|
||||
// process (1000) -> powershell (900) -> code (800) -> wininit (700) -> root (0)
|
||||
// Ancestors: [1000, 900, 800, 700]
|
||||
// Target (great-grandchild of root): 900
|
||||
const processes = [
|
||||
{
|
||||
ProcessId: 1000,
|
||||
ParentProcessId: 900,
|
||||
Name: 'node.exe',
|
||||
CommandLine: 'node.exe',
|
||||
},
|
||||
{
|
||||
ProcessId: 900,
|
||||
ParentProcessId: 800,
|
||||
Name: 'powershell.exe',
|
||||
CommandLine: 'powershell.exe',
|
||||
},
|
||||
{
|
||||
ProcessId: 800,
|
||||
ParentProcessId: 700,
|
||||
Name: 'code.exe',
|
||||
CommandLine: 'code.exe',
|
||||
},
|
||||
{
|
||||
ProcessId: 700,
|
||||
ParentProcessId: 0,
|
||||
Name: 'wininit.exe',
|
||||
CommandLine: 'wininit.exe',
|
||||
},
|
||||
];
|
||||
mockedExec.mockResolvedValueOnce({ stdout: JSON.stringify(processes) });
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
expect(result).toEqual({ pid: 900, command: 'powershell.exe' });
|
||||
expect(mockedExec).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Get-CimInstance Win32_Process'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle short process chains', async () => {
|
||||
(os.platform as Mock).mockReturnValue('win32');
|
||||
// process (1000) -> root (0)
|
||||
const processes = [
|
||||
{
|
||||
ProcessId: 1000,
|
||||
ParentProcessId: 0,
|
||||
Name: 'node.exe',
|
||||
CommandLine: 'node.exe',
|
||||
},
|
||||
];
|
||||
mockedExec.mockResolvedValueOnce({ stdout: JSON.stringify(processes) });
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
expect(result).toEqual({ pid: 1000, command: 'node.exe' });
|
||||
});
|
||||
|
||||
it('should handle PowerShell failure gracefully', async () => {
|
||||
(os.platform as Mock).mockReturnValue('win32');
|
||||
mockedExec.mockRejectedValueOnce(new Error('PowerShell failed'));
|
||||
// Fallback to getProcessInfo for current PID
|
||||
mockedExec.mockResolvedValueOnce({ stdout: '' }); // ps command fails on windows
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
expect(result).toEqual({ pid: 1000, command: '' });
|
||||
});
|
||||
|
||||
it('should handle malformed JSON output gracefully', async () => {
|
||||
(os.platform as Mock).mockReturnValue('win32');
|
||||
mockedExec.mockResolvedValueOnce({ stdout: '{"invalid":json}' });
|
||||
// Fallback to getProcessInfo for current PID
|
||||
mockedExec.mockResolvedValueOnce({ stdout: '' });
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
expect(result).toEqual({ pid: 1000, command: '' });
|
||||
});
|
||||
|
||||
it('should handle single process output from ConvertTo-Json', async () => {
|
||||
(os.platform as Mock).mockReturnValue('win32');
|
||||
const process = {
|
||||
ProcessId: 1000,
|
||||
ParentProcessId: 0,
|
||||
Name: 'node.exe',
|
||||
CommandLine: 'node.exe',
|
||||
};
|
||||
mockedExec.mockResolvedValueOnce({ stdout: JSON.stringify(process) });
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
expect(result).toEqual({ pid: 1000, command: 'node.exe' });
|
||||
});
|
||||
|
||||
it('should handle missing process in map during traversal', async () => {
|
||||
(os.platform as Mock).mockReturnValue('win32');
|
||||
// process (1000) -> parent (900) -> missing (800)
|
||||
const processes = [
|
||||
{
|
||||
ProcessId: 1000,
|
||||
ParentProcessId: 900,
|
||||
Name: 'node.exe',
|
||||
CommandLine: 'node.exe',
|
||||
},
|
||||
{
|
||||
ProcessId: 900,
|
||||
ParentProcessId: 800,
|
||||
Name: 'parent.exe',
|
||||
CommandLine: 'parent.exe',
|
||||
},
|
||||
];
|
||||
mockedExec.mockResolvedValueOnce({ stdout: JSON.stringify(processes) });
|
||||
|
||||
const result = await getIdeProcessInfo();
|
||||
// Ancestors: [1000, 900]. Length < 3, returns last (900)
|
||||
expect(result).toEqual({ pid: 900, command: 'parent.exe' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,226 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const MAX_TRAVERSAL_DEPTH = 32;
|
||||
|
||||
interface ProcessInfo {
|
||||
pid: number;
|
||||
parentPid: number;
|
||||
name: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
interface RawProcessInfo {
|
||||
ProcessId?: number;
|
||||
ParentProcessId?: number;
|
||||
Name?: string;
|
||||
CommandLine?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the entire process table on Windows.
|
||||
*/
|
||||
async function getProcessTableWindows(): Promise<Map<number, ProcessInfo>> {
|
||||
const processMap = new Map<number, ProcessInfo>();
|
||||
try {
|
||||
// Fetch ProcessId, ParentProcessId, Name, and CommandLine for all processes.
|
||||
const powershellCommand =
|
||||
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress';
|
||||
// Increase maxBuffer to handle large process lists (default is 1MB)
|
||||
const { stdout } = await execAsync(`powershell "${powershellCommand}"`, {
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
if (!stdout.trim()) {
|
||||
return processMap;
|
||||
}
|
||||
|
||||
let processes: RawProcessInfo | RawProcessInfo[];
|
||||
try {
|
||||
processes = JSON.parse(stdout);
|
||||
} catch (_e) {
|
||||
return processMap;
|
||||
}
|
||||
|
||||
if (!Array.isArray(processes)) {
|
||||
processes = [processes];
|
||||
}
|
||||
|
||||
for (const p of processes) {
|
||||
if (p && typeof p.ProcessId === 'number') {
|
||||
processMap.set(p.ProcessId, {
|
||||
pid: p.ProcessId,
|
||||
parentPid: p.ParentProcessId || 0,
|
||||
name: p.Name || '',
|
||||
command: p.CommandLine || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// Fallback or error handling if PowerShell fails
|
||||
}
|
||||
return processMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the parent process ID, name, and command for a given process ID on Unix.
|
||||
*
|
||||
* @param pid The process ID to inspect.
|
||||
* @returns A promise that resolves to the parent's PID, name, and command.
|
||||
*/
|
||||
async function getProcessInfo(pid: number): Promise<{
|
||||
parentPid: number;
|
||||
name: string;
|
||||
command: string;
|
||||
}> {
|
||||
try {
|
||||
const command = `ps -o ppid=,command= -p ${pid}`;
|
||||
const { stdout } = await execAsync(command);
|
||||
const trimmedStdout = stdout.trim();
|
||||
if (!trimmedStdout) {
|
||||
return { parentPid: 0, name: '', command: '' };
|
||||
}
|
||||
const parts = trimmedStdout.split(/\s+/);
|
||||
const ppidString = parts[0];
|
||||
const parentPid = parseInt(ppidString, 10);
|
||||
const fullCommand = trimmedStdout.substring(ppidString.length).trim();
|
||||
const processName = path.basename(fullCommand.split(' ')[0]);
|
||||
|
||||
return {
|
||||
parentPid: isNaN(parentPid) ? 1 : parentPid,
|
||||
name: processName,
|
||||
command: fullCommand,
|
||||
};
|
||||
} catch (_e) {
|
||||
return { parentPid: 0, name: '', command: '' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the IDE process info on Unix-like systems.
|
||||
*
|
||||
* The strategy is to find the shell process that spawned the CLI, and then
|
||||
* find that shell's parent process (the IDE). To get the true IDE process,
|
||||
* we traverse one level higher to get the grandparent.
|
||||
*
|
||||
* @returns A promise that resolves to the PID and command of the IDE process.
|
||||
*/
|
||||
async function getIdeProcessInfoForUnix(): Promise<{
|
||||
pid: number;
|
||||
command: string;
|
||||
}> {
|
||||
const shells = ['zsh', 'bash', 'sh', 'tcsh', 'csh', 'ksh', 'fish', 'dash'];
|
||||
let currentPid = process.pid;
|
||||
|
||||
for (let i = 0; i < MAX_TRAVERSAL_DEPTH; i++) {
|
||||
try {
|
||||
const { parentPid, name } = await getProcessInfo(currentPid);
|
||||
|
||||
const isShell = shells.some((shell) => name === shell);
|
||||
if (isShell) {
|
||||
// The direct parent of the shell is often a utility process (e.g. VS
|
||||
// Code's `ptyhost` process). To get the true IDE process, we need to
|
||||
// traverse one level higher to get the grandparent.
|
||||
let idePid = parentPid;
|
||||
try {
|
||||
const { parentPid: grandParentPid } = await getProcessInfo(parentPid);
|
||||
if (grandParentPid > 1) {
|
||||
idePid = grandParentPid;
|
||||
}
|
||||
} catch {
|
||||
// Ignore if getting grandparent fails, we'll just use the parent pid.
|
||||
}
|
||||
const { command } = await getProcessInfo(idePid);
|
||||
return { pid: idePid, command };
|
||||
}
|
||||
|
||||
if (parentPid <= 1) {
|
||||
break; // Reached the root
|
||||
}
|
||||
currentPid = parentPid;
|
||||
} catch {
|
||||
// Process in chain died
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const { command } = await getProcessInfo(currentPid);
|
||||
return { pid: currentPid, command };
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the IDE process info on Windows using a snapshot approach.
|
||||
*/
|
||||
async function getIdeProcessInfoForWindows(): Promise<{
|
||||
pid: number;
|
||||
command: string;
|
||||
}> {
|
||||
// Fetch the entire process table in one go.
|
||||
const processMap = await getProcessTableWindows();
|
||||
const myPid = process.pid;
|
||||
const myProc = processMap.get(myPid);
|
||||
|
||||
if (!myProc) {
|
||||
// Fallback: try to get info for current process directly if snapshot fails
|
||||
const { command } = await getProcessInfo(myPid);
|
||||
return { pid: myPid, command };
|
||||
}
|
||||
|
||||
// Perform tree traversal in memory.
|
||||
// Strategy: Find the great-grandchild of the root process (pid 0 or non-existent parent).
|
||||
const ancestors: ProcessInfo[] = [];
|
||||
let curr: ProcessInfo | undefined = myProc;
|
||||
|
||||
for (let i = 0; i < MAX_TRAVERSAL_DEPTH && curr; i++) {
|
||||
ancestors.push(curr);
|
||||
if (curr.parentPid === 0 || !processMap.has(curr.parentPid)) {
|
||||
break; // Reached root
|
||||
}
|
||||
curr = processMap.get(curr.parentPid);
|
||||
}
|
||||
|
||||
if (ancestors.length >= 3) {
|
||||
const target = ancestors[ancestors.length - 3];
|
||||
return { pid: target.pid, command: target.command };
|
||||
} else if (ancestors.length > 0) {
|
||||
const target = ancestors[ancestors.length - 1];
|
||||
return { pid: target.pid, command: target.command };
|
||||
}
|
||||
|
||||
return { pid: myPid, command: myProc.command };
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverses up the process tree to find the process ID and command of the IDE.
|
||||
*
|
||||
* This function uses different strategies depending on the operating system
|
||||
* to identify the main application process (e.g., the main VS Code window
|
||||
* process).
|
||||
*
|
||||
* If the IDE process cannot be reliably identified, it will return the
|
||||
* top-level ancestor process ID and command as a fallback.
|
||||
*
|
||||
* @returns A promise that resolves to the PID and command of the IDE process.
|
||||
*/
|
||||
export async function getIdeProcessInfo(): Promise<{
|
||||
pid: number;
|
||||
command: string;
|
||||
}> {
|
||||
const platform = os.platform();
|
||||
|
||||
if (platform === 'win32') {
|
||||
return getIdeProcessInfoForWindows();
|
||||
}
|
||||
|
||||
return getIdeProcessInfoForUnix();
|
||||
}
|
||||
Reference in New Issue
Block a user