diff --git a/integration-tests/json-output.test.ts b/integration-tests/json-output.test.ts index 151780ece8..3b77b2d935 100644 --- a/integration-tests/json-output.test.ts +++ b/integration-tests/json-output.test.ts @@ -7,6 +7,7 @@ import { expect, describe, it, beforeEach, afterEach } from 'vitest'; import { TestRig } from './test-helper.js'; import { join } from 'node:path'; +import { ExitCodes } from '@google/gemini-cli-core/src/index.js'; describe('JSON output', () => { let rig: TestRig; @@ -81,7 +82,7 @@ describe('JSON output', () => { expect(payload.error).toBeDefined(); expect(payload.error.type).toBe('Error'); - expect(payload.error.code).toBe(1); + expect(payload.error.code).toBe(ExitCodes.FATAL_AUTHENTICATION_ERROR); expect(payload.error.message).toContain( "enforced authentication type is 'gemini-api-key'", ); diff --git a/integration-tests/mixed-input-crash.test.ts b/integration-tests/mixed-input-crash.test.ts index e2db647316..f37da9af51 100644 --- a/integration-tests/mixed-input-crash.test.ts +++ b/integration-tests/mixed-input-crash.test.ts @@ -27,7 +27,7 @@ describe('mixed input crash prevention', () => { expect(error).toBeInstanceOf(Error); const err = error as Error; - expect(err.message).toContain('Process exited with code 1'); + expect(err.message).toContain('Process exited with code 42'); expect(err.message).toContain( '--prompt-interactive flag cannot be used when input is piped', ); diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 8603a79834..5366a4ef70 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -22,6 +22,7 @@ import { } from './gemini.js'; import os from 'node:os'; import v8 from 'node:v8'; +import { type CliArgs } from './config/config.js'; import { type LoadedSettings } from './config/settings.js'; import { appEvents, AppEvent } from './utils/events.js'; import { @@ -210,13 +211,11 @@ describe('gemini.tsx main function', () => { } const currentListeners = process.listeners('unhandledRejection'); - const addedListener = currentListeners.find( - (listener) => !initialUnhandledRejectionListeners.includes(listener), - ); - - if (addedListener) { - process.removeListener('unhandledRejection', addedListener); - } + currentListeners.forEach((listener) => { + if (!initialUnhandledRejectionListeners.includes(listener)) { + process.removeListener('unhandledRejection', listener); + } + }); vi.restoreAllMocks(); }); @@ -698,64 +697,6 @@ describe('gemini.tsx main function kitty protocol', () => { processExitSpy.mockRestore(); }); - it('should exit with error when --prompt-interactive is used with piped input', async () => { - const { loadCliConfig, parseArguments } = await import( - './config/config.js' - ); - const { loadSettings } = await import('./config/settings.js'); - const core = await import('@google/gemini-cli-core'); - const processExitSpy = vi - .spyOn(process, 'exit') - .mockImplementation((code) => { - throw new MockProcessExitError(code); - }); - const writeToStderrSpy = vi - .spyOn(core, 'writeToStderr') - .mockImplementation(() => true); - - vi.mocked(loadSettings).mockReturnValue({ - merged: { advanced: {}, security: { auth: {} }, ui: {} }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - errors: [], - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - vi.mocked(parseArguments).mockResolvedValue({ - promptInteractive: true, - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - vi.mocked(loadCliConfig).mockResolvedValue({ - isInteractive: () => false, - getQuestion: () => '', - getSandbox: () => false, - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - // Mock stdin to be non-TTY - Object.defineProperty(process.stdin, 'isTTY', { - value: false, - configurable: true, - }); - - try { - await main(); - } catch (e) { - if (!(e instanceof MockProcessExitError)) throw e; - } - - expect(writeToStderrSpy).toHaveBeenCalledWith( - expect.stringContaining( - 'Error: The --prompt-interactive flag cannot be used', - ), - ); - expect(processExitSpy).toHaveBeenCalledWith(1); - processExitSpy.mockRestore(); - writeToStderrSpy.mockRestore(); - Object.defineProperty(process.stdin, 'isTTY', { - value: true, - configurable: true, - }); // Restore TTY - }); - it('should log warning when theme is not found', async () => { const { loadCliConfig, parseArguments } = await import( './config/config.js' @@ -836,13 +777,15 @@ describe('gemini.tsx main function kitty protocol', () => { './config/config.js' ); const { loadSettings } = await import('./config/settings.js'); - vi.mock('./utils/sessionUtils.js', () => ({ - SessionSelector: class { - resolveSession = vi - .fn() - .mockRejectedValue(new Error('Session not found')); - }, - })); + const { SessionSelector } = await import('./utils/sessionUtils.js'); + vi.mocked(SessionSelector).mockImplementation( + () => + ({ + resolveSession: vi + .fn() + .mockRejectedValue(new Error('Session not found')), + }) as any, // eslint-disable-line @typescript-eslint/no-explicit-any + ); const processExitSpy = vi .spyOn(process, 'exit') @@ -905,7 +848,7 @@ describe('gemini.tsx main function kitty protocol', () => { expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining('Error resuming session: Session not found'), ); - expect(processExitSpy).toHaveBeenCalledWith(1); + expect(processExitSpy).toHaveBeenCalledWith(42); processExitSpy.mockRestore(); consoleErrorSpy.mockRestore(); }); @@ -989,83 +932,6 @@ describe('gemini.tsx main function kitty protocol', () => { processExitSpy.mockRestore(); }); - it('should handle refreshAuth failure', async () => { - const { loadCliConfig, parseArguments } = await import( - './config/config.js' - ); - const { loadSettings } = await import('./config/settings.js'); - const { loadSandboxConfig } = await import('./config/sandboxConfig.js'); - const processExitSpy = vi - .spyOn(process, 'exit') - .mockImplementation((code) => { - throw new MockProcessExitError(code); - }); - const debugLoggerErrorSpy = vi - .spyOn(debugLogger, 'error') - .mockImplementation(() => {}); - - vi.mocked(loadSettings).mockReturnValue({ - merged: { - advanced: {}, - security: { auth: { selectedType: 'google' } }, - ui: {}, - }, - setValue: vi.fn(), - forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), - errors: [], - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - vi.mocked(loadSandboxConfig).mockResolvedValue({} as any); // eslint-disable-line @typescript-eslint/no-explicit-any - vi.mocked(parseArguments).mockResolvedValue({ - promptInteractive: false, - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any - vi.mocked(loadCliConfig).mockResolvedValue({ - isInteractive: () => true, - getQuestion: () => '', - getSandbox: () => false, - getDebugMode: () => false, - getPolicyEngine: vi.fn(), - getMessageBus: () => ({ subscribe: vi.fn() }), - initialize: vi.fn(), - getContentGeneratorConfig: vi.fn(), - getMcpServers: () => ({}), - getMcpClientManager: vi.fn(), - getIdeMode: () => false, - getExperimentalZedIntegration: () => false, - getScreenReader: () => false, - getGeminiMdFileCount: () => 0, - getProjectRoot: () => '/', - getListExtensions: () => false, - getListSessions: () => false, - getDeleteSession: () => undefined, - getToolRegistry: vi.fn(), - getExtensions: () => [], - getModel: () => 'gemini-pro', - getEmbeddingModel: () => 'embedding-001', - getApprovalMode: () => 'default', - getCoreTools: () => [], - getTelemetryEnabled: () => false, - getTelemetryLogPromptsEnabled: () => false, - getFileFilteringRespectGitIgnore: () => true, - getOutputFormat: () => 'text', - getUsageStatisticsEnabled: () => false, - refreshAuth: vi.fn().mockRejectedValue(new Error('Auth refresh failed')), - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - try { - await main(); - } catch (e) { - if (!(e instanceof MockProcessExitError)) throw e; - } - - expect(debugLoggerErrorSpy).toHaveBeenCalledWith( - 'Error authenticating:', - expect.any(Error), - ); - expect(processExitSpy).toHaveBeenCalledWith(1); - processExitSpy.mockRestore(); - }); - it('should read from stdin in non-interactive mode', async () => { const { loadCliConfig, parseArguments } = await import( './config/config.js' @@ -1160,6 +1026,204 @@ describe('gemini.tsx main function kitty protocol', () => { }); }); +describe('gemini.tsx main function exit codes', () => { + let originalEnvNoRelaunch: string | undefined; + + beforeEach(() => { + originalEnvNoRelaunch = process.env['GEMINI_CLI_NO_RELAUNCH']; + process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true'; + vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + // Mock stderr to avoid cluttering output + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + if (originalEnvNoRelaunch !== undefined) { + process.env['GEMINI_CLI_NO_RELAUNCH'] = originalEnvNoRelaunch; + } else { + delete process.env['GEMINI_CLI_NO_RELAUNCH']; + } + vi.restoreAllMocks(); + }); + + it('should exit with 42 for invalid input combination (prompt-interactive with non-TTY)', async () => { + const { loadCliConfig, parseArguments } = await import( + './config/config.js' + ); + const { loadSettings } = await import('./config/settings.js'); + vi.mocked(loadCliConfig).mockResolvedValue({} as Config); + vi.mocked(loadSettings).mockReturnValue({ + merged: { security: { auth: {} }, ui: {} }, + errors: [], + } as never); + vi.mocked(parseArguments).mockResolvedValue({ + promptInteractive: true, + } as unknown as CliArgs); + Object.defineProperty(process.stdin, 'isTTY', { + value: false, + configurable: true, + }); + + try { + await main(); + expect.fail('Should have thrown MockProcessExitError'); + } catch (e) { + expect(e).toBeInstanceOf(MockProcessExitError); + expect((e as MockProcessExitError).code).toBe(42); + } + }); + + it('should exit with 41 for auth failure during sandbox setup', async () => { + const { loadCliConfig, parseArguments } = await import( + './config/config.js' + ); + const { loadSettings } = await import('./config/settings.js'); + const { loadSandboxConfig } = await import('./config/sandboxConfig.js'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(loadSandboxConfig).mockResolvedValue({} as any); + vi.mocked(loadCliConfig).mockResolvedValue({ + refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')), + } as unknown as Config); + vi.mocked(loadSettings).mockReturnValue({ + merged: { + security: { auth: { selectedType: 'google', useExternal: false } }, + ui: {}, + }, + errors: [], + } as never); + vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs); + vi.mock('./config/auth.js', () => ({ + validateAuthMethod: vi.fn().mockReturnValue(null), + })); + + try { + await main(); + expect.fail('Should have thrown MockProcessExitError'); + } catch (e) { + expect(e).toBeInstanceOf(MockProcessExitError); + expect((e as MockProcessExitError).code).toBe(41); + } + }); + + it('should exit with 42 for session resume failure', async () => { + const { loadCliConfig, parseArguments } = await import( + './config/config.js' + ); + const { loadSettings } = await import('./config/settings.js'); + + vi.mocked(loadCliConfig).mockResolvedValue({ + isInteractive: () => false, + getQuestion: () => 'test', + getSandbox: () => false, + getDebugMode: () => false, + getListExtensions: () => false, + getListSessions: () => false, + getDeleteSession: () => undefined, + getMcpServers: () => ({}), + getMcpClientManager: vi.fn(), + initialize: vi.fn(), + getIdeMode: () => false, + getExperimentalZedIntegration: () => false, + getScreenReader: () => false, + getGeminiMdFileCount: () => 0, + getPolicyEngine: vi.fn(), + getMessageBus: () => ({ subscribe: vi.fn() }), + getToolRegistry: vi.fn(), + getContentGeneratorConfig: vi.fn(), + getModel: () => 'gemini-pro', + getEmbeddingModel: () => 'embedding-001', + getApprovalMode: () => 'default', + getCoreTools: () => [], + getTelemetryEnabled: () => false, + getTelemetryLogPromptsEnabled: () => false, + getFileFilteringRespectGitIgnore: () => true, + getOutputFormat: () => 'text', + getExtensions: () => [], + getUsageStatisticsEnabled: () => false, + } as unknown as Config); + vi.mocked(loadSettings).mockReturnValue({ + merged: { security: { auth: {} }, ui: {} }, + errors: [], + } as never); + vi.mocked(parseArguments).mockResolvedValue({ + resume: 'invalid-session', + } as unknown as CliArgs); + + vi.mock('./utils/sessionUtils.js', () => ({ + SessionSelector: vi.fn().mockImplementation(() => ({ + resolveSession: vi + .fn() + .mockRejectedValue(new Error('Session not found')), + })), + })); + + try { + await main(); + expect.fail('Should have thrown MockProcessExitError'); + } catch (e) { + expect(e).toBeInstanceOf(MockProcessExitError); + expect((e as MockProcessExitError).code).toBe(42); + } + }); + + it('should exit with 42 for no input provided', async () => { + const { loadCliConfig, parseArguments } = await import( + './config/config.js' + ); + const { loadSettings } = await import('./config/settings.js'); + + vi.mocked(loadCliConfig).mockResolvedValue({ + isInteractive: () => false, + getQuestion: () => '', + getSandbox: () => false, + getDebugMode: () => false, + getListExtensions: () => false, + getListSessions: () => false, + getDeleteSession: () => undefined, + getMcpServers: () => ({}), + getMcpClientManager: vi.fn(), + initialize: vi.fn(), + getIdeMode: () => false, + getExperimentalZedIntegration: () => false, + getScreenReader: () => false, + getGeminiMdFileCount: () => 0, + getPolicyEngine: vi.fn(), + getMessageBus: () => ({ subscribe: vi.fn() }), + getToolRegistry: vi.fn(), + getContentGeneratorConfig: vi.fn(), + getModel: () => 'gemini-pro', + getEmbeddingModel: () => 'embedding-001', + getApprovalMode: () => 'default', + getCoreTools: () => [], + getTelemetryEnabled: () => false, + getTelemetryLogPromptsEnabled: () => false, + getFileFilteringRespectGitIgnore: () => true, + getOutputFormat: () => 'text', + getExtensions: () => [], + getUsageStatisticsEnabled: () => false, + } as unknown as Config); + vi.mocked(loadSettings).mockReturnValue({ + merged: { security: { auth: {} }, ui: {} }, + errors: [], + } as never); + vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs); + Object.defineProperty(process.stdin, 'isTTY', { + value: true, // Simulate TTY so it doesn't try to read stdin + configurable: true, + }); + + try { + await main(); + expect.fail('Should have thrown MockProcessExitError'); + } catch (e) { + expect(e).toBeInstanceOf(MockProcessExitError); + expect((e as MockProcessExitError).code).toBe(42); + } + }); +}); + describe('validateDnsResolutionOrder', () => { let debugLoggerWarnSpy: ReturnType; @@ -1278,7 +1342,6 @@ describe('startInteractiveUI', () => { ); // Verify render was called with correct options - expect(renderSpy).toHaveBeenCalledTimes(1); const [reactElement, options] = renderSpy.mock.calls[0]; // Verify render options diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 4ad951f676..e61f3ff364 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -56,6 +56,7 @@ import { enterAlternateScreen, disableLineWrapping, shouldEnterAlternateScreen, + ExitCodes, } from '@google/gemini-cli-core'; import { initializeApp, @@ -310,7 +311,7 @@ export async function main() { 'Error: The --prompt-interactive flag cannot be used when input is piped from stdin.\n', ); await runExitCleanup(); - process.exit(1); + process.exit(ExitCodes.FATAL_INPUT_ERROR); } const isDebugMode = cliConfig.isDebugMode(argv); @@ -396,7 +397,7 @@ export async function main() { } catch (err) { debugLogger.error('Error authenticating:', err); await runExitCleanup(); - process.exit(1); + process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR); } } let stdinData = ''; @@ -433,7 +434,7 @@ export async function main() { start_sandbox(sandboxConfig, memoryArgs, partialConfig, sandboxArgs), ); await runExitCleanup(); - process.exit(0); + process.exit(ExitCodes.SUCCESS); } else { // Relaunch app so we always have a child process that can be internally // restarted if needed. @@ -464,14 +465,14 @@ export async function main() { debugLogger.log(`- ${extension.name}`); } await runExitCleanup(); - process.exit(0); + process.exit(ExitCodes.SUCCESS); } // Handle --list-sessions flag if (config.getListSessions()) { await listSessions(config); await runExitCleanup(); - process.exit(0); + process.exit(ExitCodes.SUCCESS); } // Handle --delete-session flag @@ -479,7 +480,7 @@ export async function main() { if (sessionToDelete) { await deleteSession(config, sessionToDelete); await runExitCleanup(); - process.exit(0); + process.exit(ExitCodes.SUCCESS); } const wasRaw = process.stdin.isRaw; @@ -551,7 +552,7 @@ export async function main() { `Error resuming session: ${error instanceof Error ? error.message : 'Unknown error'}`, ); await runExitCleanup(); - process.exit(1); + process.exit(ExitCodes.FATAL_INPUT_ERROR); } } @@ -583,7 +584,7 @@ export async function main() { `No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`, ); await runExitCleanup(); - process.exit(1); + process.exit(ExitCodes.FATAL_INPUT_ERROR); } const prompt_id = Math.random().toString(16).slice(2); @@ -623,7 +624,7 @@ export async function main() { }); // Call cleanup before process.exit, which causes cleanup to not run await runExitCleanup(); - process.exit(0); + process.exit(ExitCodes.SUCCESS); } } diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index c52da8bd9d..2ba6723a5f 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -9,6 +9,7 @@ import { waitFor } from '../../test-utils/async.js'; import { act } from 'react'; import { vi } from 'vitest'; import { FolderTrustDialog } from './FolderTrustDialog.js'; +import { ExitCodes } from '@google/gemini-cli-core'; import * as processUtils from '../../utils/processUtils.js'; vi.mock('../../utils/processUtils.js', () => ({ @@ -61,7 +62,9 @@ describe('FolderTrustDialog', () => { ); }); await waitFor(() => { - expect(mockedExit).toHaveBeenCalledWith(1); + expect(mockedExit).toHaveBeenCalledWith( + ExitCodes.FATAL_CANCELLATION_ERROR, + ); }); expect(onSelect).not.toHaveBeenCalled(); }); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.tsx b/packages/cli/src/ui/components/FolderTrustDialog.tsx index 197f48548f..f1b1265c4c 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.tsx @@ -14,6 +14,8 @@ import { useKeypress } from '../hooks/useKeypress.js'; import * as process from 'node:process'; import * as path from 'node:path'; import { relaunchApp } from '../../utils/processUtils.js'; +import { runExitCleanup } from '../../utils/cleanup.js'; +import { ExitCodes } from '@google/gemini-cli-core'; export enum FolderTrustChoice { TRUST_FOLDER = 'trust_folder', @@ -46,8 +48,9 @@ export const FolderTrustDialog: React.FC = ({ (key) => { if (key.name === 'escape') { setExiting(true); - setTimeout(() => { - process.exit(1); + setTimeout(async () => { + await runExitCleanup(); + process.exit(ExitCodes.FATAL_CANCELLATION_ERROR); }, 100); } }, diff --git a/packages/cli/src/ui/hooks/useFolderTrust.test.ts b/packages/cli/src/ui/hooks/useFolderTrust.test.ts index ecfccf1f64..ed39b04562 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.test.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.test.ts @@ -14,7 +14,7 @@ import { FolderTrustChoice } from '../components/FolderTrustDialog.js'; import type { LoadedTrustedFolders } from '../../config/trustedFolders.js'; import { TrustLevel } from '../../config/trustedFolders.js'; import * as trustedFolders from '../../config/trustedFolders.js'; -import { coreEvents } from '@google/gemini-cli-core'; +import { coreEvents, ExitCodes } from '@google/gemini-cli-core'; const mockedCwd = vi.hoisted(() => vi.fn()); const mockedExit = vi.hoisted(() => vi.fn()); @@ -266,7 +266,7 @@ describe('useFolderTrust', () => { expect(result.current.isFolderTrustDialogOpen).toBe(false); // Dialog should close }); - it('should emit feedback on failure to set value', () => { + it('should emit feedback on failure to set value', async () => { isWorkspaceTrustedSpy.mockReturnValue({ isTrusted: undefined, source: undefined, @@ -283,12 +283,12 @@ describe('useFolderTrust', () => { result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER); }); - vi.runAllTimers(); + await vi.runAllTimersAsync(); expect(emitFeedbackSpy).toHaveBeenCalledWith( 'error', 'Failed to save trust settings. Exiting Gemini CLI.', ); - expect(mockedExit).toHaveBeenCalledWith(1); + expect(mockedExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR); }); }); diff --git a/packages/cli/src/ui/hooks/useFolderTrust.ts b/packages/cli/src/ui/hooks/useFolderTrust.ts index 079d727b78..aae18e4452 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.ts @@ -14,7 +14,8 @@ import { } from '../../config/trustedFolders.js'; import * as process from 'node:process'; import { type HistoryItemWithoutId, MessageType } from '../types.js'; -import { coreEvents } from '@google/gemini-cli-core'; +import { coreEvents, ExitCodes } from '@google/gemini-cli-core'; +import { runExitCleanup } from '../../utils/cleanup.js'; export const useFolderTrust = ( settings: LoadedSettings, @@ -75,8 +76,9 @@ export const useFolderTrust = ( 'error', 'Failed to save trust settings. Exiting Gemini CLI.', ); - setTimeout(() => { - process.exit(1); + setTimeout(async () => { + await runExitCleanup(); + process.exit(ExitCodes.FATAL_CONFIG_ERROR); }, 100); return; } diff --git a/packages/cli/src/validateNonInterActiveAuth.test.ts b/packages/cli/src/validateNonInterActiveAuth.test.ts index a6e6def955..be5f4a14a2 100644 --- a/packages/cli/src/validateNonInterActiveAuth.test.ts +++ b/packages/cli/src/validateNonInterActiveAuth.test.ts @@ -20,6 +20,7 @@ import { OutputFormat, makeFakeConfig, debugLogger, + ExitCodes, } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core'; import * as auth from './config/auth.js'; @@ -116,12 +117,16 @@ describe('validateNonInterActiveAuth', () => { ); expect.fail('Should have exited'); } catch (e) { - expect((e as Error).message).toContain('process.exit(1) called'); + expect((e as Error).message).toContain( + `process.exit(${ExitCodes.FATAL_AUTHENTICATION_ERROR}) called`, + ); } expect(debugLoggerErrorSpy).toHaveBeenCalledWith( expect.stringContaining('Please set an Auth method'), ); - expect(processExitSpy).toHaveBeenCalledWith(1); + expect(processExitSpy).toHaveBeenCalledWith( + ExitCodes.FATAL_AUTHENTICATION_ERROR, + ); }); it('uses LOGIN_WITH_GOOGLE if GOOGLE_GENAI_USE_GCA is set', async () => { @@ -268,10 +273,14 @@ describe('validateNonInterActiveAuth', () => { ); expect.fail('Should have exited'); } catch (e) { - expect((e as Error).message).toContain('process.exit(1) called'); + expect((e as Error).message).toContain( + `process.exit(${ExitCodes.FATAL_AUTHENTICATION_ERROR}) called`, + ); } expect(debugLoggerErrorSpy).toHaveBeenCalledWith('Auth error!'); - expect(processExitSpy).toHaveBeenCalledWith(1); + expect(processExitSpy).toHaveBeenCalledWith( + ExitCodes.FATAL_AUTHENTICATION_ERROR, + ); }); it('skips validation if useExternalAuth is true', async () => { @@ -329,12 +338,16 @@ describe('validateNonInterActiveAuth', () => { ); expect.fail('Should have exited'); } catch (e) { - expect((e as Error).message).toContain('process.exit(1) called'); + expect((e as Error).message).toContain( + `process.exit(${ExitCodes.FATAL_AUTHENTICATION_ERROR}) called`, + ); } expect(debugLoggerErrorSpy).toHaveBeenCalledWith( "The enforced authentication type is 'oauth-personal', but the current type is 'gemini-api-key'. Please re-authenticate with the correct type.", ); - expect(processExitSpy).toHaveBeenCalledWith(1); + expect(processExitSpy).toHaveBeenCalledWith( + ExitCodes.FATAL_AUTHENTICATION_ERROR, + ); }); it('exits if auth from env var does not match enforcedAuthType', async () => { @@ -354,16 +367,20 @@ describe('validateNonInterActiveAuth', () => { ); expect.fail('Should have exited'); } catch (e) { - expect((e as Error).message).toContain('process.exit(1) called'); + expect((e as Error).message).toContain( + `process.exit(${ExitCodes.FATAL_AUTHENTICATION_ERROR}) called`, + ); } expect(debugLoggerErrorSpy).toHaveBeenCalledWith( "The enforced authentication type is 'oauth-personal', but the current type is 'gemini-api-key'. Please re-authenticate with the correct type.", ); - expect(processExitSpy).toHaveBeenCalledWith(1); + expect(processExitSpy).toHaveBeenCalledWith( + ExitCodes.FATAL_AUTHENTICATION_ERROR, + ); }); describe('JSON output mode', () => { - it('prints JSON error when no auth is configured and exits with code 1', async () => { + it(`prints JSON error when no auth is configured and exits with code ${ExitCodes.FATAL_AUTHENTICATION_ERROR}`, async () => { const nonInteractiveConfig = createLocalMockConfig({ refreshAuth: refreshAuthMock, getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON), @@ -384,17 +401,19 @@ describe('validateNonInterActiveAuth', () => { thrown = e as Error; } - expect(thrown?.message).toBe('process.exit(1) called'); + expect(thrown?.message).toBe( + `process.exit(${ExitCodes.FATAL_AUTHENTICATION_ERROR}) called`, + ); const errorArg = consoleErrorSpy.mock.calls[0]?.[0] as string; const payload = JSON.parse(errorArg); expect(payload.error.type).toBe('Error'); - expect(payload.error.code).toBe(1); + expect(payload.error.code).toBe(ExitCodes.FATAL_AUTHENTICATION_ERROR); expect(payload.error.message).toContain( 'Please set an Auth method in your', ); }); - it('prints JSON error when enforced auth mismatches current auth and exits with code 1', async () => { + it(`prints JSON error when enforced auth mismatches current auth and exits with code ${ExitCodes.FATAL_AUTHENTICATION_ERROR}`, async () => { mockSettings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI; const nonInteractiveConfig = createLocalMockConfig({ refreshAuth: refreshAuthMock, @@ -416,19 +435,21 @@ describe('validateNonInterActiveAuth', () => { thrown = e as Error; } - expect(thrown?.message).toBe('process.exit(1) called'); + expect(thrown?.message).toBe( + `process.exit(${ExitCodes.FATAL_AUTHENTICATION_ERROR}) called`, + ); { const errorArg = consoleErrorSpy.mock.calls[0]?.[0] as string; const payload = JSON.parse(errorArg); expect(payload.error.type).toBe('Error'); - expect(payload.error.code).toBe(1); + expect(payload.error.code).toBe(ExitCodes.FATAL_AUTHENTICATION_ERROR); expect(payload.error.message).toContain( "The enforced authentication type is 'gemini-api-key', but the current type is 'oauth-personal'. Please re-authenticate with the correct type.", ); } }); - it('prints JSON error when validateAuthMethod fails and exits with code 1', async () => { + it(`prints JSON error when validateAuthMethod fails and exits with code ${ExitCodes.FATAL_AUTHENTICATION_ERROR}`, async () => { vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!'); process.env['GEMINI_API_KEY'] = 'fake-key'; @@ -452,12 +473,14 @@ describe('validateNonInterActiveAuth', () => { thrown = e as Error; } - expect(thrown?.message).toBe('process.exit(1) called'); + expect(thrown?.message).toBe( + `process.exit(${ExitCodes.FATAL_AUTHENTICATION_ERROR}) called`, + ); { const errorArg = consoleErrorSpy.mock.calls[0]?.[0] as string; const payload = JSON.parse(errorArg); expect(payload.error.type).toBe('Error'); - expect(payload.error.code).toBe(1); + expect(payload.error.code).toBe(ExitCodes.FATAL_AUTHENTICATION_ERROR); expect(payload.error.message).toBe('Auth error!'); } }); diff --git a/packages/cli/src/validateNonInterActiveAuth.ts b/packages/cli/src/validateNonInterActiveAuth.ts index 68801224a2..bd452310d7 100644 --- a/packages/cli/src/validateNonInterActiveAuth.ts +++ b/packages/cli/src/validateNonInterActiveAuth.ts @@ -5,7 +5,12 @@ */ import type { Config } from '@google/gemini-cli-core'; -import { AuthType, debugLogger, OutputFormat } from '@google/gemini-cli-core'; +import { + AuthType, + debugLogger, + OutputFormat, + ExitCodes, +} from '@google/gemini-cli-core'; import { USER_SETTINGS_PATH } from './config/settings.js'; import { validateAuthMethod } from './config/auth.js'; import { type LoadedSettings } from './config/settings.js'; @@ -63,12 +68,12 @@ export async function validateNonInteractiveAuth( handleError( error instanceof Error ? error : new Error(String(error)), nonInteractiveConfig, - 1, + ExitCodes.FATAL_AUTHENTICATION_ERROR, ); } else { debugLogger.error(error instanceof Error ? error.message : String(error)); await runExitCleanup(); - process.exit(1); + process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR); } } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 311410f34d..d3c711473a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -47,6 +47,7 @@ export * from './core/apiKeyCredentialStorage.js'; export * from './utils/paths.js'; export * from './utils/schemaValidator.js'; export * from './utils/errors.js'; +export * from './utils/exitCodes.js'; export * from './utils/getFolderStructure.js'; export * from './utils/memoryDiscovery.js'; export * from './utils/getPty.js'; diff --git a/packages/core/src/utils/exitCodes.ts b/packages/core/src/utils/exitCodes.ts new file mode 100644 index 0000000000..f7697a8d17 --- /dev/null +++ b/packages/core/src/utils/exitCodes.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export const ExitCodes = { + SUCCESS: 0, + FATAL_AUTHENTICATION_ERROR: 41, + FATAL_INPUT_ERROR: 42, + FATAL_CONFIG_ERROR: 52, + FATAL_CANCELLATION_ERROR: 130, +} as const;