mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 13:11:03 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce18470921 | |||
| f8dbc8084a | |||
| 115f8bce8b |
@@ -28,6 +28,7 @@ import {
|
|||||||
type MergedSettings,
|
type MergedSettings,
|
||||||
createTestMergedSettings,
|
createTestMergedSettings,
|
||||||
} from './settings.js';
|
} from './settings.js';
|
||||||
|
import * as SettingsModule from './settings.js';
|
||||||
import * as ServerConfig from '@google/gemini-cli-core';
|
import * as ServerConfig from '@google/gemini-cli-core';
|
||||||
|
|
||||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||||
@@ -813,7 +814,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should pass extension context file paths to loadServerHierarchicalMemory', async () => {
|
it('should pass extension context file paths to loadServerHierarchicalMemory', async () => {
|
||||||
process.argv = ['node', 'script.js'];
|
process.argv = ['node', 'script.js', '--prompt', 'test'];
|
||||||
const settings = createTestMergedSettings();
|
const settings = createTestMergedSettings();
|
||||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
|
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
|
||||||
{
|
{
|
||||||
@@ -862,7 +863,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is true', async () => {
|
it('should pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is true', async () => {
|
||||||
process.argv = ['node', 'script.js'];
|
process.argv = ['node', 'script.js', '--prompt', 'test'];
|
||||||
const includeDir = path.resolve(path.sep, 'path', 'to', 'include');
|
const includeDir = path.resolve(path.sep, 'path', 'to', 'include');
|
||||||
const settings = createTestMergedSettings({
|
const settings = createTestMergedSettings({
|
||||||
context: {
|
context: {
|
||||||
@@ -890,7 +891,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is false', async () => {
|
it('should NOT pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is false', async () => {
|
||||||
process.argv = ['node', 'script.js'];
|
process.argv = ['node', 'script.js', '--prompt', 'test'];
|
||||||
const settings = createTestMergedSettings({
|
const settings = createTestMergedSettings({
|
||||||
context: {
|
context: {
|
||||||
includeDirectories: ['/path/to/include'],
|
includeDirectories: ['/path/to/include'],
|
||||||
@@ -915,6 +916,39 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
|||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should skip extension, memory, and PTY discovery in bootstrap mode', async () => {
|
||||||
|
process.argv = ['node', 'script.js'];
|
||||||
|
const settings = createTestMergedSettings();
|
||||||
|
const argv = await parseArguments(settings);
|
||||||
|
const loadSettingsSpy = vi.spyOn(SettingsModule, 'loadSettings');
|
||||||
|
const getPtySpy = vi.spyOn(ServerConfig, 'getPty');
|
||||||
|
|
||||||
|
await loadCliConfig(settings, 'session-id', argv, {
|
||||||
|
mode: 'bootstrap',
|
||||||
|
loadedSettings: { merged: settings } as SettingsModule.LoadedSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(loadSettingsSpy).not.toHaveBeenCalled();
|
||||||
|
expect(ExtensionManager.prototype.loadExtensions).not.toHaveBeenCalled();
|
||||||
|
expect(ServerConfig.loadServerHierarchicalMemory).not.toHaveBeenCalled();
|
||||||
|
expect(getPtySpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should defer extension and memory discovery during interactive startup', async () => {
|
||||||
|
process.argv = ['node', 'script.js'];
|
||||||
|
const settings = createTestMergedSettings();
|
||||||
|
const argv = await parseArguments(settings);
|
||||||
|
const getPtySpy = vi.spyOn(ServerConfig, 'getPty');
|
||||||
|
|
||||||
|
await loadCliConfig(settings, 'session-id', argv, {
|
||||||
|
loadedSettings: { merged: settings } as SettingsModule.LoadedSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ExtensionManager.prototype.loadExtensions).not.toHaveBeenCalled();
|
||||||
|
expect(ServerConfig.loadServerHierarchicalMemory).not.toHaveBeenCalled();
|
||||||
|
expect(getPtySpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('mergeMcpServers', () => {
|
describe('mergeMcpServers', () => {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
resolveToRealPath,
|
resolveToRealPath,
|
||||||
applyAdminAllowlist,
|
applyAdminAllowlist,
|
||||||
getAdminBlockedMcpServersMessage,
|
getAdminBlockedMcpServersMessage,
|
||||||
|
startupProfiler,
|
||||||
type HookDefinition,
|
type HookDefinition,
|
||||||
type HookEventName,
|
type HookEventName,
|
||||||
type OutputFormat,
|
type OutputFormat,
|
||||||
@@ -47,6 +48,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
type Settings,
|
type Settings,
|
||||||
type MergedSettings,
|
type MergedSettings,
|
||||||
|
type LoadedSettings,
|
||||||
saveModelChange,
|
saveModelChange,
|
||||||
loadSettings,
|
loadSettings,
|
||||||
} from './settings.js';
|
} from './settings.js';
|
||||||
@@ -417,6 +419,8 @@ export function isDebugMode(argv: CliArgs): boolean {
|
|||||||
|
|
||||||
export interface LoadCliConfigOptions {
|
export interface LoadCliConfigOptions {
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
mode?: 'bootstrap' | 'full';
|
||||||
|
loadedSettings?: LoadedSettings;
|
||||||
projectHooks?: { [K in HookEventName]?: HookDefinition[] } & {
|
projectHooks?: { [K in HookEventName]?: HookDefinition[] } & {
|
||||||
disabled?: string[];
|
disabled?: string[];
|
||||||
};
|
};
|
||||||
@@ -428,10 +432,15 @@ export async function loadCliConfig(
|
|||||||
argv: CliArgs,
|
argv: CliArgs,
|
||||||
options: LoadCliConfigOptions = {},
|
options: LoadCliConfigOptions = {},
|
||||||
): Promise<Config> {
|
): Promise<Config> {
|
||||||
const { cwd = process.cwd(), projectHooks } = options;
|
const {
|
||||||
|
cwd = process.cwd(),
|
||||||
|
mode = 'full',
|
||||||
|
loadedSettings,
|
||||||
|
projectHooks,
|
||||||
|
} = options;
|
||||||
const debugMode = isDebugMode(argv);
|
const debugMode = isDebugMode(argv);
|
||||||
|
const resolvedLoadedSettings = loadedSettings ?? loadSettings(cwd);
|
||||||
const loadedSettings = loadSettings(cwd);
|
const isBootstrap = mode === 'bootstrap';
|
||||||
|
|
||||||
if (argv.sandbox) {
|
if (argv.sandbox) {
|
||||||
process.env['GEMINI_SANDBOX'] = 'true';
|
process.env['GEMINI_SANDBOX'] = 'true';
|
||||||
@@ -439,6 +448,18 @@ export async function loadCliConfig(
|
|||||||
|
|
||||||
const memoryImportFormat = settings.context?.importFormat || 'tree';
|
const memoryImportFormat = settings.context?.importFormat || 'tree';
|
||||||
const includeDirectoryTree = settings.context?.includeDirectoryTree ?? true;
|
const includeDirectoryTree = settings.context?.includeDirectoryTree ?? true;
|
||||||
|
const interactive =
|
||||||
|
!!argv.promptInteractive ||
|
||||||
|
!!argv.acp ||
|
||||||
|
!!argv.experimentalAcp ||
|
||||||
|
(!isHeadlessMode({ prompt: argv.prompt, query: argv.query }) &&
|
||||||
|
!argv.isCommand);
|
||||||
|
const shouldDeferInteractiveStartupWork =
|
||||||
|
!isBootstrap &&
|
||||||
|
interactive &&
|
||||||
|
!argv.listExtensions &&
|
||||||
|
!argv.listSessions &&
|
||||||
|
!argv.deleteSession;
|
||||||
|
|
||||||
const ideMode = settings.ide?.enabled ?? false;
|
const ideMode = settings.ide?.enabled ?? false;
|
||||||
|
|
||||||
@@ -480,6 +501,7 @@ export async function loadCliConfig(
|
|||||||
.map(resolvePath)
|
.map(resolvePath)
|
||||||
.concat((argv.includeDirectories || []).map(resolvePath));
|
.concat((argv.includeDirectories || []).map(resolvePath));
|
||||||
|
|
||||||
|
const clientVersion = await getVersion();
|
||||||
const extensionManager = new ExtensionManager({
|
const extensionManager = new ExtensionManager({
|
||||||
settings,
|
settings,
|
||||||
requestConsent: requestConsentNonInteractive,
|
requestConsent: requestConsentNonInteractive,
|
||||||
@@ -488,9 +510,15 @@ export async function loadCliConfig(
|
|||||||
enabledExtensionOverrides: argv.extensions,
|
enabledExtensionOverrides: argv.extensions,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
|
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
|
||||||
clientVersion: await getVersion(),
|
clientVersion,
|
||||||
});
|
});
|
||||||
await extensionManager.loadExtensions();
|
if (!isBootstrap && !shouldDeferInteractiveStartupWork) {
|
||||||
|
const loadExtensionsHandle = startupProfiler.start(
|
||||||
|
'load_extensions_during_config',
|
||||||
|
);
|
||||||
|
await extensionManager.loadExtensions();
|
||||||
|
loadExtensionsHandle?.end();
|
||||||
|
}
|
||||||
|
|
||||||
const extensionPlanSettings = extensionManager
|
const extensionPlanSettings = extensionManager
|
||||||
.getExtensions()
|
.getExtensions()
|
||||||
@@ -511,7 +539,12 @@ export async function loadCliConfig(
|
|||||||
let fileCount = 0;
|
let fileCount = 0;
|
||||||
let filePaths: string[] = [];
|
let filePaths: string[] = [];
|
||||||
|
|
||||||
if (!experimentalJitContext) {
|
if (
|
||||||
|
!experimentalJitContext &&
|
||||||
|
!isBootstrap &&
|
||||||
|
!shouldDeferInteractiveStartupWork
|
||||||
|
) {
|
||||||
|
const loadMemoryHandle = startupProfiler.start('load_memory');
|
||||||
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
|
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
|
||||||
const result = await loadServerHierarchicalMemory(
|
const result = await loadServerHierarchicalMemory(
|
||||||
cwd,
|
cwd,
|
||||||
@@ -528,6 +561,7 @@ export async function loadCliConfig(
|
|||||||
memoryContent = result.memoryContent;
|
memoryContent = result.memoryContent;
|
||||||
fileCount = result.fileCount;
|
fileCount = result.fileCount;
|
||||||
filePaths = result.filePaths;
|
filePaths = result.filePaths;
|
||||||
|
loadMemoryHandle?.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
const question = argv.promptInteractive || argv.prompt || '';
|
const question = argv.promptInteractive || argv.prompt || '';
|
||||||
@@ -617,15 +651,6 @@ export async function loadCliConfig(
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -p/--prompt forces non-interactive (headless) mode
|
|
||||||
// -i/--prompt-interactive forces interactive mode with an initial prompt
|
|
||||||
const interactive =
|
|
||||||
!!argv.promptInteractive ||
|
|
||||||
!!argv.acp ||
|
|
||||||
!!argv.experimentalAcp ||
|
|
||||||
(!isHeadlessMode({ prompt: argv.prompt, query: argv.query }) &&
|
|
||||||
!argv.isCommand);
|
|
||||||
|
|
||||||
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
|
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
|
||||||
|
|
||||||
// In non-interactive mode, exclude tools that require a prompt.
|
// In non-interactive mode, exclude tools that require a prompt.
|
||||||
@@ -695,7 +720,7 @@ export async function loadCliConfig(
|
|||||||
? argv.screenReader
|
? argv.screenReader
|
||||||
: (settings.ui?.accessibility?.screenReader ?? false);
|
: (settings.ui?.accessibility?.screenReader ?? false);
|
||||||
|
|
||||||
const ptyInfo = await getPty();
|
const ptyInfo = isBootstrap ? null : await getPty();
|
||||||
|
|
||||||
const mcpEnabled = settings.admin?.mcp?.enabled ?? true;
|
const mcpEnabled = settings.admin?.mcp?.enabled ?? true;
|
||||||
const extensionsEnabled = settings.admin?.extensions?.enabled ?? true;
|
const extensionsEnabled = settings.admin?.extensions?.enabled ?? true;
|
||||||
@@ -741,7 +766,7 @@ export async function loadCliConfig(
|
|||||||
acpMode: isAcpMode,
|
acpMode: isAcpMode,
|
||||||
clientName,
|
clientName,
|
||||||
sessionId,
|
sessionId,
|
||||||
clientVersion: await getVersion(),
|
clientVersion,
|
||||||
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
|
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||||
sandbox: sandboxConfig,
|
sandbox: sandboxConfig,
|
||||||
targetDir: cwd,
|
targetDir: cwd,
|
||||||
@@ -822,6 +847,8 @@ export async function loadCliConfig(
|
|||||||
skillsSupport: settings.skills?.enabled ?? true,
|
skillsSupport: settings.skills?.enabled ?? true,
|
||||||
disabledSkills: settings.skills?.disabled,
|
disabledSkills: settings.skills?.disabled,
|
||||||
experimentalJitContext: settings.experimental?.jitContext,
|
experimentalJitContext: settings.experimental?.jitContext,
|
||||||
|
deferInitialMemoryLoad:
|
||||||
|
shouldDeferInteractiveStartupWork && !experimentalJitContext,
|
||||||
modelSteering: settings.experimental?.modelSteering,
|
modelSteering: settings.experimental?.modelSteering,
|
||||||
toolOutputMasking: settings.experimental?.toolOutputMasking,
|
toolOutputMasking: settings.experimental?.toolOutputMasking,
|
||||||
noBrowser: !!process.env['NO_BROWSER'],
|
noBrowser: !!process.env['NO_BROWSER'],
|
||||||
@@ -864,7 +891,8 @@ export async function loadCliConfig(
|
|||||||
hooks: settings.hooks || {},
|
hooks: settings.hooks || {},
|
||||||
disabledHooks: settings.hooksConfig?.disabled || [],
|
disabledHooks: settings.hooksConfig?.disabled || [],
|
||||||
projectHooks: projectHooks || {},
|
projectHooks: projectHooks || {},
|
||||||
onModelChange: (model: string) => saveModelChange(loadedSettings, model),
|
onModelChange: (model: string) =>
|
||||||
|
saveModelChange(resolvedLoadedSettings, model),
|
||||||
onReload: async () => {
|
onReload: async () => {
|
||||||
const refreshedSettings = loadSettings(cwd);
|
const refreshedSettings = loadSettings(cwd);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ interface ExtensionManagerParams {
|
|||||||
/**
|
/**
|
||||||
* Actual implementation of an ExtensionLoader.
|
* Actual implementation of an ExtensionLoader.
|
||||||
*
|
*
|
||||||
* You must call `loadExtensions` prior to calling other methods on this class.
|
* Extension metadata is loaded lazily via `loadExtensions`/`ensureLoaded`.
|
||||||
*/
|
*/
|
||||||
export class ExtensionManager extends ExtensionLoader {
|
export class ExtensionManager extends ExtensionLoader {
|
||||||
private extensionEnablementManager: ExtensionEnablementManager;
|
private extensionEnablementManager: ExtensionEnablementManager;
|
||||||
@@ -146,12 +146,24 @@ export class ExtensionManager extends ExtensionLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getExtensions(): GeminiCLIExtension[] {
|
getExtensions(): GeminiCLIExtension[] {
|
||||||
if (!this.loadedExtensions) {
|
return this.loadedExtensions ?? [];
|
||||||
throw new Error(
|
}
|
||||||
'Extensions not yet loaded, must call `loadExtensions` first',
|
|
||||||
);
|
override isLoaded(): boolean {
|
||||||
|
return this.loadedExtensions !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
override async ensureLoaded(): Promise<void> {
|
||||||
|
if (this.loadedExtensions) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return this.loadedExtensions;
|
|
||||||
|
if (this.loadingPromise) {
|
||||||
|
await this.loadingPromise;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.loadExtensions();
|
||||||
}
|
}
|
||||||
|
|
||||||
async installOrUpdateExtension(
|
async installOrUpdateExtension(
|
||||||
|
|||||||
@@ -289,6 +289,11 @@ export function createTestMergedSettings(
|
|||||||
) as MergedSettings;
|
) as MergedSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createDefaultMergedSettings(): MergedSettings {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
return getDefaultsFromSchema() as MergedSettings;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An immutable snapshot of settings state.
|
* An immutable snapshot of settings state.
|
||||||
* Used with useSyncExternalStore for reactive updates.
|
* Used with useSyncExternalStore for reactive updates.
|
||||||
|
|||||||
@@ -269,6 +269,7 @@ vi.mock('./validateNonInterActiveAuth.js', () => ({
|
|||||||
|
|
||||||
describe('gemini.tsx main function', () => {
|
describe('gemini.tsx main function', () => {
|
||||||
let originalIsTTY: boolean | undefined;
|
let originalIsTTY: boolean | undefined;
|
||||||
|
const originalArgv = [...process.argv];
|
||||||
let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =
|
let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =
|
||||||
[];
|
[];
|
||||||
|
|
||||||
@@ -284,6 +285,7 @@ describe('gemini.tsx main function', () => {
|
|||||||
originalIsTTY = process.stdin.isTTY;
|
originalIsTTY = process.stdin.isTTY;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(process.stdin as any).isTTY = true;
|
(process.stdin as any).isTTY = true;
|
||||||
|
process.argv = ['node', 'script.js'];
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -296,11 +298,70 @@ describe('gemini.tsx main function', () => {
|
|||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(process.stdin as any).isTTY = originalIsTTY;
|
(process.stdin as any).isTTY = originalIsTTY;
|
||||||
|
process.argv = [...originalArgv];
|
||||||
|
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should fast-path --version without loading settings', async () => {
|
||||||
|
process.argv = ['node', 'script.js', '--version'];
|
||||||
|
|
||||||
|
await main();
|
||||||
|
|
||||||
|
expect(loadSettings).not.toHaveBeenCalled();
|
||||||
|
expect(parseArguments).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fast-path --help without loading settings', async () => {
|
||||||
|
process.argv = ['node', 'script.js', '--help'];
|
||||||
|
|
||||||
|
await main();
|
||||||
|
|
||||||
|
expect(loadSettings).not.toHaveBeenCalled();
|
||||||
|
expect(parseArguments).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not relaunch a child process when sandboxing and memory relaunch are unnecessary', async () => {
|
||||||
|
vi.mocked(loadSandboxConfig).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(loadSettings).mockReturnValue(
|
||||||
|
createMockSettings({
|
||||||
|
merged: { advanced: {}, security: { auth: {} }, ui: {} },
|
||||||
|
workspace: { settings: {} },
|
||||||
|
setValue: vi.fn(),
|
||||||
|
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.mocked(parseArguments).mockResolvedValue({
|
||||||
|
prompt: 'test',
|
||||||
|
} as unknown as CliArgs);
|
||||||
|
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||||
|
createMockConfig({
|
||||||
|
isInteractive: () => false,
|
||||||
|
getQuestion: () => 'test',
|
||||||
|
getSandbox: () => undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const processExitSpy = vi
|
||||||
|
.spyOn(process, 'exit')
|
||||||
|
.mockImplementation((code) => {
|
||||||
|
throw new MockProcessExitError(code);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await main();
|
||||||
|
expect.fail('Should have thrown MockProcessExitError');
|
||||||
|
} catch (e) {
|
||||||
|
expect(e).toBeInstanceOf(MockProcessExitError);
|
||||||
|
expect((e as MockProcessExitError).code).toBe(0);
|
||||||
|
} finally {
|
||||||
|
processExitSpy.mockRestore();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { relaunchAppInChildProcess } = await import('./utils/relaunch.js');
|
||||||
|
expect(relaunchAppInChildProcess).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('should log unhandled promise rejections and open debug console on first error', async () => {
|
it('should log unhandled promise rejections and open debug console on first error', async () => {
|
||||||
const processExitSpy = vi
|
const processExitSpy = vi
|
||||||
.spyOn(process, 'exit')
|
.spyOn(process, 'exit')
|
||||||
@@ -665,6 +726,42 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
processExitSpy.mockRestore();
|
processExitSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should use bootstrap config for the pre-relaunch startup pass', async () => {
|
||||||
|
const mockSettings = createMockSettings({
|
||||||
|
merged: {
|
||||||
|
advanced: {},
|
||||||
|
security: { auth: {} },
|
||||||
|
ui: {},
|
||||||
|
},
|
||||||
|
workspace: { settings: {} },
|
||||||
|
setValue: vi.fn(),
|
||||||
|
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||||
|
});
|
||||||
|
vi.mocked(loadSettings).mockReturnValue(mockSettings);
|
||||||
|
vi.mocked(parseArguments).mockResolvedValue({
|
||||||
|
promptInteractive: false,
|
||||||
|
} as unknown as CliArgs);
|
||||||
|
|
||||||
|
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||||
|
createMockConfig({
|
||||||
|
isInteractive: () => true,
|
||||||
|
getQuestion: () => '',
|
||||||
|
getSandbox: () => undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await main();
|
||||||
|
|
||||||
|
expect(loadCliConfig).toHaveBeenCalledTimes(2);
|
||||||
|
expect(vi.mocked(loadCliConfig).mock.calls[0][3]).toMatchObject({
|
||||||
|
mode: 'bootstrap',
|
||||||
|
loadedSettings: mockSettings,
|
||||||
|
});
|
||||||
|
expect(vi.mocked(loadCliConfig).mock.calls[1][3]).toMatchObject({
|
||||||
|
loadedSettings: mockSettings,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should log warning when theme is not found', async () => {
|
it('should log warning when theme is not found', async () => {
|
||||||
const { themeManager } = await import('./ui/themes/theme-manager.js');
|
const { themeManager } = await import('./ui/themes/theme-manager.js');
|
||||||
const debugLoggerWarnSpy = vi
|
const debugLoggerWarnSpy = vi
|
||||||
|
|||||||
+66
-14
@@ -17,6 +17,7 @@ import os from 'node:os';
|
|||||||
import dns from 'node:dns';
|
import dns from 'node:dns';
|
||||||
import { start_sandbox } from './utils/sandbox.js';
|
import { start_sandbox } from './utils/sandbox.js';
|
||||||
import {
|
import {
|
||||||
|
createDefaultMergedSettings,
|
||||||
loadSettings,
|
loadSettings,
|
||||||
SettingScope,
|
SettingScope,
|
||||||
type DnsResolutionOrder,
|
type DnsResolutionOrder,
|
||||||
@@ -119,6 +120,24 @@ import { cleanupBackgroundLogs } from './utils/logCleanup.js';
|
|||||||
import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js';
|
import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js';
|
||||||
|
|
||||||
const SLOW_RENDER_MS = 200;
|
const SLOW_RENDER_MS = 200;
|
||||||
|
const HELP_FLAGS = new Set(['--help', '-h']);
|
||||||
|
const VERSION_FLAGS = new Set(['--version', '-v']);
|
||||||
|
|
||||||
|
function getStartupFastPath(args: string[]): 'help' | 'version' | null {
|
||||||
|
if (args.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.every((arg) => HELP_FLAGS.has(arg))) {
|
||||||
|
return 'help';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.every((arg) => VERSION_FLAGS.has(arg))) {
|
||||||
|
return 'version';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function validateDnsResolutionOrder(
|
export function validateDnsResolutionOrder(
|
||||||
order: string | undefined,
|
order: string | undefined,
|
||||||
@@ -341,7 +360,37 @@ export async function startInteractiveUI(
|
|||||||
registerCleanup(setupTtyCheck());
|
registerCleanup(setupTtyCheck());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runStartupCleanup(
|
||||||
|
config: Config,
|
||||||
|
settings: LoadedSettings,
|
||||||
|
): Promise<void> {
|
||||||
|
const projectTempDir = config.storage.getProjectTempDir();
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
cleanupCheckpoints(projectTempDir),
|
||||||
|
cleanupToolOutputFiles(
|
||||||
|
settings.merged,
|
||||||
|
config.getDebugMode(),
|
||||||
|
projectTempDir,
|
||||||
|
),
|
||||||
|
cleanupBackgroundLogs(),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
debugLogger.warn('Deferred startup cleanup failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function main() {
|
export async function main() {
|
||||||
|
const fastPath = getStartupFastPath(process.argv.slice(2));
|
||||||
|
if (fastPath === 'version') {
|
||||||
|
writeToStdout(`${await getVersion()}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fastPath === 'help') {
|
||||||
|
await parseArguments(createDefaultMergedSettings());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const cliStartupHandle = startupProfiler.start('cli_startup');
|
const cliStartupHandle = startupProfiler.start('cli_startup');
|
||||||
|
|
||||||
// Listen for admin controls from parent process (IPC) in non-sandbox mode. In
|
// Listen for admin controls from parent process (IPC) in non-sandbox mode. In
|
||||||
@@ -375,6 +424,10 @@ export async function main() {
|
|||||||
coreEvents.emitFeedback('warning', error.message);
|
coreEvents.emitFeedback('warning', error.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const parseArgsHandle = startupProfiler.start('parse_arguments');
|
||||||
|
const argv = await parseArguments(settings.merged);
|
||||||
|
parseArgsHandle?.end();
|
||||||
|
|
||||||
const trustedFolders = loadTrustedFolders();
|
const trustedFolders = loadTrustedFolders();
|
||||||
trustedFolders.errors.forEach((error: TrustedFoldersError) => {
|
trustedFolders.errors.forEach((error: TrustedFoldersError) => {
|
||||||
coreEvents.emitFeedback(
|
coreEvents.emitFeedback(
|
||||||
@@ -382,17 +435,6 @@ export async function main() {
|
|||||||
`Error in ${error.path}: ${error.message}`,
|
`Error in ${error.path}: ${error.message}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
cleanupCheckpoints(),
|
|
||||||
cleanupToolOutputFiles(settings.merged),
|
|
||||||
cleanupBackgroundLogs(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const parseArgsHandle = startupProfiler.start('parse_arguments');
|
|
||||||
const argv = await parseArguments(settings.merged);
|
|
||||||
parseArgsHandle?.end();
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(argv.allowedTools && argv.allowedTools.length > 0) ||
|
(argv.allowedTools && argv.allowedTools.length > 0) ||
|
||||||
(settings.merged.tools?.allowed && settings.merged.tools.allowed.length > 0)
|
(settings.merged.tools?.allowed && settings.merged.tools.allowed.length > 0)
|
||||||
@@ -460,9 +502,13 @@ export async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bootstrapConfigHandle = startupProfiler.start('load_bootstrap_config');
|
||||||
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
|
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
|
||||||
|
mode: 'bootstrap',
|
||||||
|
loadedSettings: settings,
|
||||||
projectHooks: settings.workspace.settings.hooks,
|
projectHooks: settings.workspace.settings.hooks,
|
||||||
});
|
});
|
||||||
|
bootstrapConfigHandle?.end();
|
||||||
adminControlsListner.setConfig(partialConfig);
|
adminControlsListner.setConfig(partialConfig);
|
||||||
|
|
||||||
// Refresh auth to fetch remote admin settings from CCPA and before entering
|
// Refresh auth to fetch remote admin settings from CCPA and before entering
|
||||||
@@ -572,9 +618,7 @@ export async function main() {
|
|||||||
);
|
);
|
||||||
await runExitCleanup();
|
await runExitCleanup();
|
||||||
process.exit(ExitCodes.SUCCESS);
|
process.exit(ExitCodes.SUCCESS);
|
||||||
} else {
|
} else if (memoryArgs.length > 0) {
|
||||||
// Relaunch app so we always have a child process that can be internally
|
|
||||||
// restarted if needed.
|
|
||||||
await relaunchAppInChildProcess(memoryArgs, [], remoteAdminSettings);
|
await relaunchAppInChildProcess(memoryArgs, [], remoteAdminSettings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -585,6 +629,7 @@ export async function main() {
|
|||||||
{
|
{
|
||||||
const loadConfigHandle = startupProfiler.start('load_cli_config');
|
const loadConfigHandle = startupProfiler.start('load_cli_config');
|
||||||
const config = await loadCliConfig(settings.merged, sessionId, argv, {
|
const config = await loadCliConfig(settings.merged, sessionId, argv, {
|
||||||
|
loadedSettings: settings,
|
||||||
projectHooks: settings.workspace.settings.hooks,
|
projectHooks: settings.workspace.settings.hooks,
|
||||||
});
|
});
|
||||||
loadConfigHandle?.end();
|
loadConfigHandle?.end();
|
||||||
@@ -662,6 +707,13 @@ export async function main() {
|
|||||||
process.exit(ExitCodes.SUCCESS);
|
process.exit(ExitCodes.SUCCESS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startupCleanup = runStartupCleanup(config, settings);
|
||||||
|
if (config.isInteractive()) {
|
||||||
|
void startupCleanup;
|
||||||
|
} else {
|
||||||
|
await startupCleanup;
|
||||||
|
}
|
||||||
|
|
||||||
const wasRaw = process.stdin.isRaw;
|
const wasRaw = process.stdin.isRaw;
|
||||||
if (config.isInteractive() && !wasRaw && process.stdin.isTTY) {
|
if (config.isInteractive() && !wasRaw && process.stdin.isTTY) {
|
||||||
// Set this as early as possible to avoid spurious characters from
|
// Set this as early as possible to avoid spurious characters from
|
||||||
|
|||||||
@@ -796,7 +796,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
Logging in with Google... Restarting Gemini CLI to continue.
|
Logging in with Google... Restarting Gemini CLI to continue.
|
||||||
----------------------------------------------------------------
|
----------------------------------------------------------------
|
||||||
`);
|
`);
|
||||||
await relaunchApp();
|
await relaunchApp(config.getRemoteAdminSettings());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setAuthState(AuthState.Authenticated);
|
setAuthState(AuthState.Authenticated);
|
||||||
@@ -2478,16 +2478,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
onHintClear: () => {},
|
onHintClear: () => {},
|
||||||
onHintSubmit: () => {},
|
onHintSubmit: () => {},
|
||||||
handleRestart: async () => {
|
handleRestart: async () => {
|
||||||
if (process.send) {
|
await relaunchApp(config.getRemoteAdminSettings());
|
||||||
const remoteSettings = config.getRemoteAdminSettings();
|
|
||||||
if (remoteSettings) {
|
|
||||||
process.send({
|
|
||||||
type: 'admin-settings-update',
|
|
||||||
settings: remoteSettings,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await relaunchApp();
|
|
||||||
},
|
},
|
||||||
handleNewAgentsSelect: async (choice: NewAgentsChoice) => {
|
handleNewAgentsSelect: async (choice: NewAgentsChoice) => {
|
||||||
if (newAgents && choice === NewAgentsChoice.ACKNOWLEDGE) {
|
if (newAgents && choice === NewAgentsChoice.ACKNOWLEDGE) {
|
||||||
|
|||||||
@@ -22,9 +22,8 @@ import { AuthState } from '../types.js';
|
|||||||
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
|
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
|
||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
import { validateAuthMethodWithSettings } from './useAuth.js';
|
import { validateAuthMethodWithSettings } from './useAuth.js';
|
||||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
|
||||||
import { Text } from 'ink';
|
import { Text } from 'ink';
|
||||||
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
|
import { relaunchApp } from '../../utils/processUtils.js';
|
||||||
|
|
||||||
// Mocks
|
// Mocks
|
||||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
@@ -36,14 +35,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('../../utils/cleanup.js', () => ({
|
|
||||||
runExitCleanup: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('./useAuth.js', () => ({
|
vi.mock('./useAuth.js', () => ({
|
||||||
validateAuthMethodWithSettings: vi.fn(),
|
validateAuthMethodWithSettings: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../utils/processUtils.js', () => ({
|
||||||
|
relaunchApp: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../hooks/useKeypress.js', () => ({
|
vi.mock('../hooks/useKeypress.js', () => ({
|
||||||
useKeypress: vi.fn(),
|
useKeypress: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -64,7 +63,7 @@ vi.mock('../components/shared/RadioButtonSelect.js', () => ({
|
|||||||
const mockedUseKeypress = useKeypress as Mock;
|
const mockedUseKeypress = useKeypress as Mock;
|
||||||
const mockedRadioButtonSelect = RadioButtonSelect as Mock;
|
const mockedRadioButtonSelect = RadioButtonSelect as Mock;
|
||||||
const mockedValidateAuthMethod = validateAuthMethodWithSettings as Mock;
|
const mockedValidateAuthMethod = validateAuthMethodWithSettings as Mock;
|
||||||
const mockedRunExitCleanup = runExitCleanup as Mock;
|
const mockedRelaunchApp = relaunchApp as Mock;
|
||||||
|
|
||||||
describe('AuthDialog', () => {
|
describe('AuthDialog', () => {
|
||||||
let props: {
|
let props: {
|
||||||
@@ -85,6 +84,7 @@ describe('AuthDialog', () => {
|
|||||||
props = {
|
props = {
|
||||||
config: {
|
config: {
|
||||||
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
|
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
|
||||||
|
getRemoteAdminSettings: vi.fn().mockReturnValue(undefined),
|
||||||
} as unknown as Config,
|
} as unknown as Config,
|
||||||
settings: {
|
settings: {
|
||||||
merged: {
|
merged: {
|
||||||
@@ -351,11 +351,8 @@ describe('AuthDialog', () => {
|
|||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exits process for Sign in with Google when browser is suppressed', async () => {
|
it('restarts for Sign in with Google when browser is suppressed', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const exitSpy = vi
|
|
||||||
.spyOn(process, 'exit')
|
|
||||||
.mockImplementation(() => undefined as never);
|
|
||||||
const logSpy = vi.spyOn(debugLogger, 'log').mockImplementation(() => {});
|
const logSpy = vi.spyOn(debugLogger, 'log').mockImplementation(() => {});
|
||||||
vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true);
|
vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true);
|
||||||
mockedValidateAuthMethod.mockReturnValue(null);
|
mockedValidateAuthMethod.mockReturnValue(null);
|
||||||
@@ -371,10 +368,8 @@ describe('AuthDialog', () => {
|
|||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockedRunExitCleanup).toHaveBeenCalled();
|
expect(mockedRelaunchApp).toHaveBeenCalledWith(undefined);
|
||||||
expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
|
|
||||||
|
|
||||||
exitSpy.mockRestore();
|
|
||||||
logSpy.mockRestore();
|
logSpy.mockRestore();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
unmount();
|
unmount();
|
||||||
|
|||||||
@@ -132,7 +132,9 @@ export function AuthDialog({
|
|||||||
config.isBrowserLaunchSuppressed()
|
config.isBrowserLaunchSuppressed()
|
||||||
) {
|
) {
|
||||||
setExiting(true);
|
setExiting(true);
|
||||||
setTimeout(relaunchApp, 100);
|
setTimeout(async () => {
|
||||||
|
await relaunchApp(config.getRemoteAdminSettings());
|
||||||
|
}, 100);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,30 +8,23 @@ import { render } from '../../test-utils/render.js';
|
|||||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
import { LoginWithGoogleRestartDialog } from './LoginWithGoogleRestartDialog.js';
|
import { LoginWithGoogleRestartDialog } from './LoginWithGoogleRestartDialog.js';
|
||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
|
||||||
import {
|
|
||||||
RELAUNCH_EXIT_CODE,
|
|
||||||
_resetRelaunchStateForTesting,
|
|
||||||
} from '../../utils/processUtils.js';
|
|
||||||
import { type Config } from '@google/gemini-cli-core';
|
import { type Config } from '@google/gemini-cli-core';
|
||||||
|
import { relaunchApp } from '../../utils/processUtils.js';
|
||||||
|
|
||||||
// Mocks
|
// Mocks
|
||||||
vi.mock('../hooks/useKeypress.js', () => ({
|
vi.mock('../hooks/useKeypress.js', () => ({
|
||||||
useKeypress: vi.fn(),
|
useKeypress: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../utils/cleanup.js', () => ({
|
vi.mock('../../utils/processUtils.js', () => ({
|
||||||
runExitCleanup: vi.fn(),
|
relaunchApp: vi.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockedUseKeypress = useKeypress as Mock;
|
const mockedUseKeypress = useKeypress as Mock;
|
||||||
const mockedRunExitCleanup = runExitCleanup as Mock;
|
const mockedRelaunchApp = relaunchApp as Mock;
|
||||||
|
|
||||||
describe('LoginWithGoogleRestartDialog', () => {
|
describe('LoginWithGoogleRestartDialog', () => {
|
||||||
const onDismiss = vi.fn();
|
const onDismiss = vi.fn();
|
||||||
const exitSpy = vi
|
|
||||||
.spyOn(process, 'exit')
|
|
||||||
.mockImplementation(() => undefined as never);
|
|
||||||
|
|
||||||
const mockConfig = {
|
const mockConfig = {
|
||||||
getRemoteAdminSettings: vi.fn(),
|
getRemoteAdminSettings: vi.fn(),
|
||||||
@@ -39,9 +32,7 @@ describe('LoginWithGoogleRestartDialog', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
exitSpy.mockClear();
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
_resetRelaunchStateForTesting();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders correctly', async () => {
|
it('renders correctly', async () => {
|
||||||
@@ -78,36 +69,33 @@ describe('LoginWithGoogleRestartDialog', () => {
|
|||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['r', 'R'])(
|
it.each(['r', 'R'])('restarts when %s is pressed', async (keyName) => {
|
||||||
'calls runExitCleanup and process.exit when %s is pressed',
|
vi.useFakeTimers();
|
||||||
async (keyName) => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
|
|
||||||
const { waitUntilReady, unmount } = render(
|
const { waitUntilReady, unmount } = render(
|
||||||
<LoginWithGoogleRestartDialog
|
<LoginWithGoogleRestartDialog
|
||||||
onDismiss={onDismiss}
|
onDismiss={onDismiss}
|
||||||
config={mockConfig}
|
config={mockConfig}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||||
|
|
||||||
keypressHandler({
|
keypressHandler({
|
||||||
name: keyName,
|
name: keyName,
|
||||||
shift: false,
|
shift: false,
|
||||||
ctrl: false,
|
ctrl: false,
|
||||||
cmd: false,
|
cmd: false,
|
||||||
sequence: keyName,
|
sequence: keyName,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Advance timers to trigger the setTimeout callback
|
// Advance timers to trigger the setTimeout callback
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
expect(mockedRunExitCleanup).toHaveBeenCalledTimes(1);
|
expect(mockedRelaunchApp).toHaveBeenCalledTimes(1);
|
||||||
expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
|
expect(mockedRelaunchApp).toHaveBeenCalledWith(undefined);
|
||||||
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
unmount();
|
unmount();
|
||||||
},
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,16 +26,7 @@ export const LoginWithGoogleRestartDialog = ({
|
|||||||
return true;
|
return true;
|
||||||
} else if (key.name === 'r' || key.name === 'R') {
|
} else if (key.name === 'r' || key.name === 'R') {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
if (process.send) {
|
await relaunchApp(config.getRemoteAdminSettings());
|
||||||
const remoteSettings = config.getRemoteAdminSettings();
|
|
||||||
if (remoteSettings) {
|
|
||||||
process.send({
|
|
||||||
type: 'admin-settings-update',
|
|
||||||
settings: remoteSettings,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await relaunchApp();
|
|
||||||
}, 100);
|
}, 100);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,7 +231,9 @@ export const DialogManager = ({
|
|||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<SettingsDialog
|
<SettingsDialog
|
||||||
onSelect={() => uiActions.closeSettingsDialog()}
|
onSelect={() => uiActions.closeSettingsDialog()}
|
||||||
onRestartRequest={relaunchApp}
|
onRestartRequest={async () => {
|
||||||
|
await relaunchApp(config.getRemoteAdminSettings());
|
||||||
|
}}
|
||||||
availableTerminalHeight={terminalHeight - staticExtraHeight}
|
availableTerminalHeight={terminalHeight - staticExtraHeight}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -103,6 +103,21 @@ async function drainStdin() {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cleanupCheckpoints(projectTempDir?: string) {
|
||||||
|
let tempDir = projectTempDir;
|
||||||
|
if (!tempDir) {
|
||||||
|
const storage = new Storage(process.cwd());
|
||||||
|
await storage.initialize();
|
||||||
|
tempDir = storage.getProjectTempDir();
|
||||||
|
}
|
||||||
|
const checkpointsDir = join(tempDir, 'checkpoints');
|
||||||
|
try {
|
||||||
|
await fs.rm(checkpointsDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Ignore errors if the directory doesn't exist or fails to delete.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gracefully shuts down the process, ensuring cleanup runs exactly once.
|
* Gracefully shuts down the process, ensuring cleanup runs exactly once.
|
||||||
* Guards against concurrent shutdown from signals (SIGHUP, SIGTERM, SIGINT)
|
* Guards against concurrent shutdown from signals (SIGHUP, SIGTERM, SIGINT)
|
||||||
@@ -161,15 +176,3 @@ export function setupTtyCheck(): () => void {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanupCheckpoints() {
|
|
||||||
const storage = new Storage(process.cwd());
|
|
||||||
await storage.initialize();
|
|
||||||
const tempDir = storage.getProjectTempDir();
|
|
||||||
const checkpointsDir = join(tempDir, 'checkpoints');
|
|
||||||
try {
|
|
||||||
await fs.rm(checkpointsDir, { recursive: true, force: true });
|
|
||||||
} catch {
|
|
||||||
// Ignore errors if the directory doesn't exist or fails to delete.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,28 +11,56 @@ import {
|
|||||||
_resetRelaunchStateForTesting,
|
_resetRelaunchStateForTesting,
|
||||||
} from './processUtils.js';
|
} from './processUtils.js';
|
||||||
import * as cleanup from './cleanup.js';
|
import * as cleanup from './cleanup.js';
|
||||||
|
import * as relaunch from './relaunch.js';
|
||||||
import * as handleAutoUpdate from './handleAutoUpdate.js';
|
import * as handleAutoUpdate from './handleAutoUpdate.js';
|
||||||
|
|
||||||
vi.mock('./handleAutoUpdate.js', () => ({
|
vi.mock('./handleAutoUpdate.js', () => ({
|
||||||
waitForUpdateCompletion: vi.fn().mockResolvedValue(undefined),
|
waitForUpdateCompletion: vi.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('./relaunch.js', () => ({
|
||||||
|
relaunchAppInChildProcess: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('processUtils', () => {
|
describe('processUtils', () => {
|
||||||
const processExit = vi
|
const processExit = vi
|
||||||
.spyOn(process, 'exit')
|
.spyOn(process, 'exit')
|
||||||
.mockReturnValue(undefined as never);
|
.mockReturnValue(undefined as never);
|
||||||
const runExitCleanup = vi.spyOn(cleanup, 'runExitCleanup');
|
const runExitCleanup = vi.spyOn(cleanup, 'runExitCleanup');
|
||||||
|
const relaunchAppInChildProcess = vi.spyOn(
|
||||||
|
relaunch,
|
||||||
|
'relaunchAppInChildProcess',
|
||||||
|
);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
_resetRelaunchStateForTesting();
|
_resetRelaunchStateForTesting();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => vi.clearAllMocks());
|
afterEach(() => {
|
||||||
|
delete process.env['SANDBOX'];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
delete (process as any).send;
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('should wait for updates, run cleanup, and exit with the relaunch code', async () => {
|
it('should wait for updates, run cleanup, and exit with the relaunch code when a parent wrapper exists', async () => {
|
||||||
await relaunchApp();
|
process.env['SANDBOX'] = 'sandbox-exec';
|
||||||
|
processExit.mockImplementationOnce(() => {
|
||||||
|
throw new Error('PROCESS_EXIT_CALLED');
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(relaunchApp()).rejects.toThrow('PROCESS_EXIT_CALLED');
|
||||||
expect(handleAutoUpdate.waitForUpdateCompletion).toHaveBeenCalledTimes(1);
|
expect(handleAutoUpdate.waitForUpdateCompletion).toHaveBeenCalledTimes(1);
|
||||||
expect(runExitCleanup).toHaveBeenCalledTimes(1);
|
expect(runExitCleanup).toHaveBeenCalledTimes(1);
|
||||||
expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
|
expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
|
||||||
|
expect(relaunchAppInChildProcess).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should spawn a child wrapper on demand when no parent wrapper exists', async () => {
|
||||||
|
await relaunchApp();
|
||||||
|
expect(handleAutoUpdate.waitForUpdateCompletion).toHaveBeenCalledTimes(1);
|
||||||
|
expect(runExitCleanup).toHaveBeenCalledTimes(1);
|
||||||
|
expect(relaunchAppInChildProcess).toHaveBeenCalledWith([], [], undefined);
|
||||||
|
expect(processExit).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { AdminControlsSettings } from '@google/gemini-cli-core';
|
||||||
import { runExitCleanup } from './cleanup.js';
|
import { runExitCleanup } from './cleanup.js';
|
||||||
import { waitForUpdateCompletion } from './handleAutoUpdate.js';
|
import { waitForUpdateCompletion } from './handleAutoUpdate.js';
|
||||||
|
|
||||||
@@ -13,7 +14,8 @@ import { waitForUpdateCompletion } from './handleAutoUpdate.js';
|
|||||||
export const RELAUNCH_EXIT_CODE = 199;
|
export const RELAUNCH_EXIT_CODE = 199;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exits the process with a special code to signal that the parent process should relaunch it.
|
* Restarts the CLI, either by signaling an existing parent wrapper or by
|
||||||
|
* spawning one on demand.
|
||||||
*/
|
*/
|
||||||
let isRelaunching = false;
|
let isRelaunching = false;
|
||||||
|
|
||||||
@@ -22,10 +24,24 @@ export function _resetRelaunchStateForTesting(): void {
|
|||||||
isRelaunching = false;
|
isRelaunching = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function relaunchApp(): Promise<void> {
|
export async function relaunchApp(
|
||||||
|
remoteAdminSettings?: AdminControlsSettings,
|
||||||
|
): Promise<void> {
|
||||||
if (isRelaunching) return;
|
if (isRelaunching) return;
|
||||||
isRelaunching = true;
|
isRelaunching = true;
|
||||||
await waitForUpdateCompletion();
|
await waitForUpdateCompletion();
|
||||||
await runExitCleanup();
|
await runExitCleanup();
|
||||||
process.exit(RELAUNCH_EXIT_CODE);
|
|
||||||
|
if (process.send || process.env['SANDBOX']) {
|
||||||
|
if (process.send && remoteAdminSettings) {
|
||||||
|
process.send({
|
||||||
|
type: 'admin-settings-update',
|
||||||
|
settings: remoteAdminSettings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
process.exit(RELAUNCH_EXIT_CODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { relaunchAppInChildProcess } = await import('./relaunch.js');
|
||||||
|
await relaunchAppInChildProcess([], [], remoteAdminSettings);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -614,6 +614,7 @@ export interface ConfigParameters {
|
|||||||
disabledSkills?: string[];
|
disabledSkills?: string[];
|
||||||
adminSkillsEnabled?: boolean;
|
adminSkillsEnabled?: boolean;
|
||||||
experimentalJitContext?: boolean;
|
experimentalJitContext?: boolean;
|
||||||
|
deferInitialMemoryLoad?: boolean;
|
||||||
toolOutputMasking?: Partial<ToolOutputMaskingConfig>;
|
toolOutputMasking?: Partial<ToolOutputMaskingConfig>;
|
||||||
disableLLMCorrection?: boolean;
|
disableLLMCorrection?: boolean;
|
||||||
plan?: boolean;
|
plan?: boolean;
|
||||||
@@ -832,6 +833,7 @@ export class Config implements McpContext, AgentLoopContext {
|
|||||||
private readonly adminSkillsEnabled: boolean;
|
private readonly adminSkillsEnabled: boolean;
|
||||||
|
|
||||||
private readonly experimentalJitContext: boolean;
|
private readonly experimentalJitContext: boolean;
|
||||||
|
private readonly deferInitialMemoryLoad: boolean;
|
||||||
private readonly disableLLMCorrection: boolean;
|
private readonly disableLLMCorrection: boolean;
|
||||||
private readonly planEnabled: boolean;
|
private readonly planEnabled: boolean;
|
||||||
private readonly trackerEnabled: boolean;
|
private readonly trackerEnabled: boolean;
|
||||||
@@ -934,6 +936,7 @@ export class Config implements McpContext, AgentLoopContext {
|
|||||||
this.adminSkillsEnabled = params.adminSkillsEnabled ?? true;
|
this.adminSkillsEnabled = params.adminSkillsEnabled ?? true;
|
||||||
this.modelAvailabilityService = new ModelAvailabilityService();
|
this.modelAvailabilityService = new ModelAvailabilityService();
|
||||||
this.experimentalJitContext = params.experimentalJitContext ?? false;
|
this.experimentalJitContext = params.experimentalJitContext ?? false;
|
||||||
|
this.deferInitialMemoryLoad = params.deferInitialMemoryLoad ?? false;
|
||||||
this.modelSteering = params.modelSteering ?? false;
|
this.modelSteering = params.modelSteering ?? false;
|
||||||
this.userHintService = new UserHintService(() =>
|
this.userHintService = new UserHintService(() =>
|
||||||
this.isModelSteeringEnabled(),
|
this.isModelSteeringEnabled(),
|
||||||
@@ -1174,6 +1177,23 @@ export class Config implements McpContext, AgentLoopContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.getExtensionLoader().isLoaded()) {
|
||||||
|
const extensionLoadHandle = startupProfiler.start('load_extensions');
|
||||||
|
await this.getExtensionLoader().ensureLoaded();
|
||||||
|
extensionLoadHandle?.end();
|
||||||
|
} else {
|
||||||
|
await this.getExtensionLoader().ensureLoaded();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.deferInitialMemoryLoad) {
|
||||||
|
const memoryLoadHandle = startupProfiler.start('load_memory');
|
||||||
|
const { refreshServerHierarchicalMemory } = await import(
|
||||||
|
'../utils/memoryDiscovery.js'
|
||||||
|
);
|
||||||
|
await refreshServerHierarchicalMemory(this);
|
||||||
|
memoryLoadHandle?.end();
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize centralized FileDiscoveryService
|
// Initialize centralized FileDiscoveryService
|
||||||
const discoverToolsHandle = startupProfiler.start('discover_tools');
|
const discoverToolsHandle = startupProfiler.start('discover_tools');
|
||||||
this.getFileService();
|
this.getFileService();
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ export abstract class ExtensionLoader {
|
|||||||
|
|
||||||
constructor(private readonly eventEmitter?: EventEmitter<ExtensionEvents>) {}
|
constructor(private readonly eventEmitter?: EventEmitter<ExtensionEvents>) {}
|
||||||
|
|
||||||
|
isLoaded(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures extension metadata is available before initialization continues.
|
||||||
|
*
|
||||||
|
* Most implementations have nothing to do here, but loaders backed by disk
|
||||||
|
* discovery can override this to defer filesystem work until after first
|
||||||
|
* render.
|
||||||
|
*/
|
||||||
|
async ensureLoaded(): Promise<void> {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All currently known extensions, both active and inactive.
|
* All currently known extensions, both active and inactive.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -17,25 +17,38 @@ export interface PtyProcess {
|
|||||||
kill(signal?: string): void;
|
kill(signal?: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let ptyImplementationPromise: Promise<PtyImplementation> | undefined;
|
||||||
|
|
||||||
export const getPty = async (): Promise<PtyImplementation> => {
|
export const getPty = async (): Promise<PtyImplementation> => {
|
||||||
if (process.env['GEMINI_PTY_INFO'] === 'child_process') {
|
if (process.env['GEMINI_PTY_INFO'] === 'child_process') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
if (!ptyImplementationPromise) {
|
||||||
const lydell = '@lydell/node-pty';
|
ptyImplementationPromise = (async () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
try {
|
||||||
const module = await import(lydell);
|
const lydell = '@lydell/node-pty';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
return { module, name: 'lydell-node-pty' };
|
const module = await import(lydell);
|
||||||
} catch (_e) {
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
try {
|
return { module, name: 'lydell-node-pty' };
|
||||||
const nodePty = 'node-pty';
|
} catch (_e) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
try {
|
||||||
const module = await import(nodePty);
|
const nodePty = 'node-pty';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
return { module, name: 'node-pty' };
|
const module = await import(nodePty);
|
||||||
} catch (_e2) {
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
return null;
|
return { module, name: 'node-pty' };
|
||||||
}
|
} catch (_e2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return ptyImplementationPromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** For testing purposes only. */
|
||||||
|
export function resetPtyCache(): void {
|
||||||
|
ptyImplementationPromise = undefined;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user