diff --git a/.github/workflows/presubmit.yml b/.github/workflows/presubmit.yml index b5cad8e..b277de4 100644 --- a/.github/workflows/presubmit.yml +++ b/.github/workflows/presubmit.yml @@ -56,9 +56,16 @@ jobs: - name: Install dependencies run: npm ci - - name: Run tests - run: | - npm run test:i + - name: Run unit tests + run: npm run test:i + + - name: 'Set up Cloud SDK' + uses: 'google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db' # v3 + with: + version: '>= 530.0.0' + + - name: Run integration tests + run: npm run test:integration --workspace packages/gcloud-mcp coverage: if: github.actor != 'dependabot[bot]' diff --git a/.gitignore b/.gitignore index e2714fe..e56b531 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ Thumbs.db coverage/ packages/*/coverage/ junit.xml +junit.integration.xml packages/*/junit.xml # Ignore package-lock.json files in subdirectories diff --git a/packages/gcloud-mcp/package.json b/packages/gcloud-mcp/package.json index e6a35dc..c50bad8 100644 --- a/packages/gcloud-mcp/package.json +++ b/packages/gcloud-mcp/package.json @@ -11,6 +11,7 @@ "scripts": { "build": "tsc --noemit && node build.js", "test": "vitest run", + "test:integration": "vitest run --config vitest.config.integration.ts", "start": "node dist/bundle.js", "lint": "prettier --check . && eslint . --max-warnings 0", "fix": "prettier --write . && eslint . --fix", diff --git a/packages/gcloud-mcp/src/gcloud-executor.ts b/packages/gcloud-mcp/src/gcloud-executor.ts new file mode 100644 index 0000000..10b1631 --- /dev/null +++ b/packages/gcloud-mcp/src/gcloud-executor.ts @@ -0,0 +1,116 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as child_process from 'child_process'; +import { getWindowsCloudSDKSettingsAsync } from './windows_gcloud_utils.js'; +import * as path from 'path'; + +export const isWindows = (): boolean => process.platform === 'win32'; + +export interface GcloudExecutionResult { + code: number | null; + stdout: string; + stderr: string; +} + +export interface GcloudExecutor { + execute: (args: string[]) => Promise +} + +export const findExecutable = async (): Promise => { + const executor = await createExecutor(); + return { + execute: async (args: string[]): Promise => + new Promise(async (resolve, reject) => { + let stdout = ''; + let stderr = ''; + + const gcloud = executor.execute(args); + + gcloud.stdout.on('data', (data) => { + stdout += data.toString(); + }); + gcloud.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + gcloud.on('close', (code) => { + // All responses from gcloud, including non-zero codes. + resolve({ code, stdout, stderr }); + }); + gcloud.on('error', (err) => { + // Process failed to start. gcloud isn't able to be invoked. + reject(err); + }); + }) + } +} + +const isAvailable = (): Promise => + new Promise((resolve) => { + const which = child_process.spawn(isWindows() ? 'where' : 'which', ['gcloud']); + which.on('close', (code) => { + resolve(code === 0); + }); + which.on('error', () => { + resolve(false); + }); + }); + +const createExecutor = async () => { + if(!await isAvailable()) { + throw Error('gcloud executable not found'); + } + if (isWindows()) { + return await createWindowsExecutor(); + } + return createDirectExecutor(); +} + +/** Creates an executor that directly invokes the gcloud binary on the current PATH. */ +const createDirectExecutor = () => ({ + execute: (args: string[]) => child_process.spawn('gcloud', args, { + stdio: ['ignore', 'pipe', 'pipe'], + }) +}); + +const createWindowsExecutor = async () => { + const settings = await getWindowsCloudSDKSettingsAsync(); + + if (settings == null || settings.noWorkingPythonFound) { + throw Error('no working Python installation found for Windows gcloud execution.'); + } + + const windowsPathForGcloudPy = path.join( + settings.cloudSdkRootDir, + 'lib', + 'gcloud.py', + ); + + const pythonPath = path.normalize( + settings.cloudSdkPython, + ); + + return { + execute: (args: string[]) => child_process.spawn(pythonPath, [ + ...settings.cloudSdkPythonArgsList, + windowsPathForGcloudPy, + ...args + ], { + stdio: ['ignore', 'pipe', 'pipe'] + }) + } +} diff --git a/packages/gcloud-mcp/src/gcloud.integration.test.ts b/packages/gcloud-mcp/src/gcloud.integration.test.ts new file mode 100644 index 0000000..ad7ab50 --- /dev/null +++ b/packages/gcloud-mcp/src/gcloud.integration.test.ts @@ -0,0 +1,91 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect, assert } from 'vitest'; +import * as gcloud from './gcloud.js'; + +test('gcloud is available', async () => { + const result = await gcloud.isAvailable(); + expect(result).toBe(true); +}); + +test('can invoke gcloud to lint a command', async () => { + const result = await gcloud.lint('compute instances list'); + assert(result.success); + expect(result.parsedCommand).toBe('compute instances list'); +}, 10000); + +test('cannot inject a command by appending arguments', async () => { + const result = await gcloud.invoke(['config', 'list', '&&', 'echo', 'asdf']); + expect(result.stdout).not.toContain('asdf'); + expect(result.code).toBeGreaterThan(0); +}, 10000); + +test('cannot inject a command by appending command', async () => { + const result = await gcloud.invoke(['config', 'list', '&&', 'echo asdf']); + expect(result.code).toBeGreaterThan(0); +}, 10000); + +test('cannot inject a command with a final argument', async () => { + const result = await gcloud.invoke(['config', 'list', '&& echo asdf']); + expect(result.code).toBeGreaterThan(0); +}, 10000); + +test('cannot inject a command with a single argument', async () => { + const result = await gcloud.invoke(['config list && echo asdf']); + expect(result.code).toBeGreaterThan(0); +}, 10000); + +test('can invoke windows gcloud when there are multiple python args', async () => { + // Set the environment variables correctly and then reimport gcloud to force it to reload + process.env['CLOUDSDK_PYTHON_ARGS'] = '-S -u -B'; + const gcloud = await import('./gcloud.js'); + const result = await gcloud.invoke(['config', 'list']); + expect(result.code).toBe(0); +}, 10000); + +test('can invoke windows gcloud when there are 1 python args', async () => { + // Set the environment variables correctly and then reimport gcloud to force it to reload + process.env['CLOUDSDK_PYTHON_ARGS'] = '-u'; + const gcloud = await import('./gcloud.js'); + const result = await gcloud.invoke(['config', 'list']); + expect(result.code).toBe(0); +}, 10000); + +test('can invoke windows gcloud when there are no python args', async () => { + // Set the environment variables correctly and then reimport gcloud to force it to reload + process.env['CLOUDSDK_PYTHON_ARGS'] = ''; + const gcloud = await import('./gcloud.js'); + const result = await gcloud.invoke(['config', 'list']); + expect(result.code).toBe(0); +}, 10000); + +test('can invoke windows gcloud when site packages are enabled', async () => { + // Set the environment variables correctly and then reimport gcloud to force it to reload + process.env['CLOUDSDK_PYTHON_SITEPACKAGES'] = '1'; + const gcloud = await import('./gcloud.js'); + const result = await gcloud.invoke(['config', 'list']); + expect(result.code).toBe(0); +}, 10000); + +test('can invoke windows gcloud when site packages are enabled and python args exists', async () => { + // Set the environment variables correctly and then reimport gcloud to force it to reload + process.env['CLOUDSDK_PYTHON_SITEPACKAGES'] = '1'; + process.env['CLOUDSDK_PYTHON_ARGS'] = '-u'; + const gcloud = await import('./gcloud.js'); + const result = await gcloud.invoke(['config', 'list']); + expect(result.code).toBe(0); +}, 10000); diff --git a/packages/gcloud-mcp/src/gcloud.test.ts b/packages/gcloud-mcp/src/gcloud.test.ts index a90dec4..018e2fc 100644 --- a/packages/gcloud-mcp/src/gcloud.test.ts +++ b/packages/gcloud-mcp/src/gcloud.test.ts @@ -17,28 +17,152 @@ import { test, expect, beforeEach, Mock, vi, assert } from 'vitest'; import * as child_process from 'child_process'; import { PassThrough } from 'stream'; -import * as gcloud from './gcloud.js'; -import { isWindows } from './gcloud.js'; +import * as path from 'path'; // Import path module + +let gcloud: typeof import('./gcloud.js'); +let isWindows: typeof import('./gcloud.js').isWindows; vi.mock('child_process', () => ({ spawn: vi.fn(), })); const mockedSpawn = child_process.spawn as unknown as Mock; +let mockedGetCloudSDKSettings: Mock; + +interface MockChildProcess extends PassThrough { + stdout: PassThrough; + stderr: PassThrough; + on: Mock; +} + +// Helper function to create a mock child process +const createMockChildProcess = (exitCode: number) => { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const child = new PassThrough() as MockChildProcess; + child.stdout = stdout; + child.stderr = stderr; + child.on = vi.fn((event, callback) => { + if (event === 'close') { + setTimeout(() => callback(exitCode), 0); + } + }); + return child; +}; -beforeEach(() => { +beforeEach(async () => { vi.clearAllMocks(); + vi.resetModules(); // Clear module cache before each test + + // Explicitly mock windows_gcloud_utils.js here to ensure it's active before gcloud.js is imported. + vi.doMock('./windows_gcloud_utils.js', () => ({ + getCloudSDKSettings: vi.fn(), + getCloudSDKSettingsAsync: vi.fn(), + })); + mockedGetCloudSDKSettings = (await import('./windows_gcloud_utils.js')) + .getCloudSDKSettingsAsync as unknown as Mock; + + // Dynamically import gcloud.js after mocks are set up. + gcloud = await import('./gcloud.js'); + isWindows = gcloud.isWindows; +}); + +test('getPlatformSpecificGcloudCommand should return gcloud command for non-windows platform', async () => { + mockedGetCloudSDKSettings.mockResolvedValue({ + isWindowsPlatform: false, + windowsCloudSDKSettings: null, + }); + const { command, args } = await gcloud.getPlatformSpecificGcloudCommand([ + 'test', + '--project=test-project', + ]); + expect(command).toBe('gcloud'); + expect(args).toEqual(['test', '--project=test-project']); +}); + +test('getPlatformSpecificGcloudCommand should return python command for windows platform', async () => { + const cloudSdkRootDir = 'C:\\Users\\test\\AppData\\Local\\Google\\Cloud SDK'; + const cloudSdkPython = path.win32.join( + cloudSdkRootDir, + 'platform', + 'bundledpython', + 'python.exe', + ); + const gcloudPyPath = path.win32.join(cloudSdkRootDir, 'lib', 'gcloud.py'); + + mockedGetCloudSDKSettings.mockResolvedValue({ + isWindowsPlatform: true, + windowsCloudSDKSettings: { + cloudSdkRootDir, + cloudSdkPython, + cloudSdkPythonArgsList: ['-S'], + }, + }); + const { command, args } = await gcloud.getPlatformSpecificGcloudCommand([ + 'test', + '--project=test-project', + ]); + expect(command).toBe(path.win32.normalize(cloudSdkPython)); + expect(args).toEqual(['-S', gcloudPyPath, 'test', '--project=test-project']); +}); + +test('invoke should call gcloud with the correct arguments on non-windows platform', async () => { + const mockChild = createMockChildProcess(0); + mockedSpawn.mockReturnValue(mockChild); + mockedGetCloudSDKSettings.mockResolvedValue({ + isWindowsPlatform: false, + windowsCloudSDKSettings: null, + }); + + const resultPromise = gcloud.invoke(['test', '--project=test-project']); + mockChild.stdout.end(); + await resultPromise; + + expect(mockedSpawn).toHaveBeenCalledWith('gcloud', ['test', '--project=test-project'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); +}); + +test('invoke should call python with the correct arguments on windows platform', async () => { + const mockChild = createMockChildProcess(0); + mockedSpawn.mockReturnValue(mockChild); + mockedGetCloudSDKSettings.mockResolvedValue({ + isWindowsPlatform: true, + windowsCloudSDKSettings: { + cloudSdkRootDir: 'C:\\Users\\test\\AppData\\Local\\Google\\Cloud SDK', + cloudSdkPython: + 'C:\\Users\\test\\AppData\\Local\\Google\\Cloud SDK\\platform\\bundledpython\\python.exe', + cloudSdkPythonArgsList: ['-S'], + }, + }); + + const resultPromise = gcloud.invoke(['test', '--project=test-project']); + mockChild.stdout.end(); + await resultPromise; + + expect(mockedSpawn).toHaveBeenCalledWith( + path.win32.normalize( + 'C:\\Users\\test\\AppData\\Local\\Google\\Cloud SDK\\platform\\bundledpython\\python.exe', + ), + [ + '-S', + path.win32.normalize('C:\\Users\\test\\AppData\\Local\\Google\\Cloud SDK\\lib\\gcloud.py'), + 'test', + '--project=test-project', + ], + { + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); }); test('should return true if which command succeeds', async () => { - const mockChildProcess = { - on: vi.fn((event, callback) => { - if (event === 'close') { - setTimeout(() => callback(0), 0); - } - }), - }; - mockedSpawn.mockReturnValue(mockChildProcess); + const mockChild = createMockChildProcess(0); + mockedSpawn.mockReturnValue(mockChild); + mockedGetCloudSDKSettings.mockResolvedValue({ + isWindowsPlatform: false, + windowsCloudSDKSettings: null, + }); const result = await gcloud.isAvailable(); @@ -51,14 +175,8 @@ test('should return true if which command succeeds', async () => { }); test('should return false if which command fails with non-zero exit code', async () => { - const mockChildProcess = { - on: vi.fn((event, callback) => { - if (event === 'close') { - setTimeout(() => callback(1), 0); - } - }), - }; - mockedSpawn.mockReturnValue(mockChildProcess); + const mockChild = createMockChildProcess(1); + mockedSpawn.mockReturnValue(mockChild); const result = await gcloud.isAvailable(); @@ -71,14 +189,14 @@ test('should return false if which command fails with non-zero exit code', async }); test('should return false if which command fails', async () => { - const mockChildProcess = { + const mockChild = { on: vi.fn((event, callback) => { if (event === 'error') { setTimeout(() => callback(new Error('Failed to start')), 0); } }), }; - mockedSpawn.mockReturnValue(mockChildProcess); + mockedSpawn.mockReturnValue(mockChild); const result = await gcloud.isAvailable(); @@ -91,25 +209,22 @@ test('should return false if which command fails', async () => { }); test('should correctly handle stdout and stderr', async () => { - const mockChildProcess = { - stdout: new PassThrough(), - stderr: new PassThrough(), - stdin: new PassThrough(), - on: vi.fn((event, callback) => { - if (event === 'close') { - setTimeout(() => callback(0), 0); - } - }), - }; - mockedSpawn.mockReturnValue(mockChildProcess); + const mockChild = createMockChildProcess(0); + mockedSpawn.mockReturnValue(mockChild); + mockedGetCloudSDKSettings.mockResolvedValue({ + isWindowsPlatform: false, + windowsCloudSDKSettings: null, + }); const resultPromise = gcloud.invoke(['interactive-command']); - mockChildProcess.stdout.emit('data', 'Standard out'); - mockChildProcess.stderr.emit('data', 'Stan'); - mockChildProcess.stdout.emit('data', 'put'); - mockChildProcess.stderr.emit('data', 'dard error'); - mockChildProcess.stdout.end(); + process.nextTick(() => { + mockChild.stdout.emit('data', 'Standard output'); + mockChild.stderr.emit('data', 'Stan'); + mockChild.stdout.emit('data', 'put'); + mockChild.stderr.emit('data', 'dard error'); + mockChild.stdout.end(); + }); const result = await resultPromise; @@ -122,25 +237,22 @@ test('should correctly handle stdout and stderr', async () => { }); test('should correctly non-zero exit codes', async () => { - const mockChildProcess = { - stdout: new PassThrough(), - stderr: new PassThrough(), - stdin: new PassThrough(), - on: vi.fn((event, callback) => { - if (event === 'close') { - setTimeout(() => callback(1), 0); // Error code - } - }), - }; - mockedSpawn.mockReturnValue(mockChildProcess); + const mockChild = createMockChildProcess(1); + mockedSpawn.mockReturnValue(mockChild); + mockedGetCloudSDKSettings.mockResolvedValue({ + isWindowsPlatform: false, + windowsCloudSDKSettings: null, + }); const resultPromise = gcloud.invoke(['interactive-command']); - mockChildProcess.stdout.emit('data', 'Standard out'); - mockChildProcess.stderr.emit('data', 'Stan'); - mockChildProcess.stdout.emit('data', 'put'); - mockChildProcess.stderr.emit('data', 'dard error'); - mockChildProcess.stdout.end(); + process.nextTick(() => { + mockChild.stdout.emit('data', 'Standard output'); + mockChild.stderr.emit('data', 'Stan'); + mockChild.stdout.emit('data', 'put'); + mockChild.stderr.emit('data', 'dard error'); + mockChild.stdout.end(); + }); const result = await resultPromise; @@ -153,15 +265,19 @@ test('should correctly non-zero exit codes', async () => { }); test('should reject when process fails to start', async () => { - mockedSpawn.mockReturnValue({ + const mockChild = { stdout: new PassThrough(), stderr: new PassThrough(), - stdin: new PassThrough(), on: vi.fn((event, callback) => { if (event === 'error') { setTimeout(() => callback(new Error('Failed to start')), 0); } }), + }; + mockedSpawn.mockReturnValue(mockChild); + mockedGetCloudSDKSettings.mockResolvedValue({ + isWindowsPlatform: false, + windowsCloudSDKSettings: null, }); const resultPromise = gcloud.invoke(['some-command']); @@ -173,17 +289,12 @@ test('should reject when process fails to start', async () => { }); test('should correctly call lint double quotes', async () => { - const mockChildProcess = { - stdout: new PassThrough(), - stderr: new PassThrough(), - stdin: new PassThrough(), - on: vi.fn((event, callback) => { - if (event === 'close') { - setTimeout(() => callback(0), 0); - } - }), - }; - mockedSpawn.mockReturnValue(mockChildProcess); + const mockChild = createMockChildProcess(0); + mockedSpawn.mockReturnValue(mockChild); + mockedGetCloudSDKSettings.mockResolvedValue({ + isWindowsPlatform: false, + windowsCloudSDKSettings: null, + }); const resultPromise = gcloud.lint('compute instances list --project "cloud123"'); @@ -195,9 +306,8 @@ test('should correctly call lint double quotes', async () => { error_type: null, }, ]); - mockChildProcess.stdout.emit('data', json); - mockChildProcess.stderr.emit('data', 'Update available'); - mockChildProcess.stdout.end(); + mockChild.stdout.write(json); + mockChild.stdout.end(); const result = await resultPromise; @@ -219,17 +329,12 @@ test('should correctly call lint double quotes', async () => { }); test('should correctly call lint single quotes', async () => { - const mockChildProcess = { - stdout: new PassThrough(), - stderr: new PassThrough(), - stdin: new PassThrough(), - on: vi.fn((event, callback) => { - if (event === 'close') { - setTimeout(() => callback(0), 0); - } - }), - }; - mockedSpawn.mockReturnValue(mockChildProcess); + const mockChild = createMockChildProcess(0); + mockedSpawn.mockReturnValue(mockChild); + mockedGetCloudSDKSettings.mockResolvedValue({ + isWindowsPlatform: false, + windowsCloudSDKSettings: null, + }); const resultPromise = gcloud.lint("compute instances list --project 'cloud123'"); @@ -241,9 +346,8 @@ test('should correctly call lint single quotes', async () => { error_type: null, }, ]); - mockChildProcess.stdout.emit('data', json); - mockChildProcess.stderr.emit('data', 'Update available'); - mockChildProcess.stdout.end(); + mockChild.stdout.write(json); + mockChild.stdout.end(); const result = await resultPromise; diff --git a/packages/gcloud-mcp/src/gcloud.ts b/packages/gcloud-mcp/src/gcloud.ts index 7cca281..8aa6d83 100644 --- a/packages/gcloud-mcp/src/gcloud.ts +++ b/packages/gcloud-mcp/src/gcloud.ts @@ -15,20 +15,56 @@ */ import { z } from 'zod'; -import * as child_process from 'child_process'; +import { findExecutable } from './gcloud-executor.js'; -export const isWindows = (): boolean => process.platform === 'win32'; +export interface GcloudExecutable { + invoke: (args: string[]) => Promise; + lint: (command: string) => Promise; +} + +export const create = async (): Promise => { + const gcloud = await findExecutable(); + + return { + invoke: gcloud.execute, + lint: async (command: string): Promise => { + const { code, stdout, stderr } = await gcloud.execute([ + 'meta', + 'lint-gcloud-commands', + '--command-string', + `gcloud ${command}`, + ]); + + const json = JSON.parse(stdout); + const lintCommands: LintCommandsOutput = LintCommandsSchema.parse(json); + const lintCommand = lintCommands[0]; + if (!lintCommand) { + throw new Error('gcloud lint result contained no contents'); + } + + // gcloud returned a non-zero response + if (code !== 0) { + return { success: false, error: stderr }; + } -export const isAvailable = (): Promise => - new Promise((resolve) => { - const which = child_process.spawn(isWindows() ? 'where' : 'which', ['gcloud']); - which.on('close', (code) => { - resolve(code === 0); - }); - which.on('error', () => { - resolve(false); - }); - }); + // Command has bad syntax + if (!lintCommand.success) { + let error = `${lintCommand.error_message}`; + if (lintCommand.error_type) { + error = `${lintCommand.error_type}: ${error}`; + } + return { success: false, error }; + } + + // Else, success. + return { + success: true, + // Remove gcloud prefix since we added it in during the invocation, above. + parsedCommand: lintCommand.command_string_no_args.slice('gcloud '.length), + }; + } + } +} export interface GcloudInvocationResult { code: number | null; @@ -36,30 +72,6 @@ export interface GcloudInvocationResult { stderr: string; } -export const invoke = (args: string[]): Promise => - new Promise((resolve, reject) => { - let stdout = ''; - let stderr = ''; - - const gcloud = child_process.spawn('gcloud', args, { stdio: ['ignore', 'pipe', 'pipe'] }); - - gcloud.stdout.on('data', (data) => { - stdout += data.toString(); - }); - gcloud.stderr.on('data', (data) => { - stderr += data.toString(); - }); - - gcloud.on('close', (code) => { - // All responses from gcloud, including non-zero codes. - resolve({ code, stdout, stderr }); - }); - gcloud.on('error', (err) => { - // Process failed to start. gcloud isn't able to be invoked. - reject(err); - }); - }); - // There are more fields in this object, but we're only parsing the ones currently in use. const LintCommandSchema = z.object({ command_string_no_args: z.string(), @@ -81,39 +93,3 @@ export type ParsedGcloudLintResult = error: string; }; -export const lint = async (command: string): Promise => { - const { code, stdout, stderr } = await invoke([ - 'meta', - 'lint-gcloud-commands', - '--command-string', - `gcloud ${command}`, - ]); - - const json = JSON.parse(stdout); - const lintCommands: LintCommandsOutput = LintCommandsSchema.parse(json); - const lintCommand = lintCommands[0]; - if (!lintCommand) { - throw new Error('gcloud lint result contained no contents'); - } - - // gcloud returned a non-zero response - if (code !== 0) { - return { success: false, error: stderr }; - } - - // Command has bad syntax - if (!lintCommand.success) { - let error = `${lintCommand.error_message}`; - if (lintCommand.error_type) { - error = `${lintCommand.error_type}: ${error}`; - } - return { success: false, error }; - } - - // Else, success. - return { - success: true, - // Remove gcloud prefix since we added it in during the invocation, above. - parsedCommand: lintCommand.command_string_no_args.slice('gcloud '.length), - }; -}; diff --git a/packages/gcloud-mcp/src/index.test.ts b/packages/gcloud-mcp/src/index.test.ts index 9f9dbd4..debaaae 100644 --- a/packages/gcloud-mcp/src/index.test.ts +++ b/packages/gcloud-mcp/src/index.test.ts @@ -45,6 +45,10 @@ beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); + vi.spyOn(gcloud, 'getMemoizedCloudSDKSettingsAsync').mockResolvedValue({ + isWindowsPlatform: false, + windowsCloudSDKSettings: null, + }); registerToolSpy.mockClear(); }); @@ -150,3 +154,29 @@ test('should exit if config file is invalid JSON', async () => { ); expect(process.exit).toHaveBeenCalledWith(1); }); + +test('should exit if os is windows and it can not find working python', async () => { + process.argv = ['node', 'index.js']; + vi.spyOn(gcloud, 'isAvailable').mockResolvedValue(true); + vi.spyOn(gcloud, 'getMemoizedCloudSDKSettingsAsync').mockResolvedValue({ + isWindowsPlatform: true, + windowsCloudSDKSettings: { + cloudSdkRootDir: '', + cloudSdkPython: '', + cloudSdkPythonArgsList: [], + noWorkingPythonFound: true, + env: {}, + }, + }); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.stubGlobal('process', { ...process, exit: vi.fn(), on: vi.fn() }); + + await import('./index.js'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Unable to start gcloud mcp server: No working Python installation found for Windows gcloud execution.', + ), + ); + expect(process.exit).toHaveBeenCalledWith(1); +}); diff --git a/packages/gcloud-mcp/src/index.ts b/packages/gcloud-mcp/src/index.ts index a33e6fa..44a93f9 100644 --- a/packages/gcloud-mcp/src/index.ts +++ b/packages/gcloud-mcp/src/index.ts @@ -71,12 +71,6 @@ const main = async () => { .help() .parse()) as { config?: string; [key: string]: unknown }; - const isAvailable = await gcloud.isAvailable(); - if (!isAvailable) { - log.error('Unable to start gcloud mcp server: gcloud executable not found.'); - process.exit(1); - } - let config: McpConfig = {}; const configFile = argv.config; @@ -112,10 +106,20 @@ const main = async () => { }, { capabilities: { tools: {} } }, ); + const acl = createAccessControlList(config.allow, [...default_deny, ...(config.deny ?? [])]); - createRunGcloudCommand(acl).register(server); - await server.connect(new StdioServerTransport()); - log.info('🚀 gcloud mcp server started'); + + try { + const cli = await gcloud.create(); + createRunGcloudCommand(cli, acl).register(server); + await server.connect(new StdioServerTransport()); + log.info('🚀 gcloud mcp server started'); + + } catch (e: unknown) { + const error = String(e); + log.error(`Unable to start gcloud mcp server: ${error}`) + process.exit(1); + } process.on('uncaughtException', async (err: unknown) => { await server.close(); diff --git a/packages/gcloud-mcp/src/tools/run_gcloud_command.ts b/packages/gcloud-mcp/src/tools/run_gcloud_command.ts index cbd2d26..cedb7c2 100644 --- a/packages/gcloud-mcp/src/tools/run_gcloud_command.ts +++ b/packages/gcloud-mcp/src/tools/run_gcloud_command.ts @@ -15,7 +15,7 @@ */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import * as gcloud from '../gcloud.js'; +import { GcloudExecutable } from '../gcloud.js'; import { AccessControlList } from '../denylist.js'; import { findSuggestedAlternativeCommand } from '../suggest.js'; import { z } from 'zod'; @@ -31,7 +31,7 @@ const aclErrorMessage = (aclMessage: string) => '\n\n' + 'To get the access control list details, invoke this tool again with the args ["gcloud-mcp", "debug", "config"]'; -export const createRunGcloudCommand = (acl: AccessControlList) => ({ +export const createRunGcloudCommand = (gcloud: GcloudExecutable, acl: AccessControlList) => ({ register: (server: McpServer) => { server.registerTool( 'run_gcloud_command', diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts new file mode 100644 index 0000000..397c285 --- /dev/null +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.test.ts @@ -0,0 +1,339 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as child_process from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + execWhereAsync, + getPythonVersionAsync, + findWindowsPythonPathAsync, + getSDKRootDirectoryAsync, + getWindowsCloudSDKSettingsAsync, + getCloudSDKSettingsAsync, +} from './windows_gcloud_utils.js'; + +vi.mock('child_process'); +vi.mock('fs'); +vi.mock('os'); + +describe('windows_gcloud_utils', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe('execWhereAsync', () => { + it('should return paths when command is found', async () => { + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback( + null, + 'C:\\Program Files\\Python\\Python39\\python.exe\r\nC:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe', + '', + ); + } + return {} as child_process.ChildProcess; + }); + const result = await execWhereAsync('command', {}); + expect(result).toStrictEqual( + [ + 'C:\\Program Files\\Python\\Python39\\python.exe', + 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe', + ].map((p) => path.win32.normalize(p)), + ); + }); + + it('should return empty array when command is not found', async () => { + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(new Error('not found'), '', ''); + } + return {} as child_process.ChildProcess; + }); + const result = await execWhereAsync('command', {}); + expect(result).toStrictEqual([]); + }); + }); + + describe('getPythonVersionAsync', () => { + it('should return python version', async () => { + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(null, '3.9.0', ''); + } + return {} as child_process.ChildProcess; + }); + const version = await getPythonVersionAsync('python', {}); + expect(version).toBe('3.9.0'); + }); + + it('should return undefined if python not found', async () => { + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(new Error('not found'), '', ''); + } + return {} as child_process.ChildProcess; + }); + const version = await getPythonVersionAsync('python', {}); + expect(version).toBeUndefined(); + }); + }); + + describe('findWindowsPythonPathAsync', () => { + it('should find python3 when multiple python versions are present', async () => { + const execMock = vi.spyOn(child_process, 'exec'); + // Mock for execWhereAsync('python', spawnEnv) + execMock.mockImplementationOnce((_command, _options, callback) => { + if (callback) { + callback(null, 'C:\\Python27\\python.exe\r\nC:\\Python39\\python.exe', ''); + } + return {} as child_process.ChildProcess; + }); + // Mock for getPythonVersionAsync('C:\\Python27\\python.exe', spawnEnv) + execMock.mockImplementationOnce((_command, _options, callback) => { + if (callback) { + callback(null, '2.7.18', ''); + } + return {} as child_process.ChildProcess; + }); + // Mock for getPythonVersionAsync('C:\\Python39\\python.exe', spawnEnv) + execMock.mockImplementationOnce((_command, _options, callback) => { + if (callback) { + callback(null, '3.9.5', ''); + } + return {} as child_process.ChildProcess; + }); + + const pythonPath = await findWindowsPythonPathAsync({}); + expect(pythonPath).toBe('C:\\Python39\\python.exe'); + }); + + it('should find python2 if no python3 is available', async () => { + const execMock = vi.spyOn(child_process, 'exec'); + // Mock for execWhereAsync('python', spawnEnv) + execMock.mockImplementationOnce((_command, _options, callback) => { + if (callback) { + callback(null, 'C:\\Python27\\python.exe', ''); + } + return {} as child_process.ChildProcess; + }); + // Mock for getPythonVersionAsync('C:\\Python27\\python.exe', spawnEnv) + execMock.mockImplementationOnce((_command, _options, callback) => { + if (callback) { + callback(null, '2.7.18', ''); + } + return {} as child_process.ChildProcess; + }); + // Mock for execWhereAsync('python3', spawnEnv) + execMock.mockImplementationOnce((_command, _options, callback) => { + if (callback) { + callback(new Error('not found'), '', ''); + } + return {} as child_process.ChildProcess; + }); + // Mock for the second loop of pythonCandidates + execMock.mockImplementationOnce((_command, _options, callback) => { + if (callback) { + callback(null, '2.7.18', ''); + } + return {} as child_process.ChildProcess; + }); + + const pythonPath = await findWindowsPythonPathAsync({}); + expect(pythonPath).toBe('C:\\Python27\\python.exe'); + }); + + it('should return default python.exe if no python is found', async () => { + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(new Error('not found'), '', ''); + } + return {} as child_process.ChildProcess; + }); + const pythonPath = await findWindowsPythonPathAsync({}); + expect(pythonPath).toBe('python.exe'); + }); + }); + + describe('getSDKRootDirectoryAsync', () => { + it('should get root directory from CLOUDSDK_ROOT_DIR', async () => { + const sdkRoot = await getSDKRootDirectoryAsync({ CLOUDSDK_ROOT_DIR: 'sdk_root' }); + expect(sdkRoot).toBe(path.win32.normalize('sdk_root')); + }); + + it('should get root directory from where gcloud', async () => { + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(null, 'C:\\Program Files\\Google\\Cloud SDK\\bin\\gcloud.cmd', ''); + } + return {} as child_process.ChildProcess; + }); + const sdkRoot = await getSDKRootDirectoryAsync({}); + expect(sdkRoot).toBe(path.win32.normalize('C:\\Program Files\\Google\\Cloud SDK')); + }); + + it('should return empty string if gcloud not found', async () => { + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(new Error('not found'), '', ''); + } + return {} as child_process.ChildProcess; + }); + const sdkRoot = await getSDKRootDirectoryAsync({}); + expect(sdkRoot).toBe(''); + }); + }); + + describe('getWindowsCloudSDKSettingsAsync', () => { + it('should get settings with bundled python', async () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(null, '3.9.0', ''); + } + return {} as child_process.ChildProcess; // Return a mock ChildProcess + }); // For getPythonVersionAsync + + const settings = await getWindowsCloudSDKSettingsAsync({ + CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', + CLOUDSDK_PYTHON_SITEPACKAGES: '', // no site packages + }); + + expect(settings.cloudSdkRootDir).toBe(path.win32.normalize('C:\\CloudSDK')); + expect(settings.cloudSdkPython).toBe( + path.win32.normalize('C:\\CloudSDK\\platform\\bundledpython\\python.exe'), + ); + expect(settings.cloudSdkPythonArgsList).toStrictEqual(['-S']); // Expect -S to be added + expect(settings.noWorkingPythonFound).toBe(false); + }); + + it('should get settings with CLOUDSDK_PYTHON and site packages enabled', async () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(false); // No bundled python + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(null, '3.9.0', ''); + } + return {} as child_process.ChildProcess; // Return a mock ChildProcess + }); // For getPythonVersionAsync + const settings = await getWindowsCloudSDKSettingsAsync({ + CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', + CLOUDSDK_PYTHON: 'C:\\Python39\\python.exe', + CLOUDSDK_PYTHON_SITEPACKAGES: '1', + }); + + expect(settings.cloudSdkRootDir).toBe(path.win32.normalize('C:\\CloudSDK')); + expect(settings.cloudSdkPython).toBe('C:\\Python39\\python.exe'); + expect(settings.cloudSdkPythonArgsList).toStrictEqual([]); // Expect no -S + expect(settings.noWorkingPythonFound).toBe(false); + }); + + it('should set noWorkingPythonFound to true if python version cannot be determined', async () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(false); // No bundled python + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(new Error('whoops'), '', ''); + } + return {} as child_process.ChildProcess; + }); // getPythonVersion throws + + const settings = await getWindowsCloudSDKSettingsAsync({ + CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', + CLOUDSDK_PYTHON: 'C:\\NonExistentPython\\python.exe', + }); + + expect(settings.noWorkingPythonFound).toBe(true); + }); + + it('should handle VIRTUAL_ENV for site packages', async () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(false); + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(null, '3.9.0', ''); + } + return {} as child_process.ChildProcess; // Return a mock ChildProcess + }); // For getPythonVersionAsync + + const settings = await getWindowsCloudSDKSettingsAsync({ + CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', + CLOUDSDK_PYTHON: 'C:\\Python39\\python.exe', // Explicitly set python to avoid findWindowsPythonPath + VIRTUAL_ENV: 'C:\\MyVirtualEnv', + CLOUDSDK_PYTHON_SITEPACKAGES: undefined, // Ensure this is undefined to hit the if condition + }); + expect(settings.cloudSdkPythonArgsList).toStrictEqual([]); + }); + + it('should keep existing CLOUDSDK_PYTHON_ARGS and add -S if no site packages', async () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(null, '3.9.0', ''); + } + return {} as child_process.ChildProcess; // Return a mock ChildProcess + }); // For getPythonVersionAsync + + const settings = await getWindowsCloudSDKSettingsAsync({ + CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', + CLOUDSDK_PYTHON_ARGS: '-v', + CLOUDSDK_PYTHON_SITEPACKAGES: '', + }); + expect(settings.cloudSdkPythonArgsList).toStrictEqual(['-v', '-S']); + }); + + it('should remove -S from CLOUDSDK_PYTHON_ARGS if site packages enabled', async () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(null, '3.9.0', ''); + } + return {} as child_process.ChildProcess; + }); + + const settings = await getWindowsCloudSDKSettingsAsync({ + CLOUDSDK_ROOT_DIR: 'C:\\CloudSDK', + CLOUDSDK_PYTHON_ARGS: '-v -S', + CLOUDSDK_PYTHON_SITEPACKAGES: '1', + }); + expect(settings.cloudSdkPythonArgsList).toStrictEqual(['-v']); + }); + }); + + describe('getCloudSDKSettingsAsync', () => { + it('should return windows settings on windows', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32'); + vi.spyOn(child_process, 'exec').mockImplementation((_command, _options, callback) => { + if (callback) { + callback(null, '3.9.0', ''); + } + return {} as child_process.ChildProcess; // Return a mock ChildProcess + }); // For getPythonVersionAsync + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + + const settings = await getCloudSDKSettingsAsync(); + expect(settings.isWindowsPlatform).toBe(true); + expect(settings.windowsCloudSDKSettings).not.toBeNull(); + expect(settings.windowsCloudSDKSettings?.noWorkingPythonFound).toBe(false); + }); + + it('should not return windows settings on other platforms', async () => { + vi.spyOn(os, 'platform').mockReturnValue('linux'); + const settings = await getCloudSDKSettingsAsync(); + expect(settings.isWindowsPlatform).toBe(false); + expect(settings.windowsCloudSDKSettings).toBeNull(); + }); + }); +}); diff --git a/packages/gcloud-mcp/src/windows_gcloud_utils.ts b/packages/gcloud-mcp/src/windows_gcloud_utils.ts new file mode 100644 index 0000000..4d884cc --- /dev/null +++ b/packages/gcloud-mcp/src/windows_gcloud_utils.ts @@ -0,0 +1,221 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as child_process from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { log } from './utility/logger.js'; + +export interface WindowsCloudSDKSettings { + cloudSdkRootDir: string; + cloudSdkPython: string; + cloudSdkPythonArgsList: string[]; + noWorkingPythonFound: boolean; + /** Environment variables to use when spawning gcloud.py */ + env: { [key: string]: string | undefined }; +} + +export interface CloudSDKSettings { + isWindowsPlatform: boolean; + windowsCloudSDKSettings: WindowsCloudSDKSettings | null; +} + +export async function execWhereAsync( + command: string, + spawnEnv: { [key: string]: string | undefined }, +): Promise { + return new Promise((resolve) => { + child_process.exec( + `where ${command}`, + { + encoding: 'utf8', + env: spawnEnv, // Use updated PATH for where command + }, + (error, stdout) => { + if (error) { + resolve([]); + return; + } + const result = stdout.trim(); + resolve( + result + .split(/\r?\n/) + .filter((line) => line.length > 0) + .map((line) => path.win32.normalize(line)), + ); + }, + ); + }); +} + +export async function getPythonVersionAsync( + pythonPath: string, + spawnEnv: { [key: string]: string | undefined }, +): Promise { + return new Promise((resolve) => { + const escapedPath = pythonPath.includes(' ') ? `"${pythonPath}"` : pythonPath; + const cmd = `${escapedPath} -c "import sys; print(sys.version)"`; + child_process.exec( + cmd, + { + encoding: 'utf8', + env: spawnEnv, // Use env without PYTHONHOME + }, + (error, stdout) => { + if (error) { + resolve(undefined); + return; + } + const result = stdout.trim(); + resolve(result.split(/[\r\n]+/)[0]); + }, + ); + }); +} + +export async function findWindowsPythonPathAsync(spawnEnv: { + [key: string]: string | undefined; +}): Promise { + // Try to find a Python installation on Windows + // Try Python, python3, python2 + + const pythonCandidates = await execWhereAsync('python', spawnEnv); + if (pythonCandidates.length > 0) { + for (const candidate of pythonCandidates) { + const version = await getPythonVersionAsync(candidate, spawnEnv); + if (version && version.startsWith('3')) { + return candidate; + } + } + } + + const python3Candidates = await execWhereAsync('python3', spawnEnv); + if (python3Candidates.length > 0) { + for (const candidate of python3Candidates) { + const version = await getPythonVersionAsync(candidate, spawnEnv); + if (version && version.startsWith('3')) { + return candidate; + } + } + } + + // Try to find python2 last + if (pythonCandidates.length > 0) { + for (const candidate of pythonCandidates) { + const version = await getPythonVersionAsync(candidate, spawnEnv); + if (version && version.startsWith('2')) { + return candidate; + } + } + } + return 'python.exe'; // Fallback to default python command +} + +export async function getSDKRootDirectoryAsync(env: NodeJS.ProcessEnv): Promise { + const cloudSdkRootDir = env['CLOUDSDK_ROOT_DIR'] || ''; + if (cloudSdkRootDir) { + return path.win32.normalize(cloudSdkRootDir); + } + + // Use 'where gcloud' to find the gcloud executable on Windows + const gcloudPathOutput = (await execWhereAsync('gcloud', env))[0]; + + if (gcloudPathOutput) { + // Assuming gcloud.cmd is in /bin/gcloud.cmd + // We need to go up two levels from the gcloud.cmd path + const binDir = path.win32.dirname(gcloudPathOutput); + const sdkRoot = path.win32.dirname(binDir); + return sdkRoot; + } + + // gcloud not found in PATH, or other error + log.warn('gcloud not found in PATH. Please ensure Google Cloud SDK is installed and configured.'); + + return ''; // Return empty string if not found +} + +export async function getWindowsCloudSDKSettingsAsync( + currentEnv: NodeJS.ProcessEnv = process.env, +): Promise { + const env = { ...currentEnv }; + const cloudSdkRootDir = await getSDKRootDirectoryAsync(env); + + let cloudSdkPython = env['CLOUDSDK_PYTHON'] || ''; + // Find bundled python if no python is set in the environment. + if (!cloudSdkPython) { + const bundledPython = path.win32.join( + cloudSdkRootDir, + 'platform', + 'bundledpython', + 'python.exe', + ); + if (fs.existsSync(bundledPython)) { + cloudSdkPython = bundledPython; + } + } + // If not bundled Python is found, try to find a Python installation on windows + if (!cloudSdkPython) { + cloudSdkPython = await findWindowsPythonPathAsync(env); + } + + // Where.exe always exist in a Windows Platform + let noWorkingPythonFound = false; + // Juggling check to hit null and undefined at the same time + if (!(await getPythonVersionAsync(cloudSdkPython, env))) { + noWorkingPythonFound = true; + } + + // Check if the User has site package enabled + let cloudSdkPythonSitePackages = currentEnv['CLOUDSDK_PYTHON_SITEPACKAGES']; + if (cloudSdkPythonSitePackages === undefined) { + if (currentEnv['VIRTUAL_ENV']) { + cloudSdkPythonSitePackages = '1'; + } else { + cloudSdkPythonSitePackages = ''; + } + } else if (cloudSdkPythonSitePackages === null) { + cloudSdkPythonSitePackages = ''; + } + + let cloudSdkPythonArgs = env['CLOUDSDK_PYTHON_ARGS'] || ''; + const argsWithoutS = cloudSdkPythonArgs.replace('-S', '').trim(); + + // Spacing here matters + // When site pacakge is set, invoke without -S + // otherwise, invoke with -S + cloudSdkPythonArgs = !cloudSdkPythonSitePackages + ? `${argsWithoutS}${argsWithoutS ? ' ' : ''}-S` + : argsWithoutS; + + const cloudSdkPythonArgsList = cloudSdkPythonArgs ? cloudSdkPythonArgs.split(' ') : []; + + return { + cloudSdkRootDir, + cloudSdkPython, + cloudSdkPythonArgsList, + noWorkingPythonFound, + env, + }; +} + +export async function getCloudSDKSettingsAsync(): Promise { + const isWindowsPlatform = os.platform() === 'win32'; + return { + isWindowsPlatform, + windowsCloudSDKSettings: isWindowsPlatform ? await getWindowsCloudSDKSettingsAsync() : null, + }; +} diff --git a/packages/gcloud-mcp/vitest.config.integration.ts b/packages/gcloud-mcp/vitest.config.integration.ts new file mode 100644 index 0000000..49c6047 --- /dev/null +++ b/packages/gcloud-mcp/vitest.config.integration.ts @@ -0,0 +1,46 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/// +import { defineConfig } from 'vitest/config'; + +// eslint-disable-next-line import/no-default-export +export default defineConfig({ + test: { + include: ['**/src/**/*.integration.test.ts', '**/src/**/*.integration.test.js'], + exclude: ['**/node_modules/**', '**/dist/**'], + environment: 'node', + globals: true, + reporters: ['default', 'junit'], + silent: true, + outputFile: { junit: 'junit.integration.xml' }, + coverage: { + enabled: true, + provider: 'v8', + reportsDirectory: './coverage', + include: ['**/src/**/*'], + reporter: [ + 'cobertura', + 'html', + 'json', + ['json-summary', { outputFile: 'coverage-summary.integration.json' }], + 'lcov', + 'text', + ['text', { file: 'full-text-summary.integration.txt' }], + ], + }, + }, +}); diff --git a/packages/gcloud-mcp/vitest.config.ts b/packages/gcloud-mcp/vitest.config.ts index 222a085..5e12a76 100644 --- a/packages/gcloud-mcp/vitest.config.ts +++ b/packages/gcloud-mcp/vitest.config.ts @@ -21,7 +21,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { include: ['**/src/**/*.test.ts', '**/src/**/*.test.js'], - exclude: ['**/node_modules/**', '**/dist/**'], + exclude: ['**/node_modules/**', '**/dist/**', '**/src/**/*.integration.test.ts'], environment: 'node', globals: true, reporters: ['default', 'junit'], diff --git a/vitest.config.ts b/vitest.config.ts index 222a085..5e12a76 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,7 +21,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { include: ['**/src/**/*.test.ts', '**/src/**/*.test.js'], - exclude: ['**/node_modules/**', '**/dist/**'], + exclude: ['**/node_modules/**', '**/dist/**', '**/src/**/*.integration.test.ts'], environment: 'node', globals: true, reporters: ['default', 'junit'],