mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
refactor(ide): Improve IDE detection discovery (#6765)
This commit is contained in:
@@ -44,7 +44,10 @@ You can also install the extension directly from a marketplace.
|
|||||||
- **For Visual Studio Code:** Install from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=google.gemini-cli-vscode-ide-companion).
|
- **For Visual Studio Code:** Install from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=google.gemini-cli-vscode-ide-companion).
|
||||||
- **For VS Code Forks:** To support forks of VS Code, the extension is also published on the [Open VSX Registry](https://open-vsx.org/extension/google/gemini-cli-vscode-ide-companion). Follow your editor's instructions for installing extensions from this registry.
|
- **For VS Code Forks:** To support forks of VS Code, the extension is also published on the [Open VSX Registry](https://open-vsx.org/extension/google/gemini-cli-vscode-ide-companion). Follow your editor's instructions for installing extensions from this registry.
|
||||||
|
|
||||||
After any installation method, it's recommended to open a new terminal window to ensure the integration is activated correctly. Once installed, you can use `/ide enable` to connect.
|
> NOTE:
|
||||||
|
> The "Gemini CLI Companion" extension may appear towards the bottom of search results. If you don't see it immediately, try scrolling down or sorting by "Newly Published".
|
||||||
|
>
|
||||||
|
> After manually installing the extension, you must run `/ide enable` in the CLI to activate the integration.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -110,7 +113,7 @@ If you encounter issues with IDE integration, here are some common error message
|
|||||||
|
|
||||||
### Connection Errors
|
### Connection Errors
|
||||||
|
|
||||||
- **Message:** `🔴 Disconnected: Failed to connect to IDE companion extension for [IDE Name]. Please ensure the extension is running and try restarting your terminal. To install the extension, run /ide install.`
|
- **Message:** `🔴 Disconnected: Failed to connect to IDE companion extension in [IDE Name]. Please ensure the extension is running. To install the extension, run /ide install.`
|
||||||
- **Cause:** Gemini CLI could not find the necessary environment variables (`GEMINI_CLI_IDE_WORKSPACE_PATH` or `GEMINI_CLI_IDE_SERVER_PORT`) to connect to the IDE. This usually means the IDE companion extension is not running or did not initialize correctly.
|
- **Cause:** Gemini CLI could not find the necessary environment variables (`GEMINI_CLI_IDE_WORKSPACE_PATH` or `GEMINI_CLI_IDE_SERVER_PORT`) to connect to the IDE. This usually means the IDE companion extension is not running or did not initialize correctly.
|
||||||
- **Solution:**
|
- **Solution:**
|
||||||
1. Make sure you have installed the **Gemini CLI Companion** extension in your IDE and that it is enabled.
|
1. Make sure you have installed the **Gemini CLI Companion** extension in your IDE and that it is enabled.
|
||||||
@@ -122,13 +125,13 @@ If you encounter issues with IDE integration, here are some common error message
|
|||||||
|
|
||||||
### Configuration Errors
|
### Configuration Errors
|
||||||
|
|
||||||
- **Message:** `🔴 Disconnected: Directory mismatch. Gemini CLI is running in a different location than the open workspace in [IDE Name]. Please run the CLI from the same directory as your project's root folder.`
|
- **Message:** `🔴 Disconnected: Directory mismatch. Gemini CLI is running in a different location than the open workspace in [IDE Name]. Please run the CLI from one of the following directories: [List of directories]`
|
||||||
- **Cause:** The CLI's current working directory is outside the folder or workspace you have open in your IDE.
|
- **Cause:** The CLI's current working directory is outside the workspace you have open in your IDE.
|
||||||
- **Solution:** `cd` into the same directory that is open in your IDE and restart the CLI.
|
- **Solution:** `cd` into the same directory that is open in your IDE and restart the CLI.
|
||||||
|
|
||||||
- **Message:** `🔴 Disconnected: To use this feature, please open a single workspace folder in [IDE Name] and try again.`
|
- **Message:** `🔴 Disconnected: To use this feature, please open a workspace folder in [IDE Name] and try again.`
|
||||||
- **Cause:** You have multiple workspace folders open in your IDE, or no folder is open at all. The IDE integration requires a single root workspace folder to operate correctly.
|
- **Cause:** You have no workspace open in your IDE.
|
||||||
- **Solution:** Open a single project folder in your IDE and restart the CLI.
|
- **Solution:** Open a workspace in your IDE and restart the CLI.
|
||||||
|
|
||||||
### General Errors
|
### General Errors
|
||||||
|
|
||||||
@@ -136,6 +139,6 @@ If you encounter issues with IDE integration, here are some common error message
|
|||||||
- **Cause:** You are running Gemini CLI in a terminal or environment that is not a supported IDE.
|
- **Cause:** You are running Gemini CLI in a terminal or environment that is not a supported IDE.
|
||||||
- **Solution:** Run Gemini CLI from the integrated terminal of a supported IDE, like VS Code.
|
- **Solution:** Run Gemini CLI from the integrated terminal of a supported IDE, like VS Code.
|
||||||
|
|
||||||
- **Message:** `No installer is available for [IDE Name]. Please install the IDE companion manually from its marketplace.`
|
- **Message:** `No installer is available for IDE. Please install the Gemini CLI Companion extension manually from the marketplace.`
|
||||||
- **Cause:** You ran `/ide install`, but the CLI does not have an automated installer for your specific IDE.
|
- **Cause:** You ran `/ide install`, but the CLI does not have an automated installer for your specific IDE.
|
||||||
- **Solution:** Open your IDE's extension marketplace, search for "Gemini CLI Companion", and install it manually.
|
- **Solution:** Open your IDE's extension marketplace, search for "Gemini CLI Companion", and [install it manually](#3-manual-installation-from-a-marketplace).
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe.skip('IdeClient', () => {
|
|||||||
process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'] = process.cwd();
|
process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'] = process.cwd();
|
||||||
process.env['TERM_PROGRAM'] = 'vscode';
|
process.env['TERM_PROGRAM'] = 'vscode';
|
||||||
|
|
||||||
const ideClient = IdeClient.getInstance();
|
const ideClient = await IdeClient.getInstance();
|
||||||
await ideClient.connect();
|
await ideClient.connect();
|
||||||
|
|
||||||
expect(ideClient.getConnectionStatus()).toEqual({
|
expect(ideClient.getConnectionStatus()).toEqual({
|
||||||
@@ -67,7 +67,8 @@ describe('IdeClient fallback connection logic', () => {
|
|||||||
process.env['TERM_PROGRAM'] = 'vscode';
|
process.env['TERM_PROGRAM'] = 'vscode';
|
||||||
process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'] = process.cwd();
|
process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'] = process.cwd();
|
||||||
// Reset instance
|
// Reset instance
|
||||||
IdeClient.instance = undefined;
|
(IdeClient as unknown as { instance: IdeClient | undefined }).instance =
|
||||||
|
undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -85,7 +86,7 @@ describe('IdeClient fallback connection logic', () => {
|
|||||||
fs.unlinkSync(portFile);
|
fs.unlinkSync(portFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ideClient = IdeClient.getInstance();
|
const ideClient = await IdeClient.getInstance();
|
||||||
await ideClient.connect();
|
await ideClient.connect();
|
||||||
|
|
||||||
expect(ideClient.getConnectionStatus()).toEqual({
|
expect(ideClient.getConnectionStatus()).toEqual({
|
||||||
@@ -99,7 +100,7 @@ describe('IdeClient fallback connection logic', () => {
|
|||||||
// Write port file with a port that is not listening
|
// Write port file with a port that is not listening
|
||||||
fs.writeFileSync(portFile, JSON.stringify({ port: filePort }));
|
fs.writeFileSync(portFile, JSON.stringify({ port: filePort }));
|
||||||
|
|
||||||
const ideClient = IdeClient.getInstance();
|
const ideClient = await IdeClient.getInstance();
|
||||||
await ideClient.connect();
|
await ideClient.connect();
|
||||||
|
|
||||||
expect(ideClient.getConnectionStatus()).toEqual({
|
expect(ideClient.getConnectionStatus()).toEqual({
|
||||||
@@ -110,7 +111,7 @@ describe('IdeClient fallback connection logic', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe.skip('getIdeProcessId', () => {
|
describe.skip('getIdeProcessId', () => {
|
||||||
let child: ChildProcess;
|
let child: child_process.ChildProcess;
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
if (child) {
|
if (child) {
|
||||||
@@ -173,11 +174,12 @@ describe('IdeClient with proxy', () => {
|
|||||||
vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', process.cwd());
|
vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', process.cwd());
|
||||||
|
|
||||||
// Reset instance
|
// Reset instance
|
||||||
IdeClient.instance = undefined;
|
(IdeClient as unknown as { instance: IdeClient | undefined }).instance =
|
||||||
|
undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
IdeClient.getInstance().disconnect();
|
(await IdeClient.getInstance()).disconnect();
|
||||||
await mcpServer.stop();
|
await mcpServer.stop();
|
||||||
proxyServer.close();
|
proxyServer.close();
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
@@ -188,7 +190,7 @@ describe('IdeClient with proxy', () => {
|
|||||||
vi.stubEnv('HTTPS_PROXY', `http://localhost:${proxyServerPort}`);
|
vi.stubEnv('HTTPS_PROXY', `http://localhost:${proxyServerPort}`);
|
||||||
vi.stubEnv('NO_PROXY', 'example.com,127.0.0.1,::1');
|
vi.stubEnv('NO_PROXY', 'example.com,127.0.0.1,::1');
|
||||||
|
|
||||||
const ideClient = IdeClient.getInstance();
|
const ideClient = await IdeClient.getInstance();
|
||||||
await ideClient.connect();
|
await ideClient.connect();
|
||||||
|
|
||||||
expect(ideClient.getConnectionStatus()).toEqual({
|
expect(ideClient.getConnectionStatus()).toEqual({
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export function IdeIntegrationNudge({
|
|||||||
<Box marginBottom={1} flexDirection="column">
|
<Box marginBottom={1} flexDirection="column">
|
||||||
<Text>
|
<Text>
|
||||||
<Text color="yellow">{'> '}</Text>
|
<Text color="yellow">{'> '}</Text>
|
||||||
{`Do you want to connect ${ideName ?? 'your'} editor to Gemini CLI?`}
|
{`Do you want to connect ${ideName ?? 'your editor'} to Gemini CLI?`}
|
||||||
</Text>
|
</Text>
|
||||||
<Text dimColor>{installText}</Text>
|
<Text dimColor>{installText}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -6,10 +6,8 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Config,
|
Config,
|
||||||
DetectedIde,
|
|
||||||
GEMINI_CLI_COMPANION_EXTENSION_NAME,
|
GEMINI_CLI_COMPANION_EXTENSION_NAME,
|
||||||
IDEConnectionStatus,
|
IDEConnectionStatus,
|
||||||
getIdeInfo,
|
|
||||||
getIdeInstaller,
|
getIdeInstaller,
|
||||||
IdeClient,
|
IdeClient,
|
||||||
type File,
|
type File,
|
||||||
@@ -130,11 +128,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
|
|||||||
({
|
({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
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: ${Object.values(
|
content: `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: VS Code or VS Code forks.`,
|
||||||
DetectedIde,
|
|
||||||
)
|
|
||||||
.map((ide) => getIdeInfo(ide).displayName)
|
|
||||||
.join(', ')}`,
|
|
||||||
}) as const,
|
}) as const,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ import { McpPromptLoader } from '../../services/McpPromptLoader.js';
|
|||||||
import {
|
import {
|
||||||
SlashCommandStatus,
|
SlashCommandStatus,
|
||||||
makeFakeConfig,
|
makeFakeConfig,
|
||||||
|
type IdeClient,
|
||||||
} from '@google/gemini-cli-core/index.js';
|
} from '@google/gemini-cli-core/index.js';
|
||||||
|
|
||||||
function createTestCommand(
|
function createTestCommand(
|
||||||
@@ -109,6 +110,10 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
const mockSetQuittingMessages = vi.fn();
|
const mockSetQuittingMessages = vi.fn();
|
||||||
|
|
||||||
const mockConfig = makeFakeConfig({});
|
const mockConfig = makeFakeConfig({});
|
||||||
|
vi.spyOn(mockConfig, 'getIdeClient').mockReturnValue({
|
||||||
|
addStatusChangeListener: vi.fn(),
|
||||||
|
removeStatusChangeListener: vi.fn(),
|
||||||
|
} as unknown as IdeClient);
|
||||||
|
|
||||||
const mockSettings = {} as LoadedSettings;
|
const mockSettings = {} as LoadedSettings;
|
||||||
|
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ export class Config {
|
|||||||
private readonly folderTrustFeature: boolean;
|
private readonly folderTrustFeature: boolean;
|
||||||
private readonly folderTrust: boolean;
|
private readonly folderTrust: boolean;
|
||||||
private ideMode: boolean;
|
private ideMode: boolean;
|
||||||
private ideClient: IdeClient;
|
private ideClient!: IdeClient;
|
||||||
private inFallbackMode = false;
|
private inFallbackMode = false;
|
||||||
private readonly maxSessionTurns: number;
|
private readonly maxSessionTurns: number;
|
||||||
private readonly listExtensions: boolean;
|
private readonly listExtensions: boolean;
|
||||||
@@ -342,7 +342,6 @@ export class Config {
|
|||||||
this.folderTrustFeature = params.folderTrustFeature ?? false;
|
this.folderTrustFeature = params.folderTrustFeature ?? false;
|
||||||
this.folderTrust = params.folderTrust ?? false;
|
this.folderTrust = params.folderTrust ?? false;
|
||||||
this.ideMode = params.ideMode ?? false;
|
this.ideMode = params.ideMode ?? false;
|
||||||
this.ideClient = IdeClient.getInstance();
|
|
||||||
this.loadMemoryFromIncludeDirectories =
|
this.loadMemoryFromIncludeDirectories =
|
||||||
params.loadMemoryFromIncludeDirectories ?? false;
|
params.loadMemoryFromIncludeDirectories ?? false;
|
||||||
this.chatCompression = params.chatCompression;
|
this.chatCompression = params.chatCompression;
|
||||||
@@ -373,6 +372,7 @@ export class Config {
|
|||||||
throw Error('Config was already initialized');
|
throw Error('Config was already initialized');
|
||||||
}
|
}
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
|
this.ideClient = await IdeClient.getInstance();
|
||||||
// Initialize centralized FileDiscoveryService
|
// Initialize centralized FileDiscoveryService
|
||||||
this.getFileService();
|
this.getFileService();
|
||||||
if (this.getCheckpointingEnabled()) {
|
if (this.getCheckpointingEnabled()) {
|
||||||
|
|||||||
@@ -4,65 +4,133 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { detectIde, DetectedIde } from './detect-ide.js';
|
import { detectIde, DetectedIde, getIdeInfo } from './detect-ide.js';
|
||||||
|
|
||||||
describe('detectIde', () => {
|
describe('detectIde', () => {
|
||||||
|
const ideProcessInfo = { pid: 123, command: 'some/path/to/code' };
|
||||||
|
const ideProcessInfoNoCode = { pid: 123, command: 'some/path/to/fork' };
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it('should return undefined if TERM_PROGRAM is not vscode', () => {
|
||||||
{
|
vi.stubEnv('TERM_PROGRAM', '');
|
||||||
env: {},
|
expect(detectIde(ideProcessInfo)).toBeUndefined();
|
||||||
expected: DetectedIde.VSCode,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
env: { __COG_BASHRC_SOURCED: '1' },
|
|
||||||
expected: DetectedIde.Devin,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
env: { REPLIT_USER: 'test' },
|
|
||||||
expected: DetectedIde.Replit,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
env: { CURSOR_TRACE_ID: 'test' },
|
|
||||||
expected: DetectedIde.Cursor,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
env: { CODESPACES: 'true' },
|
|
||||||
expected: DetectedIde.Codespaces,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
env: { EDITOR_IN_CLOUD_SHELL: 'true' },
|
|
||||||
expected: DetectedIde.CloudShell,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
env: { CLOUD_SHELL: 'true' },
|
|
||||||
expected: DetectedIde.CloudShell,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
env: { TERM_PRODUCT: 'Trae' },
|
|
||||||
expected: DetectedIde.Trae,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
env: { FIREBASE_DEPLOY_AGENT: 'true' },
|
|
||||||
expected: DetectedIde.FirebaseStudio,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
env: { MONOSPACE_ENV: 'true' },
|
|
||||||
expected: DetectedIde.FirebaseStudio,
|
|
||||||
},
|
|
||||||
])('detects the IDE for $expected', ({ env, expected }) => {
|
|
||||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
|
||||||
for (const [key, value] of Object.entries(env)) {
|
|
||||||
vi.stubEnv(key, value);
|
|
||||||
}
|
|
||||||
expect(detectIde()).toBe(expected);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined for non-vscode', () => {
|
it('should detect Devin', () => {
|
||||||
vi.stubEnv('TERM_PROGRAM', 'definitely-not-vscode');
|
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||||
expect(detectIde()).toBeUndefined();
|
vi.stubEnv('__COG_BASHRC_SOURCED', '1');
|
||||||
|
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.Devin);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect Replit', () => {
|
||||||
|
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||||
|
vi.stubEnv('REPLIT_USER', 'testuser');
|
||||||
|
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.Replit);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect Cursor', () => {
|
||||||
|
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||||
|
vi.stubEnv('CURSOR_TRACE_ID', 'some-id');
|
||||||
|
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.Cursor);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect Codespaces', () => {
|
||||||
|
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||||
|
vi.stubEnv('CODESPACES', 'true');
|
||||||
|
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.Codespaces);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect Cloud Shell via EDITOR_IN_CLOUD_SHELL', () => {
|
||||||
|
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||||
|
vi.stubEnv('EDITOR_IN_CLOUD_SHELL', 'true');
|
||||||
|
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.CloudShell);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect Cloud Shell via CLOUD_SHELL', () => {
|
||||||
|
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||||
|
vi.stubEnv('CLOUD_SHELL', 'true');
|
||||||
|
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.CloudShell);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect Trae', () => {
|
||||||
|
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||||
|
vi.stubEnv('TERM_PRODUCT', 'Trae');
|
||||||
|
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.Trae);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect Firebase Studio via FIREBASE_DEPLOY_AGENT', () => {
|
||||||
|
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||||
|
vi.stubEnv('FIREBASE_DEPLOY_AGENT', 'true');
|
||||||
|
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.FirebaseStudio);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect Firebase Studio via MONOSPACE_ENV', () => {
|
||||||
|
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||||
|
vi.stubEnv('MONOSPACE_ENV', 'true');
|
||||||
|
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.FirebaseStudio);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect VSCode when no other IDE is detected and command includes "code"', () => {
|
||||||
|
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||||
|
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.VSCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect VSCodeFork when no other IDE is detected and command does not include "code"', () => {
|
||||||
|
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||||
|
expect(detectIde(ideProcessInfoNoCode)).toBe(DetectedIde.VSCodeFork);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prioritize other IDEs over VSCode detection', () => {
|
||||||
|
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||||
|
vi.stubEnv('REPLIT_USER', 'testuser');
|
||||||
|
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.Replit);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getIdeInfo', () => {
|
||||||
|
it('should return correct info for Devin', () => {
|
||||||
|
expect(getIdeInfo(DetectedIde.Devin)).toEqual({ displayName: 'Devin' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct info for Replit', () => {
|
||||||
|
expect(getIdeInfo(DetectedIde.Replit)).toEqual({ displayName: 'Replit' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct info for Cursor', () => {
|
||||||
|
expect(getIdeInfo(DetectedIde.Cursor)).toEqual({ displayName: 'Cursor' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct info for CloudShell', () => {
|
||||||
|
expect(getIdeInfo(DetectedIde.CloudShell)).toEqual({
|
||||||
|
displayName: 'Cloud Shell',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct info for Codespaces', () => {
|
||||||
|
expect(getIdeInfo(DetectedIde.Codespaces)).toEqual({
|
||||||
|
displayName: 'GitHub Codespaces',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct info for FirebaseStudio', () => {
|
||||||
|
expect(getIdeInfo(DetectedIde.FirebaseStudio)).toEqual({
|
||||||
|
displayName: 'Firebase Studio',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct info for Trae', () => {
|
||||||
|
expect(getIdeInfo(DetectedIde.Trae)).toEqual({ displayName: 'Trae' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct info for VSCode', () => {
|
||||||
|
expect(getIdeInfo(DetectedIde.VSCode)).toEqual({ displayName: 'VS Code' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct info for VSCodeFork', () => {
|
||||||
|
expect(getIdeInfo(DetectedIde.VSCodeFork)).toEqual({ displayName: 'IDE' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,12 +7,13 @@
|
|||||||
export enum DetectedIde {
|
export enum DetectedIde {
|
||||||
Devin = 'devin',
|
Devin = 'devin',
|
||||||
Replit = 'replit',
|
Replit = 'replit',
|
||||||
VSCode = 'vscode',
|
|
||||||
Cursor = 'cursor',
|
Cursor = 'cursor',
|
||||||
CloudShell = 'cloudshell',
|
CloudShell = 'cloudshell',
|
||||||
Codespaces = 'codespaces',
|
Codespaces = 'codespaces',
|
||||||
FirebaseStudio = 'firebasestudio',
|
FirebaseStudio = 'firebasestudio',
|
||||||
Trae = 'trae',
|
Trae = 'trae',
|
||||||
|
VSCode = 'vscode',
|
||||||
|
VSCodeFork = 'vscodefork',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IdeInfo {
|
export interface IdeInfo {
|
||||||
@@ -29,10 +30,6 @@ export function getIdeInfo(ide: DetectedIde): IdeInfo {
|
|||||||
return {
|
return {
|
||||||
displayName: 'Replit',
|
displayName: 'Replit',
|
||||||
};
|
};
|
||||||
case DetectedIde.VSCode:
|
|
||||||
return {
|
|
||||||
displayName: 'VS Code',
|
|
||||||
};
|
|
||||||
case DetectedIde.Cursor:
|
case DetectedIde.Cursor:
|
||||||
return {
|
return {
|
||||||
displayName: 'Cursor',
|
displayName: 'Cursor',
|
||||||
@@ -53,6 +50,14 @@ export function getIdeInfo(ide: DetectedIde): IdeInfo {
|
|||||||
return {
|
return {
|
||||||
displayName: 'Trae',
|
displayName: 'Trae',
|
||||||
};
|
};
|
||||||
|
case DetectedIde.VSCode:
|
||||||
|
return {
|
||||||
|
displayName: 'VS Code',
|
||||||
|
};
|
||||||
|
case DetectedIde.VSCodeFork:
|
||||||
|
return {
|
||||||
|
displayName: 'IDE',
|
||||||
|
};
|
||||||
default: {
|
default: {
|
||||||
// This ensures that if a new IDE is added to the enum, we get a compile-time error.
|
// This ensures that if a new IDE is added to the enum, we get a compile-time error.
|
||||||
const exhaustiveCheck: never = ide;
|
const exhaustiveCheck: never = ide;
|
||||||
@@ -61,11 +66,7 @@ export function getIdeInfo(ide: DetectedIde): IdeInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function detectIde(): DetectedIde | undefined {
|
export function detectIdeFromEnv(): DetectedIde {
|
||||||
// Only VSCode-based integrations are currently supported.
|
|
||||||
if (process.env['TERM_PROGRAM'] !== 'vscode') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (process.env['__COG_BASHRC_SOURCED']) {
|
if (process.env['__COG_BASHRC_SOURCED']) {
|
||||||
return DetectedIde.Devin;
|
return DetectedIde.Devin;
|
||||||
}
|
}
|
||||||
@@ -89,3 +90,32 @@ export function detectIde(): DetectedIde | undefined {
|
|||||||
}
|
}
|
||||||
return DetectedIde.VSCode;
|
return DetectedIde.VSCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function verifyVSCode(
|
||||||
|
ide: DetectedIde,
|
||||||
|
ideProcessInfo: {
|
||||||
|
pid: number;
|
||||||
|
command: string;
|
||||||
|
},
|
||||||
|
): DetectedIde {
|
||||||
|
if (ide !== DetectedIde.VSCode) {
|
||||||
|
return ide;
|
||||||
|
}
|
||||||
|
if (ideProcessInfo.command.toLowerCase().includes('code')) {
|
||||||
|
return DetectedIde.VSCode;
|
||||||
|
}
|
||||||
|
return DetectedIde.VSCodeFork;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectIde(ideProcessInfo: {
|
||||||
|
pid: number;
|
||||||
|
command: string;
|
||||||
|
}): DetectedIde | undefined {
|
||||||
|
// Only VSCode-based integrations are currently supported.
|
||||||
|
if (process.env['TERM_PROGRAM'] !== 'vscode') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ide = detectIdeFromEnv();
|
||||||
|
return verifyVSCode(ide, ideProcessInfo);
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import { IdeClient, IDEConnectionStatus } from './ide-client.js';
|
import { IdeClient, IDEConnectionStatus } from './ide-client.js';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import { getIdeProcessId } from './process-utils.js';
|
import { getIdeProcessInfo } from './process-utils.js';
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
@@ -51,7 +51,7 @@ describe('IdeClient', () => {
|
|||||||
let mockHttpTransport: Mocked<StreamableHTTPClientTransport>;
|
let mockHttpTransport: Mocked<StreamableHTTPClientTransport>;
|
||||||
let mockStdioTransport: Mocked<StdioClientTransport>;
|
let mockStdioTransport: Mocked<StdioClientTransport>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
// Reset singleton instance for test isolation
|
// Reset singleton instance for test isolation
|
||||||
(IdeClient as unknown as { instance: IdeClient | undefined }).instance =
|
(IdeClient as unknown as { instance: IdeClient | undefined }).instance =
|
||||||
undefined;
|
undefined;
|
||||||
@@ -68,7 +68,10 @@ describe('IdeClient', () => {
|
|||||||
vi.mocked(getIdeInfo).mockReturnValue({
|
vi.mocked(getIdeInfo).mockReturnValue({
|
||||||
displayName: 'VS Code',
|
displayName: 'VS Code',
|
||||||
} as IdeInfo);
|
} as IdeInfo);
|
||||||
vi.mocked(getIdeProcessId).mockResolvedValue(12345);
|
vi.mocked(getIdeProcessInfo).mockResolvedValue({
|
||||||
|
pid: 12345,
|
||||||
|
command: 'test-ide',
|
||||||
|
});
|
||||||
vi.mocked(os.tmpdir).mockReturnValue('/tmp');
|
vi.mocked(os.tmpdir).mockReturnValue('/tmp');
|
||||||
|
|
||||||
// Mock MCP client and transports
|
// Mock MCP client and transports
|
||||||
@@ -88,6 +91,8 @@ describe('IdeClient', () => {
|
|||||||
vi.mocked(Client).mockReturnValue(mockClient);
|
vi.mocked(Client).mockReturnValue(mockClient);
|
||||||
vi.mocked(StreamableHTTPClientTransport).mockReturnValue(mockHttpTransport);
|
vi.mocked(StreamableHTTPClientTransport).mockReturnValue(mockHttpTransport);
|
||||||
vi.mocked(StdioClientTransport).mockReturnValue(mockStdioTransport);
|
vi.mocked(StdioClientTransport).mockReturnValue(mockStdioTransport);
|
||||||
|
|
||||||
|
await IdeClient.getInstance();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -99,7 +104,7 @@ describe('IdeClient', () => {
|
|||||||
const config = { port: '8080' };
|
const config = { port: '8080' };
|
||||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||||
|
|
||||||
const ideClient = IdeClient.getInstance();
|
const ideClient = await IdeClient.getInstance();
|
||||||
await ideClient.connect();
|
await ideClient.connect();
|
||||||
|
|
||||||
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
||||||
@@ -120,7 +125,7 @@ describe('IdeClient', () => {
|
|||||||
const config = { stdio: { command: 'test-cmd', args: ['--foo'] } };
|
const config = { stdio: { command: 'test-cmd', args: ['--foo'] } };
|
||||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||||
|
|
||||||
const ideClient = IdeClient.getInstance();
|
const ideClient = await IdeClient.getInstance();
|
||||||
await ideClient.connect();
|
await ideClient.connect();
|
||||||
|
|
||||||
expect(StdioClientTransport).toHaveBeenCalledWith({
|
expect(StdioClientTransport).toHaveBeenCalledWith({
|
||||||
@@ -140,7 +145,7 @@ describe('IdeClient', () => {
|
|||||||
};
|
};
|
||||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||||
|
|
||||||
const ideClient = IdeClient.getInstance();
|
const ideClient = await IdeClient.getInstance();
|
||||||
await ideClient.connect();
|
await ideClient.connect();
|
||||||
|
|
||||||
expect(StreamableHTTPClientTransport).toHaveBeenCalled();
|
expect(StreamableHTTPClientTransport).toHaveBeenCalled();
|
||||||
@@ -156,7 +161,7 @@ describe('IdeClient', () => {
|
|||||||
);
|
);
|
||||||
process.env['GEMINI_CLI_IDE_SERVER_PORT'] = '9090';
|
process.env['GEMINI_CLI_IDE_SERVER_PORT'] = '9090';
|
||||||
|
|
||||||
const ideClient = IdeClient.getInstance();
|
const ideClient = await IdeClient.getInstance();
|
||||||
await ideClient.connect();
|
await ideClient.connect();
|
||||||
|
|
||||||
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
||||||
@@ -176,7 +181,7 @@ describe('IdeClient', () => {
|
|||||||
process.env['GEMINI_CLI_IDE_SERVER_STDIO_COMMAND'] = 'env-cmd';
|
process.env['GEMINI_CLI_IDE_SERVER_STDIO_COMMAND'] = 'env-cmd';
|
||||||
process.env['GEMINI_CLI_IDE_SERVER_STDIO_ARGS'] = '["--bar"]';
|
process.env['GEMINI_CLI_IDE_SERVER_STDIO_ARGS'] = '["--bar"]';
|
||||||
|
|
||||||
const ideClient = IdeClient.getInstance();
|
const ideClient = await IdeClient.getInstance();
|
||||||
await ideClient.connect();
|
await ideClient.connect();
|
||||||
|
|
||||||
expect(StdioClientTransport).toHaveBeenCalledWith({
|
expect(StdioClientTransport).toHaveBeenCalledWith({
|
||||||
@@ -194,7 +199,7 @@ describe('IdeClient', () => {
|
|||||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
|
||||||
process.env['GEMINI_CLI_IDE_SERVER_PORT'] = '9090';
|
process.env['GEMINI_CLI_IDE_SERVER_PORT'] = '9090';
|
||||||
|
|
||||||
const ideClient = IdeClient.getInstance();
|
const ideClient = await IdeClient.getInstance();
|
||||||
await ideClient.connect();
|
await ideClient.connect();
|
||||||
|
|
||||||
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
||||||
@@ -211,7 +216,7 @@ describe('IdeClient', () => {
|
|||||||
new Error('File not found'),
|
new Error('File not found'),
|
||||||
);
|
);
|
||||||
|
|
||||||
const ideClient = IdeClient.getInstance();
|
const ideClient = await IdeClient.getInstance();
|
||||||
await ideClient.connect();
|
await ideClient.connect();
|
||||||
|
|
||||||
expect(StreamableHTTPClientTransport).not.toHaveBeenCalled();
|
expect(StreamableHTTPClientTransport).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
CloseDiffResponseSchema,
|
CloseDiffResponseSchema,
|
||||||
DiffUpdateResult,
|
DiffUpdateResult,
|
||||||
} from '../ide/ideContext.js';
|
} from '../ide/ideContext.js';
|
||||||
import { getIdeProcessId } from './process-utils.js';
|
import { getIdeProcessInfo } from './process-utils.js';
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
@@ -72,21 +72,25 @@ export class IdeClient {
|
|||||||
details:
|
details:
|
||||||
'IDE integration is currently disabled. To enable it, run /ide enable.',
|
'IDE integration is currently disabled. To enable it, run /ide enable.',
|
||||||
};
|
};
|
||||||
private readonly currentIde: DetectedIde | undefined;
|
private currentIde: DetectedIde | undefined;
|
||||||
private readonly currentIdeDisplayName: string | undefined;
|
private currentIdeDisplayName: string | undefined;
|
||||||
|
private ideProcessInfo: { pid: number; command: string } | undefined;
|
||||||
private diffResponses = new Map<string, (result: DiffUpdateResult) => void>();
|
private diffResponses = new Map<string, (result: DiffUpdateResult) => void>();
|
||||||
private statusListeners = new Set<(state: IDEConnectionState) => void>();
|
private statusListeners = new Set<(state: IDEConnectionState) => void>();
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {}
|
||||||
this.currentIde = detectIde();
|
|
||||||
if (this.currentIde) {
|
|
||||||
this.currentIdeDisplayName = getIdeInfo(this.currentIde).displayName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static getInstance(): IdeClient {
|
static async getInstance(): Promise<IdeClient> {
|
||||||
if (!IdeClient.instance) {
|
if (!IdeClient.instance) {
|
||||||
IdeClient.instance = new IdeClient();
|
const client = new IdeClient();
|
||||||
|
client.ideProcessInfo = await getIdeProcessInfo();
|
||||||
|
client.currentIde = detectIde(client.ideProcessInfo);
|
||||||
|
if (client.currentIde) {
|
||||||
|
client.currentIdeDisplayName = getIdeInfo(
|
||||||
|
client.currentIde,
|
||||||
|
).displayName;
|
||||||
|
}
|
||||||
|
IdeClient.instance = client;
|
||||||
}
|
}
|
||||||
return IdeClient.instance;
|
return IdeClient.instance;
|
||||||
}
|
}
|
||||||
@@ -103,11 +107,7 @@ export class IdeClient {
|
|||||||
if (!this.currentIde || !this.currentIdeDisplayName) {
|
if (!this.currentIde || !this.currentIdeDisplayName) {
|
||||||
this.setState(
|
this.setState(
|
||||||
IDEConnectionStatus.Disconnected,
|
IDEConnectionStatus.Disconnected,
|
||||||
`IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: ${Object.values(
|
`IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: VS Code or VS Code forks`,
|
||||||
DetectedIde,
|
|
||||||
)
|
|
||||||
.map((ide) => getIdeInfo(ide).displayName)
|
|
||||||
.join(', ')}`,
|
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -168,7 +168,7 @@ export class IdeClient {
|
|||||||
|
|
||||||
this.setState(
|
this.setState(
|
||||||
IDEConnectionStatus.Disconnected,
|
IDEConnectionStatus.Disconnected,
|
||||||
`Failed to connect to IDE companion extension for ${this.currentIdeDisplayName}. Please ensure the extension is running. To install the extension, run /ide install.`,
|
`Failed to connect to IDE companion extension in ${this.currentIdeDisplayName}. Please ensure the extension is running. To install the extension, run /ide install.`,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -309,7 +309,7 @@ export class IdeClient {
|
|||||||
if (ideWorkspacePath === undefined) {
|
if (ideWorkspacePath === undefined) {
|
||||||
return {
|
return {
|
||||||
isValid: false,
|
isValid: false,
|
||||||
error: `Failed to connect to IDE companion extension for ${currentIdeDisplayName}. Please ensure the extension is running. To install the extension, run /ide install.`,
|
error: `Failed to connect to IDE companion extension in ${currentIdeDisplayName}. Please ensure the extension is running. To install the extension, run /ide install.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,11 +375,13 @@ export class IdeClient {
|
|||||||
private async getConnectionConfigFromFile(): Promise<
|
private async getConnectionConfigFromFile(): Promise<
|
||||||
(ConnectionConfig & { workspacePath?: string }) | undefined
|
(ConnectionConfig & { workspacePath?: string }) | undefined
|
||||||
> {
|
> {
|
||||||
|
if (!this.ideProcessInfo) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const ideProcessId = await getIdeProcessId();
|
|
||||||
const portFile = path.join(
|
const portFile = path.join(
|
||||||
os.tmpdir(),
|
os.tmpdir(),
|
||||||
`gemini-ide-server-${ideProcessId}.json`,
|
`gemini-ide-server-${this.ideProcessInfo.pid}.json`,
|
||||||
);
|
);
|
||||||
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
|
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
|
||||||
return JSON.parse(portFileContents);
|
return JSON.parse(portFileContents);
|
||||||
|
|||||||
90
packages/core/src/ide/process-utils.test.ts
Normal file
90
packages/core/src/ide/process-utils.test.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* @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');
|
||||||
|
const processInfoMap = new Map([
|
||||||
|
[1000, { stdout: 'ParentProcessId=900\r\nCommandLine=node.exe\r\n' }],
|
||||||
|
[
|
||||||
|
900,
|
||||||
|
{ stdout: 'ParentProcessId=800\r\nCommandLine=powershell.exe\r\n' },
|
||||||
|
],
|
||||||
|
[800, { stdout: 'ParentProcessId=700\r\nCommandLine=code.exe\r\n' }],
|
||||||
|
[700, { stdout: 'ParentProcessId=0\r\nCommandLine=wininit.exe\r\n' }],
|
||||||
|
]);
|
||||||
|
mockedExec.mockImplementation((command: string) => {
|
||||||
|
const pidMatch = command.match(/ProcessId=(\d+)/);
|
||||||
|
if (pidMatch) {
|
||||||
|
const pid = parseInt(pidMatch[1], 10);
|
||||||
|
return Promise.resolve(processInfoMap.get(pid));
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error('Invalid command for mock'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getIdeProcessInfo();
|
||||||
|
expect(result).toEqual({ pid: 900, command: 'powershell.exe' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,24 +14,27 @@ const execAsync = promisify(exec);
|
|||||||
const MAX_TRAVERSAL_DEPTH = 32;
|
const MAX_TRAVERSAL_DEPTH = 32;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the parent process ID and name for a given process ID.
|
* Fetches the parent process ID, name, and command for a given process ID.
|
||||||
*
|
*
|
||||||
* @param pid The process ID to inspect.
|
* @param pid The process ID to inspect.
|
||||||
* @returns A promise that resolves to the parent's PID and name.
|
* @returns A promise that resolves to the parent's PID, name, and command.
|
||||||
*/
|
*/
|
||||||
async function getParentProcessInfo(pid: number): Promise<{
|
async function getProcessInfo(pid: number): Promise<{
|
||||||
parentPid: number;
|
parentPid: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
command: string;
|
||||||
}> {
|
}> {
|
||||||
const platform = os.platform();
|
const platform = os.platform();
|
||||||
if (platform === 'win32') {
|
if (platform === 'win32') {
|
||||||
const command = `wmic process where "ProcessId=${pid}" get Name,ParentProcessId /value`;
|
const command = `wmic process where "ProcessId=${pid}" get Name,ParentProcessId,CommandLine /value`;
|
||||||
const { stdout } = await execAsync(command);
|
const { stdout } = await execAsync(command);
|
||||||
const nameMatch = stdout.match(/Name=([^\n]*)/);
|
const nameMatch = stdout.match(/Name=([^\n]*)/);
|
||||||
const processName = nameMatch ? nameMatch[1].trim() : '';
|
const processName = nameMatch ? nameMatch[1].trim() : '';
|
||||||
const ppidMatch = stdout.match(/ParentProcessId=(\d+)/);
|
const ppidMatch = stdout.match(/ParentProcessId=(\d+)/);
|
||||||
const parentPid = ppidMatch ? parseInt(ppidMatch[1], 10) : 0;
|
const parentPid = ppidMatch ? parseInt(ppidMatch[1], 10) : 0;
|
||||||
return { parentPid, name: processName };
|
const commandLineMatch = stdout.match(/CommandLine=([^\n]*)/);
|
||||||
|
const commandLine = commandLineMatch ? commandLineMatch[1].trim() : '';
|
||||||
|
return { parentPid, name: processName, command: commandLine };
|
||||||
} else {
|
} else {
|
||||||
const command = `ps -o ppid=,command= -p ${pid}`;
|
const command = `ps -o ppid=,command= -p ${pid}`;
|
||||||
const { stdout } = await execAsync(command);
|
const { stdout } = await execAsync(command);
|
||||||
@@ -40,42 +43,50 @@ async function getParentProcessInfo(pid: number): Promise<{
|
|||||||
const parentPid = parseInt(ppidString, 10);
|
const parentPid = parseInt(ppidString, 10);
|
||||||
const fullCommand = trimmedStdout.substring(ppidString.length).trim();
|
const fullCommand = trimmedStdout.substring(ppidString.length).trim();
|
||||||
const processName = path.basename(fullCommand.split(' ')[0]);
|
const processName = path.basename(fullCommand.split(' ')[0]);
|
||||||
return { parentPid: isNaN(parentPid) ? 1 : parentPid, name: processName };
|
return {
|
||||||
|
parentPid: isNaN(parentPid) ? 1 : parentPid,
|
||||||
|
name: processName,
|
||||||
|
command: fullCommand,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Traverses the process tree on Unix-like systems to find the IDE process ID.
|
* Finds the IDE process info on Unix-like systems.
|
||||||
*
|
*
|
||||||
* The strategy is to find the shell process that spawned the CLI, and then
|
* 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,
|
* find that shell's parent process (the IDE). To get the true IDE process,
|
||||||
* we traverse one level higher to get the grandparent.
|
* we traverse one level higher to get the grandparent.
|
||||||
*
|
*
|
||||||
* @returns A promise that resolves to the numeric PID.
|
* @returns A promise that resolves to the PID and command of the IDE process.
|
||||||
*/
|
*/
|
||||||
async function getIdeProcessIdForUnix(): Promise<number> {
|
async function getIdeProcessInfoForUnix(): Promise<{
|
||||||
|
pid: number;
|
||||||
|
command: string;
|
||||||
|
}> {
|
||||||
const shells = ['zsh', 'bash', 'sh', 'tcsh', 'csh', 'ksh', 'fish', 'dash'];
|
const shells = ['zsh', 'bash', 'sh', 'tcsh', 'csh', 'ksh', 'fish', 'dash'];
|
||||||
let currentPid = process.pid;
|
let currentPid = process.pid;
|
||||||
|
|
||||||
for (let i = 0; i < MAX_TRAVERSAL_DEPTH; i++) {
|
for (let i = 0; i < MAX_TRAVERSAL_DEPTH; i++) {
|
||||||
try {
|
try {
|
||||||
const { parentPid, name } = await getParentProcessInfo(currentPid);
|
const { parentPid, name } = await getProcessInfo(currentPid);
|
||||||
|
|
||||||
const isShell = shells.some((shell) => name === shell);
|
const isShell = shells.some((shell) => name === shell);
|
||||||
if (isShell) {
|
if (isShell) {
|
||||||
// The direct parent of the shell is often a utility process (e.g. VS
|
// 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
|
// Code's `ptyhost` process). To get the true IDE process, we need to
|
||||||
// traverse one level higher to get the grandparent.
|
// traverse one level higher to get the grandparent.
|
||||||
|
let idePid = parentPid;
|
||||||
try {
|
try {
|
||||||
const { parentPid: grandParentPid } =
|
const { parentPid: grandParentPid } = await getProcessInfo(parentPid);
|
||||||
await getParentProcessInfo(parentPid);
|
|
||||||
if (grandParentPid > 1) {
|
if (grandParentPid > 1) {
|
||||||
return grandParentPid;
|
idePid = grandParentPid;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore if getting grandparent fails, we'll just use the parent pid.
|
// Ignore if getting grandparent fails, we'll just use the parent pid.
|
||||||
}
|
}
|
||||||
return parentPid;
|
const { command } = await getProcessInfo(idePid);
|
||||||
|
return { pid: idePid, command };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parentPid <= 1) {
|
if (parentPid <= 1) {
|
||||||
@@ -91,30 +102,36 @@ async function getIdeProcessIdForUnix(): Promise<number> {
|
|||||||
console.error(
|
console.error(
|
||||||
'Failed to find shell process in the process tree. Falling back to top-level process, which may be inaccurate. If you see this, please file a bug via /bug.',
|
'Failed to find shell process in the process tree. Falling back to top-level process, which may be inaccurate. If you see this, please file a bug via /bug.',
|
||||||
);
|
);
|
||||||
return currentPid;
|
const { command } = await getProcessInfo(currentPid);
|
||||||
|
return { pid: currentPid, command };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Traverses the process tree on Windows to find the IDE process ID.
|
* Finds the IDE process info on Windows.
|
||||||
*
|
*
|
||||||
* The strategy is to find the grandchild of the root process.
|
* The strategy is to find the great-grandchild of the root process.
|
||||||
*
|
*
|
||||||
* @returns A promise that resolves to the numeric PID.
|
* @returns A promise that resolves to the PID and command of the IDE process.
|
||||||
*/
|
*/
|
||||||
async function getIdeProcessIdForWindows(): Promise<number> {
|
async function getIdeProcessInfoForWindows(): Promise<{
|
||||||
|
pid: number;
|
||||||
|
command: string;
|
||||||
|
}> {
|
||||||
let currentPid = process.pid;
|
let currentPid = process.pid;
|
||||||
|
let previousPid = process.pid;
|
||||||
|
|
||||||
for (let i = 0; i < MAX_TRAVERSAL_DEPTH; i++) {
|
for (let i = 0; i < MAX_TRAVERSAL_DEPTH; i++) {
|
||||||
try {
|
try {
|
||||||
const { parentPid } = await getParentProcessInfo(currentPid);
|
const { parentPid } = await getProcessInfo(currentPid);
|
||||||
|
|
||||||
if (parentPid > 0) {
|
if (parentPid > 0) {
|
||||||
try {
|
try {
|
||||||
const { parentPid: grandParentPid } =
|
const { parentPid: grandParentPid } = await getProcessInfo(parentPid);
|
||||||
await getParentProcessInfo(parentPid);
|
|
||||||
if (grandParentPid === 0) {
|
if (grandParentPid === 0) {
|
||||||
// Found grandchild of root
|
// We've found the grandchild of the root (`currentPid`). The IDE
|
||||||
return currentPid;
|
// process is its child, which we've stored in `previousPid`.
|
||||||
|
const { command } = await getProcessInfo(previousPid);
|
||||||
|
return { pid: previousPid, command };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// getting grandparent failed, proceed
|
// getting grandparent failed, proceed
|
||||||
@@ -124,34 +141,39 @@ async function getIdeProcessIdForWindows(): Promise<number> {
|
|||||||
if (parentPid <= 0) {
|
if (parentPid <= 0) {
|
||||||
break; // Reached the root
|
break; // Reached the root
|
||||||
}
|
}
|
||||||
|
previousPid = currentPid;
|
||||||
currentPid = parentPid;
|
currentPid = parentPid;
|
||||||
} catch {
|
} catch {
|
||||||
// Process in chain died
|
// Process in chain died
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return currentPid;
|
const { command } = await getProcessInfo(currentPid);
|
||||||
|
return { pid: currentPid, command };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Traverses up the process tree to find the process ID of the IDE.
|
* 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
|
* This function uses different strategies depending on the operating system
|
||||||
* to identify the main application process (e.g., the main VS Code window
|
* to identify the main application process (e.g., the main VS Code window
|
||||||
* process).
|
* process).
|
||||||
*
|
*
|
||||||
* If the IDE process cannot be reliably identified, it will return the
|
* If the IDE process cannot be reliably identified, it will return the
|
||||||
* top-level ancestor process ID as a fallback.
|
* top-level ancestor process ID and command as a fallback.
|
||||||
*
|
*
|
||||||
* @returns A promise that resolves to the numeric PID of the IDE process.
|
* @returns A promise that resolves to the PID and command of the IDE process.
|
||||||
* @throws Will throw an error if the underlying shell commands fail.
|
* @throws Will throw an error if the underlying shell commands fail.
|
||||||
*/
|
*/
|
||||||
export async function getIdeProcessId(): Promise<number> {
|
export async function getIdeProcessInfo(): Promise<{
|
||||||
|
pid: number;
|
||||||
|
command: string;
|
||||||
|
}> {
|
||||||
const platform = os.platform();
|
const platform = os.platform();
|
||||||
|
|
||||||
if (platform === 'win32') {
|
if (platform === 'win32') {
|
||||||
return getIdeProcessIdForWindows();
|
return getIdeProcessInfoForWindows();
|
||||||
}
|
}
|
||||||
|
|
||||||
return getIdeProcessIdForUnix();
|
return getIdeProcessInfoForUnix();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { UserAccountManager } from '../../utils/userAccountManager.js';
|
|||||||
import { safeJsonStringify } from '../../utils/safeJsonStringify.js';
|
import { safeJsonStringify } from '../../utils/safeJsonStringify.js';
|
||||||
import { FixedDeque } from 'mnemonist';
|
import { FixedDeque } from 'mnemonist';
|
||||||
import { GIT_COMMIT_INFO, CLI_VERSION } from '../../generated/git-commit.js';
|
import { GIT_COMMIT_INFO, CLI_VERSION } from '../../generated/git-commit.js';
|
||||||
import { DetectedIde, detectIde } from '../../ide/detect-ide.js';
|
import { DetectedIde, detectIdeFromEnv } from '../../ide/detect-ide.js';
|
||||||
|
|
||||||
export enum EventNames {
|
export enum EventNames {
|
||||||
START_SESSION = 'start_session',
|
START_SESSION = 'start_session',
|
||||||
@@ -93,7 +93,7 @@ function determineSurface(): string {
|
|||||||
} else if (process.env['GITHUB_SHA']) {
|
} else if (process.env['GITHUB_SHA']) {
|
||||||
return 'GitHub';
|
return 'GitHub';
|
||||||
} else if (process.env['TERM_PROGRAM'] === 'vscode') {
|
} else if (process.env['TERM_PROGRAM'] === 'vscode') {
|
||||||
return detectIde() || DetectedIde.VSCode;
|
return detectIdeFromEnv() || DetectedIde.VSCode;
|
||||||
} else {
|
} else {
|
||||||
return 'SURFACE_NOT_SET';
|
return 'SURFACE_NOT_SET';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user