mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 09:17:53 +00:00
Auth First Run (#1207)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com> Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
This commit is contained in:
@@ -145,6 +145,15 @@ vi.mock('./hooks/useGeminiStream', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('./hooks/useAuthCommand', () => ({
|
||||
useAuthCommand: vi.fn(() => ({
|
||||
isAuthDialogOpen: false,
|
||||
openAuthDialog: vi.fn(),
|
||||
handleAuthSelect: vi.fn(),
|
||||
handleAuthHighlight: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('./hooks/useLogger', () => ({
|
||||
useLogger: vi.fn(() => ({
|
||||
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
|
||||
@@ -176,7 +185,9 @@ describe('App UI', () => {
|
||||
};
|
||||
const workspaceSettingsFile: SettingsFile = {
|
||||
path: '/workspace/.gemini/settings.json',
|
||||
settings,
|
||||
settings: {
|
||||
...settings,
|
||||
},
|
||||
};
|
||||
return new LoadedSettings(userSettingsFile, workspaceSettingsFile, []);
|
||||
};
|
||||
@@ -184,10 +195,6 @@ describe('App UI', () => {
|
||||
beforeEach(() => {
|
||||
const ServerConfigMocked = vi.mocked(ServerConfig, true);
|
||||
mockConfig = new ServerConfigMocked({
|
||||
contentGeneratorConfig: {
|
||||
apiKey: 'test-key',
|
||||
model: 'test-model',
|
||||
},
|
||||
embeddingModel: 'test-embedding-model',
|
||||
sandbox: undefined,
|
||||
targetDir: '/test/dir',
|
||||
@@ -197,7 +204,7 @@ describe('App UI', () => {
|
||||
showMemoryUsage: false,
|
||||
sessionId: 'test-session-id',
|
||||
cwd: '/tmp',
|
||||
// Provide other required fields for ConfigParameters if necessary
|
||||
model: 'model',
|
||||
}) as unknown as MockServerConfig;
|
||||
|
||||
// Ensure the getShowMemoryUsage mock function is specifically set up if not covered by constructor mock
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useTerminalSize } from './hooks/useTerminalSize.js';
|
||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
||||
import { useAuthCommand } from './hooks/useAuthCommand.js';
|
||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
|
||||
@@ -31,6 +32,7 @@ import { ShellModeIndicator } from './components/ShellModeIndicator.js';
|
||||
import { InputPrompt } from './components/InputPrompt.js';
|
||||
import { Footer } from './components/Footer.js';
|
||||
import { ThemeDialog } from './components/ThemeDialog.js';
|
||||
import { AuthDialog } from './components/AuthDialog.js';
|
||||
import { EditorSettingsDialog } from './components/EditorSettingsDialog.js';
|
||||
import { Colors } from './colors.js';
|
||||
import { Help } from './components/Help.js';
|
||||
@@ -51,6 +53,7 @@ import {
|
||||
isEditorAvailable,
|
||||
EditorType,
|
||||
} from '@gemini-cli/core';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
import { useLogger } from './hooks/useLogger.js';
|
||||
import { StreamingContext } from './contexts/StreamingContext.js';
|
||||
import {
|
||||
@@ -101,6 +104,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||
const [debugMessage, setDebugMessage] = useState<string>('');
|
||||
const [showHelp, setShowHelp] = useState<boolean>(false);
|
||||
const [themeError, setThemeError] = useState<string | null>(null);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const [editorError, setEditorError] = useState<string | null>(null);
|
||||
const [footerHeight, setFooterHeight] = useState<number>(0);
|
||||
const [corgiMode, setCorgiMode] = useState(false);
|
||||
@@ -129,6 +133,23 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||
handleThemeHighlight,
|
||||
} = useThemeCommand(settings, setThemeError, addItem);
|
||||
|
||||
const {
|
||||
isAuthDialogOpen,
|
||||
openAuthDialog,
|
||||
handleAuthSelect,
|
||||
handleAuthHighlight,
|
||||
} = useAuthCommand(settings, setAuthError, config);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.merged.selectedAuthType) {
|
||||
const error = validateAuthMethod(settings.merged.selectedAuthType);
|
||||
if (error) {
|
||||
setAuthError(error);
|
||||
openAuthDialog();
|
||||
}
|
||||
}
|
||||
}, [settings.merged.selectedAuthType, openAuthDialog, setAuthError]);
|
||||
|
||||
const {
|
||||
isEditorDialogOpen,
|
||||
openEditorDialog,
|
||||
@@ -197,6 +218,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||
setShowHelp,
|
||||
setDebugMessage,
|
||||
openThemeDialog,
|
||||
openAuthDialog,
|
||||
openEditorDialog,
|
||||
performMemoryRefresh,
|
||||
toggleCorgiMode,
|
||||
@@ -306,6 +328,11 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||
return editorType as EditorType;
|
||||
}, [settings, openEditorDialog]);
|
||||
|
||||
const onAuthError = useCallback(() => {
|
||||
setAuthError('reauth required');
|
||||
openAuthDialog();
|
||||
}, [openAuthDialog, setAuthError]);
|
||||
|
||||
const {
|
||||
streamingState,
|
||||
submitQuery,
|
||||
@@ -322,6 +349,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||
handleSlashCommand,
|
||||
shellModeActive,
|
||||
getPreferredEditor,
|
||||
onAuthError,
|
||||
);
|
||||
pendingHistoryItems.push(...pendingGeminiHistoryItems);
|
||||
const { elapsedTime, currentLoadingPhrase } =
|
||||
@@ -557,6 +585,20 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||
terminalWidth={mainAreaWidth}
|
||||
/>
|
||||
</Box>
|
||||
) : isAuthDialogOpen ? (
|
||||
<Box flexDirection="column">
|
||||
{authError && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={Colors.AccentRed}>{authError}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<AuthDialog
|
||||
onSelect={handleAuthSelect}
|
||||
onHighlight={handleAuthHighlight}
|
||||
settings={settings}
|
||||
initialErrorMessage={authError}
|
||||
/>
|
||||
</Box>
|
||||
) : isEditorDialogOpen ? (
|
||||
<Box flexDirection="column">
|
||||
{editorError && (
|
||||
|
||||
41
packages/cli/src/ui/components/AuthDialog.test.tsx
Normal file
41
packages/cli/src/ui/components/AuthDialog.test.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { AuthDialog } from './AuthDialog.js';
|
||||
import { LoadedSettings } from '../../config/settings.js';
|
||||
import { AuthType } from '@gemini-cli/core';
|
||||
|
||||
describe('AuthDialog', () => {
|
||||
it('should show an error if the initial auth type is invalid', () => {
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: AuthType.USE_GEMINI,
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<AuthDialog
|
||||
onSelect={() => {}}
|
||||
onHighlight={() => {}}
|
||||
settings={settings}
|
||||
initialErrorMessage="GEMINI_API_KEY environment variable not found"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain(
|
||||
'GEMINI_API_KEY environment variable not found',
|
||||
);
|
||||
});
|
||||
});
|
||||
94
packages/cli/src/ui/components/AuthDialog.tsx
Normal file
94
packages/cli/src/ui/components/AuthDialog.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import { AuthType } from '@gemini-cli/core';
|
||||
import { validateAuthMethod } from '../../config/auth.js';
|
||||
|
||||
interface AuthDialogProps {
|
||||
onSelect: (authMethod: string | undefined, scope: SettingScope) => void;
|
||||
onHighlight: (authMethod: string | undefined) => void;
|
||||
settings: LoadedSettings;
|
||||
initialErrorMessage?: string | null;
|
||||
}
|
||||
|
||||
export function AuthDialog({
|
||||
onSelect,
|
||||
onHighlight,
|
||||
settings,
|
||||
initialErrorMessage,
|
||||
}: AuthDialogProps): React.JSX.Element {
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(
|
||||
initialErrorMessage || null,
|
||||
);
|
||||
const authItems = [
|
||||
{
|
||||
label: 'Login with Google Personal Account',
|
||||
value: AuthType.LOGIN_WITH_GOOGLE_PERSONAL,
|
||||
},
|
||||
{ label: 'Gemini API Key', value: AuthType.USE_GEMINI },
|
||||
{
|
||||
label: 'Login with GCP Project and Google Work Account',
|
||||
value: AuthType.LOGIN_WITH_GOOGLE_ENTERPRISE,
|
||||
},
|
||||
{ label: 'Vertex AI', value: AuthType.USE_VERTEX_AI },
|
||||
];
|
||||
|
||||
let initialAuthIndex = authItems.findIndex(
|
||||
(item) => item.value === settings.merged.selectedAuthType,
|
||||
);
|
||||
|
||||
if (initialAuthIndex === -1) {
|
||||
initialAuthIndex = 0;
|
||||
}
|
||||
|
||||
const handleAuthSelect = (authMethod: string) => {
|
||||
const error = validateAuthMethod(authMethod);
|
||||
if (error) {
|
||||
setErrorMessage(error);
|
||||
} else {
|
||||
setErrorMessage(null);
|
||||
onSelect(authMethod, SettingScope.User);
|
||||
}
|
||||
};
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (key.escape) {
|
||||
onSelect(undefined, SettingScope.User);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.Gray}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold>Select Auth Method</Text>
|
||||
<RadioButtonSelect
|
||||
items={authItems}
|
||||
initialIndex={initialAuthIndex}
|
||||
onSelect={handleAuthSelect}
|
||||
onHighlight={onHighlight}
|
||||
isFocused={true}
|
||||
/>
|
||||
{errorMessage && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentRed}>{errorMessage}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>(Use Enter to select)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -103,6 +103,7 @@ describe('useSlashCommandProcessor', () => {
|
||||
let mockSetShowHelp: ReturnType<typeof vi.fn>;
|
||||
let mockOnDebugMessage: ReturnType<typeof vi.fn>;
|
||||
let mockOpenThemeDialog: ReturnType<typeof vi.fn>;
|
||||
let mockOpenAuthDialog: ReturnType<typeof vi.fn>;
|
||||
let mockOpenEditorDialog: ReturnType<typeof vi.fn>;
|
||||
let mockPerformMemoryRefresh: ReturnType<typeof vi.fn>;
|
||||
let mockSetQuittingMessages: ReturnType<typeof vi.fn>;
|
||||
@@ -120,6 +121,7 @@ describe('useSlashCommandProcessor', () => {
|
||||
mockSetShowHelp = vi.fn();
|
||||
mockOnDebugMessage = vi.fn();
|
||||
mockOpenThemeDialog = vi.fn();
|
||||
mockOpenAuthDialog = vi.fn();
|
||||
mockOpenEditorDialog = vi.fn();
|
||||
mockPerformMemoryRefresh = vi.fn().mockResolvedValue(undefined);
|
||||
mockSetQuittingMessages = vi.fn();
|
||||
@@ -171,6 +173,7 @@ describe('useSlashCommandProcessor', () => {
|
||||
mockSetShowHelp,
|
||||
mockOnDebugMessage,
|
||||
mockOpenThemeDialog,
|
||||
mockOpenAuthDialog,
|
||||
mockOpenEditorDialog,
|
||||
mockPerformMemoryRefresh,
|
||||
mockCorgiMode,
|
||||
|
||||
@@ -68,6 +68,7 @@ export const useSlashCommandProcessor = (
|
||||
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
onDebugMessage: (message: string) => void,
|
||||
openThemeDialog: () => void,
|
||||
openAuthDialog: () => void,
|
||||
openEditorDialog: () => void,
|
||||
performMemoryRefresh: () => Promise<void>,
|
||||
toggleCorgiMode: () => void,
|
||||
@@ -197,6 +198,13 @@ export const useSlashCommandProcessor = (
|
||||
openThemeDialog();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'auth',
|
||||
description: 'change the auth method',
|
||||
action: (_mainCommand, _subCommand, _args) => {
|
||||
openAuthDialog();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'editor',
|
||||
description: 'set external editor preference',
|
||||
@@ -907,6 +915,7 @@ Add any other context about the problem here.
|
||||
setShowHelp,
|
||||
refreshStatic,
|
||||
openThemeDialog,
|
||||
openAuthDialog,
|
||||
openEditorDialog,
|
||||
clearItems,
|
||||
performMemoryRefresh,
|
||||
|
||||
57
packages/cli/src/ui/hooks/useAuthCommand.ts
Normal file
57
packages/cli/src/ui/hooks/useAuthCommand.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import { AuthType, Config, clearCachedCredentialFile } from '@gemini-cli/core';
|
||||
|
||||
async function performAuthFlow(authMethod: AuthType, config: Config) {
|
||||
await config.refreshAuth(authMethod);
|
||||
console.log(`Authenticated via "${authMethod}".`);
|
||||
}
|
||||
|
||||
export const useAuthCommand = (
|
||||
settings: LoadedSettings,
|
||||
setAuthError: (error: string | null) => void,
|
||||
config: Config,
|
||||
) => {
|
||||
const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(
|
||||
settings.merged.selectedAuthType === undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthDialogOpen) {
|
||||
performAuthFlow(settings.merged.selectedAuthType as AuthType, config);
|
||||
}
|
||||
}, [isAuthDialogOpen, settings, config]);
|
||||
|
||||
const openAuthDialog = useCallback(() => {
|
||||
setIsAuthDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleAuthSelect = useCallback(
|
||||
async (authMethod: string | undefined, scope: SettingScope) => {
|
||||
if (authMethod) {
|
||||
await clearCachedCredentialFile();
|
||||
settings.setValue(scope, 'selectedAuthType', authMethod);
|
||||
}
|
||||
setIsAuthDialogOpen(false);
|
||||
setAuthError(null);
|
||||
},
|
||||
[settings, setAuthError],
|
||||
);
|
||||
|
||||
const handleAuthHighlight = useCallback((_authMethod: string | undefined) => {
|
||||
// For now, we don't do anything on highlight.
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isAuthDialogOpen,
|
||||
openAuthDialog,
|
||||
handleAuthSelect,
|
||||
handleAuthHighlight,
|
||||
};
|
||||
};
|
||||
@@ -359,6 +359,7 @@ describe('useGeminiStream', () => {
|
||||
props.handleSlashCommand,
|
||||
props.shellModeActive,
|
||||
() => 'vscode' as EditorType,
|
||||
() => {},
|
||||
),
|
||||
{
|
||||
initialProps: {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
GitService,
|
||||
EditorType,
|
||||
ThoughtSummary,
|
||||
isAuthError,
|
||||
} from '@gemini-cli/core';
|
||||
import { type Part, type PartListUnion } from '@google/genai';
|
||||
import {
|
||||
@@ -87,6 +88,7 @@ export const useGeminiStream = (
|
||||
>,
|
||||
shellModeActive: boolean,
|
||||
getPreferredEditor: () => EditorType | undefined,
|
||||
onAuthError: () => void,
|
||||
) => {
|
||||
const [initError, setInitError] = useState<string | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
@@ -496,7 +498,9 @@ export const useGeminiStream = (
|
||||
setPendingHistoryItem(null);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!isNodeError(error) || error.name !== 'AbortError') {
|
||||
if (isAuthError(error)) {
|
||||
onAuthError();
|
||||
} else if (!isNodeError(error) || error.name !== 'AbortError') {
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
@@ -522,6 +526,7 @@ export const useGeminiStream = (
|
||||
setInitError,
|
||||
geminiClient,
|
||||
startNewTurn,
|
||||
onAuthError,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user