mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 08:47:44 +00:00
Merge branch 'main' of github.com:QwenLM/qwen-code into mingholy/feat/cli-sdk
This commit is contained in:
@@ -14,6 +14,13 @@ This guide provides solutions to common issues and debugging tips, including top
|
|||||||
- **Solution:** Set the `NODE_EXTRA_CA_CERTS` environment variable to the absolute path of your corporate root CA certificate file.
|
- **Solution:** Set the `NODE_EXTRA_CA_CERTS` environment variable to the absolute path of your corporate root CA certificate file.
|
||||||
- Example: `export NODE_EXTRA_CA_CERTS=/path/to/your/corporate-ca.crt`
|
- Example: `export NODE_EXTRA_CA_CERTS=/path/to/your/corporate-ca.crt`
|
||||||
|
|
||||||
|
- **Issue: Unable to display UI after authentication failure**
|
||||||
|
- **Cause:** If authentication fails after selecting an authentication type, the `security.auth.selectedType` setting may be persisted in `settings.json`. On restart, the CLI may get stuck trying to authenticate with the failed auth type and fail to display the UI.
|
||||||
|
- **Solution:** Clear the `security.auth.selectedType` configuration item in your `settings.json` file:
|
||||||
|
- Open `~/.qwen/settings.json` (or `./.qwen/settings.json` for project-specific settings)
|
||||||
|
- Remove the `security.auth.selectedType` field
|
||||||
|
- Restart the CLI to allow it to prompt for authentication again
|
||||||
|
|
||||||
## Frequently asked questions (FAQs)
|
## Frequently asked questions (FAQs)
|
||||||
|
|
||||||
- **Q: How do I update Qwen Code to the latest version?**
|
- **Q: How do I update Qwen Code to the latest version?**
|
||||||
|
|||||||
@@ -21,23 +21,21 @@ describe('Interactive Mode', () => {
|
|||||||
it.skipIf(process.platform === 'win32')(
|
it.skipIf(process.platform === 'win32')(
|
||||||
'should trigger chat compression with /compress command',
|
'should trigger chat compression with /compress command',
|
||||||
async () => {
|
async () => {
|
||||||
await rig.setup('interactive-compress-test');
|
await rig.setup('interactive-compress-test', {
|
||||||
|
settings: {
|
||||||
|
security: {
|
||||||
|
auth: {
|
||||||
|
selectedType: 'openai',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const { ptyProcess } = rig.runInteractive();
|
const { ptyProcess } = rig.runInteractive();
|
||||||
|
|
||||||
let fullOutput = '';
|
let fullOutput = '';
|
||||||
ptyProcess.onData((data) => (fullOutput += data));
|
ptyProcess.onData((data) => (fullOutput += data));
|
||||||
|
|
||||||
const authDialogAppeared = await rig.waitForText(
|
|
||||||
'How would you like to authenticate',
|
|
||||||
5000,
|
|
||||||
);
|
|
||||||
|
|
||||||
// select the second option if auth dialog come's up
|
|
||||||
if (authDialogAppeared) {
|
|
||||||
ptyProcess.write('2');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for the app to be ready
|
// Wait for the app to be ready
|
||||||
const isReady = await rig.waitForText('Type your message', 15000);
|
const isReady = await rig.waitForText('Type your message', 15000);
|
||||||
expect(
|
expect(
|
||||||
@@ -68,35 +66,30 @@ describe('Interactive Mode', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it.skipIf(process.platform === 'win32')(
|
it.skip('should handle compression failure on token inflation', async () => {
|
||||||
'should handle compression failure on token inflation',
|
await rig.setup('interactive-compress-test', {
|
||||||
async () => {
|
settings: {
|
||||||
await rig.setup('interactive-compress-test');
|
security: {
|
||||||
|
auth: {
|
||||||
|
selectedType: 'openai',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const { ptyProcess } = rig.runInteractive();
|
const { ptyProcess } = rig.runInteractive();
|
||||||
|
|
||||||
let fullOutput = '';
|
let fullOutput = '';
|
||||||
ptyProcess.onData((data) => (fullOutput += data));
|
ptyProcess.onData((data) => (fullOutput += data));
|
||||||
|
|
||||||
const authDialogAppeared = await rig.waitForText(
|
|
||||||
'How would you like to authenticate',
|
|
||||||
5000,
|
|
||||||
);
|
|
||||||
|
|
||||||
// select the second option if auth dialog come's up
|
|
||||||
if (authDialogAppeared) {
|
|
||||||
ptyProcess.write('2');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for the app to be ready
|
// Wait for the app to be ready
|
||||||
const isReady = await rig.waitForText('Type your message', 25000);
|
const isReady = await rig.waitForText('Type your message', 25000);
|
||||||
expect(
|
expect(isReady, 'CLI did not start up in interactive mode correctly').toBe(
|
||||||
isReady,
|
true,
|
||||||
'CLI did not start up in interactive mode correctly',
|
);
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
await type(ptyProcess, '/compress');
|
await type(ptyProcess, '/compress');
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
await type(ptyProcess, '\r');
|
await type(ptyProcess, '\r');
|
||||||
|
|
||||||
const foundEvent = await rig.waitForTelemetryEvent(
|
const foundEvent = await rig.waitForTelemetryEvent(
|
||||||
@@ -106,11 +99,10 @@ describe('Interactive Mode', () => {
|
|||||||
expect(foundEvent).toBe(true);
|
expect(foundEvent).toBe(true);
|
||||||
|
|
||||||
const compressionFailed = await rig.waitForText(
|
const compressionFailed = await rig.waitForText(
|
||||||
'compression was not beneficial',
|
'Nothing to compress.',
|
||||||
25000,
|
25000,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(compressionFailed).toBe(true);
|
expect(compressionFailed).toBe(true);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,21 +22,19 @@ describe('Interactive file system', () => {
|
|||||||
'should perform a read-then-write sequence in interactive mode',
|
'should perform a read-then-write sequence in interactive mode',
|
||||||
async () => {
|
async () => {
|
||||||
const fileName = 'version.txt';
|
const fileName = 'version.txt';
|
||||||
await rig.setup('interactive-read-then-write');
|
await rig.setup('interactive-read-then-write', {
|
||||||
|
settings: {
|
||||||
|
security: {
|
||||||
|
auth: {
|
||||||
|
selectedType: 'openai',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
rig.createFile(fileName, '1.0.0');
|
rig.createFile(fileName, '1.0.0');
|
||||||
|
|
||||||
const { ptyProcess } = rig.runInteractive();
|
const { ptyProcess } = rig.runInteractive();
|
||||||
|
|
||||||
const authDialogAppeared = await rig.waitForText(
|
|
||||||
'How would you like to authenticate',
|
|
||||||
5000,
|
|
||||||
);
|
|
||||||
|
|
||||||
// select the second option if auth dialog come's up
|
|
||||||
if (authDialogAppeared) {
|
|
||||||
ptyProcess.write('2');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for the app to be ready
|
// Wait for the app to be ready
|
||||||
const isReady = await rig.waitForText('Type your message', 15000);
|
const isReady = await rig.waitForText('Type your message', 15000);
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -860,5 +860,6 @@ export function saveSettings(settingsFile: SettingsFile): void {
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving user settings file:', error);
|
console.error('Error saving user settings file:', error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
type AuthType,
|
type AuthType,
|
||||||
type Config,
|
type Config,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
|
logAuth,
|
||||||
|
AuthEvent,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,11 +27,21 @@ export async function performInitialAuth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await config.refreshAuth(authType);
|
await config.refreshAuth(authType, true);
|
||||||
// The console.log is intentionally left out here.
|
// The console.log is intentionally left out here.
|
||||||
// We can add a dedicated startup message later if needed.
|
// We can add a dedicated startup message later if needed.
|
||||||
|
|
||||||
|
// Log authentication success
|
||||||
|
const authEvent = new AuthEvent(authType, 'auto', 'success');
|
||||||
|
logAuth(config, authEvent);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return `Failed to login. Message: ${getErrorMessage(e)}`;
|
const errorMessage = `Failed to login. Message: ${getErrorMessage(e)}`;
|
||||||
|
|
||||||
|
// Log authentication failure
|
||||||
|
const authEvent = new AuthEvent(authType, 'auto', 'error', errorMessage);
|
||||||
|
logAuth(config, authEvent);
|
||||||
|
|
||||||
|
return errorMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
logIdeConnection,
|
logIdeConnection,
|
||||||
type Config,
|
type Config,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { type LoadedSettings } from '../config/settings.js';
|
import { type LoadedSettings, SettingScope } from '../config/settings.js';
|
||||||
import { performInitialAuth } from './auth.js';
|
import { performInitialAuth } from './auth.js';
|
||||||
import { validateTheme } from './theme.js';
|
import { validateTheme } from './theme.js';
|
||||||
|
|
||||||
@@ -33,10 +33,18 @@ export async function initializeApp(
|
|||||||
config: Config,
|
config: Config,
|
||||||
settings: LoadedSettings,
|
settings: LoadedSettings,
|
||||||
): Promise<InitializationResult> {
|
): Promise<InitializationResult> {
|
||||||
const authError = await performInitialAuth(
|
const authType = settings.merged.security?.auth?.selectedType;
|
||||||
config,
|
const authError = await performInitialAuth(config, authType);
|
||||||
settings.merged.security?.auth?.selectedType,
|
|
||||||
|
// Fallback to user select when initial authentication fails
|
||||||
|
if (authError) {
|
||||||
|
settings.setValue(
|
||||||
|
SettingScope.User,
|
||||||
|
'security.auth.selectedType',
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const themeError = validateTheme(settings);
|
const themeError = validateTheme(settings);
|
||||||
|
|
||||||
const shouldOpenAuthDialog =
|
const shouldOpenAuthDialog =
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
type HistoryItem,
|
type HistoryItem,
|
||||||
ToolCallStatus,
|
ToolCallStatus,
|
||||||
type HistoryItemWithoutId,
|
type HistoryItemWithoutId,
|
||||||
AuthState,
|
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { MessageType, StreamingState } from './types.js';
|
import { MessageType, StreamingState } from './types.js';
|
||||||
import {
|
import {
|
||||||
@@ -48,7 +47,6 @@ import { useHistory } from './hooks/useHistoryManager.js';
|
|||||||
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
|
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
|
||||||
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
||||||
import { useAuthCommand } from './auth/useAuth.js';
|
import { useAuthCommand } from './auth/useAuth.js';
|
||||||
import { useQwenAuth } from './hooks/useQwenAuth.js';
|
|
||||||
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
||||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||||
@@ -93,6 +91,7 @@ import { ShellFocusContext } from './contexts/ShellFocusContext.js';
|
|||||||
import { useQuitConfirmation } from './hooks/useQuitConfirmation.js';
|
import { useQuitConfirmation } from './hooks/useQuitConfirmation.js';
|
||||||
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
|
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
|
||||||
import { useDialogClose } from './hooks/useDialogClose.js';
|
import { useDialogClose } from './hooks/useDialogClose.js';
|
||||||
|
import { useInitializationAuthError } from './hooks/useInitializationAuthError.js';
|
||||||
import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js';
|
import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js';
|
||||||
import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js';
|
import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js';
|
||||||
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
|
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
|
||||||
@@ -349,20 +348,13 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
onAuthError,
|
onAuthError,
|
||||||
isAuthDialogOpen,
|
isAuthDialogOpen,
|
||||||
isAuthenticating,
|
isAuthenticating,
|
||||||
|
pendingAuthType,
|
||||||
|
qwenAuthState,
|
||||||
handleAuthSelect,
|
handleAuthSelect,
|
||||||
openAuthDialog,
|
openAuthDialog,
|
||||||
|
cancelAuthentication,
|
||||||
} = useAuthCommand(settings, config);
|
} = useAuthCommand(settings, config);
|
||||||
|
|
||||||
// Qwen OAuth authentication state
|
|
||||||
const {
|
|
||||||
isQwenAuth,
|
|
||||||
isQwenAuthenticating,
|
|
||||||
deviceAuth,
|
|
||||||
authStatus,
|
|
||||||
authMessage,
|
|
||||||
cancelQwenAuth,
|
|
||||||
} = useQwenAuth(settings, isAuthenticating);
|
|
||||||
|
|
||||||
const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({
|
const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({
|
||||||
config,
|
config,
|
||||||
historyManager,
|
historyManager,
|
||||||
@@ -371,19 +363,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
setModelSwitchedFromQuotaError,
|
setModelSwitchedFromQuotaError,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle Qwen OAuth timeout
|
useInitializationAuthError(initializationResult.authError, onAuthError);
|
||||||
const handleQwenAuthTimeout = useCallback(() => {
|
|
||||||
onAuthError('Qwen OAuth authentication timed out. Please try again.');
|
|
||||||
cancelQwenAuth();
|
|
||||||
setAuthState(AuthState.Updating);
|
|
||||||
}, [onAuthError, cancelQwenAuth, setAuthState]);
|
|
||||||
|
|
||||||
// Handle Qwen OAuth cancel
|
|
||||||
const handleQwenAuthCancel = useCallback(() => {
|
|
||||||
onAuthError('Qwen OAuth authentication cancelled.');
|
|
||||||
cancelQwenAuth();
|
|
||||||
setAuthState(AuthState.Updating);
|
|
||||||
}, [onAuthError, cancelQwenAuth, setAuthState]);
|
|
||||||
|
|
||||||
// Sync user tier from config when authentication changes
|
// Sync user tier from config when authentication changes
|
||||||
// TODO: Implement getUserTier() method on Config if needed
|
// TODO: Implement getUserTier() method on Config if needed
|
||||||
@@ -395,6 +375,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
|
|
||||||
// Check for enforced auth type mismatch
|
// Check for enforced auth type mismatch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Check for initialization error first
|
||||||
|
|
||||||
if (
|
if (
|
||||||
settings.merged.security?.auth?.enforcedType &&
|
settings.merged.security?.auth?.enforcedType &&
|
||||||
settings.merged.security?.auth.selectedType &&
|
settings.merged.security?.auth.selectedType &&
|
||||||
@@ -959,7 +941,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
handleApprovalModeSelect,
|
handleApprovalModeSelect,
|
||||||
isAuthDialogOpen,
|
isAuthDialogOpen,
|
||||||
handleAuthSelect,
|
handleAuthSelect,
|
||||||
selectedAuthType: settings.merged.security?.auth?.selectedType,
|
pendingAuthType,
|
||||||
isEditorDialogOpen,
|
isEditorDialogOpen,
|
||||||
exitEditorDialog,
|
exitEditorDialog,
|
||||||
isSettingsDialogOpen,
|
isSettingsDialogOpen,
|
||||||
@@ -1201,7 +1183,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
isVisionSwitchDialogOpen ||
|
isVisionSwitchDialogOpen ||
|
||||||
isPermissionsDialogOpen ||
|
isPermissionsDialogOpen ||
|
||||||
isAuthDialogOpen ||
|
isAuthDialogOpen ||
|
||||||
(isAuthenticating && isQwenAuthenticating) ||
|
isAuthenticating ||
|
||||||
isEditorDialogOpen ||
|
isEditorDialogOpen ||
|
||||||
showIdeRestartPrompt ||
|
showIdeRestartPrompt ||
|
||||||
!!proQuotaRequest ||
|
!!proQuotaRequest ||
|
||||||
@@ -1224,12 +1206,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
isConfigInitialized,
|
isConfigInitialized,
|
||||||
authError,
|
authError,
|
||||||
isAuthDialogOpen,
|
isAuthDialogOpen,
|
||||||
|
pendingAuthType,
|
||||||
// Qwen OAuth state
|
// Qwen OAuth state
|
||||||
isQwenAuth,
|
qwenAuthState,
|
||||||
isQwenAuthenticating,
|
|
||||||
deviceAuth,
|
|
||||||
authStatus,
|
|
||||||
authMessage,
|
|
||||||
editorError,
|
editorError,
|
||||||
isEditorDialogOpen,
|
isEditorDialogOpen,
|
||||||
corgiMode,
|
corgiMode,
|
||||||
@@ -1319,12 +1298,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
isConfigInitialized,
|
isConfigInitialized,
|
||||||
authError,
|
authError,
|
||||||
isAuthDialogOpen,
|
isAuthDialogOpen,
|
||||||
|
pendingAuthType,
|
||||||
// Qwen OAuth state
|
// Qwen OAuth state
|
||||||
isQwenAuth,
|
qwenAuthState,
|
||||||
isQwenAuthenticating,
|
|
||||||
deviceAuth,
|
|
||||||
authStatus,
|
|
||||||
authMessage,
|
|
||||||
editorError,
|
editorError,
|
||||||
isEditorDialogOpen,
|
isEditorDialogOpen,
|
||||||
corgiMode,
|
corgiMode,
|
||||||
@@ -1418,9 +1394,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
handleAuthSelect,
|
handleAuthSelect,
|
||||||
setAuthState,
|
setAuthState,
|
||||||
onAuthError,
|
onAuthError,
|
||||||
// Qwen OAuth handlers
|
cancelAuthentication,
|
||||||
handleQwenAuthTimeout,
|
|
||||||
handleQwenAuthCancel,
|
|
||||||
handleEditorSelect,
|
handleEditorSelect,
|
||||||
exitEditorDialog,
|
exitEditorDialog,
|
||||||
closeSettingsDialog,
|
closeSettingsDialog,
|
||||||
@@ -1454,9 +1428,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
handleAuthSelect,
|
handleAuthSelect,
|
||||||
setAuthState,
|
setAuthState,
|
||||||
onAuthError,
|
onAuthError,
|
||||||
// Qwen OAuth handlers
|
cancelAuthentication,
|
||||||
handleQwenAuthTimeout,
|
|
||||||
handleQwenAuthCancel,
|
|
||||||
handleEditorSelect,
|
handleEditorSelect,
|
||||||
exitEditorDialog,
|
exitEditorDialog,
|
||||||
closeSettingsDialog,
|
closeSettingsDialog,
|
||||||
|
|||||||
@@ -9,6 +9,53 @@ import { AuthDialog } from './AuthDialog.js';
|
|||||||
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||||
import { renderWithProviders } from '../../test-utils/render.js';
|
import { renderWithProviders } from '../../test-utils/render.js';
|
||||||
|
import { UIStateContext } from '../contexts/UIStateContext.js';
|
||||||
|
import { UIActionsContext } from '../contexts/UIActionsContext.js';
|
||||||
|
import type { UIState } from '../contexts/UIStateContext.js';
|
||||||
|
import type { UIActions } from '../contexts/UIActionsContext.js';
|
||||||
|
|
||||||
|
const createMockUIState = (overrides: Partial<UIState> = {}): UIState => {
|
||||||
|
// AuthDialog only uses authError and pendingAuthType
|
||||||
|
const baseState = {
|
||||||
|
authError: null,
|
||||||
|
pendingAuthType: undefined,
|
||||||
|
} as Partial<UIState>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseState,
|
||||||
|
...overrides,
|
||||||
|
} as UIState;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMockUIActions = (overrides: Partial<UIActions> = {}): UIActions => {
|
||||||
|
// AuthDialog only uses handleAuthSelect
|
||||||
|
const baseActions = {
|
||||||
|
handleAuthSelect: vi.fn(),
|
||||||
|
} as Partial<UIActions>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseActions,
|
||||||
|
...overrides,
|
||||||
|
} as UIActions;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAuthDialog = (
|
||||||
|
settings: LoadedSettings,
|
||||||
|
uiStateOverrides: Partial<UIState> = {},
|
||||||
|
uiActionsOverrides: Partial<UIActions> = {},
|
||||||
|
) => {
|
||||||
|
const uiState = createMockUIState(uiStateOverrides);
|
||||||
|
const uiActions = createMockUIActions(uiActionsOverrides);
|
||||||
|
|
||||||
|
return renderWithProviders(
|
||||||
|
<UIStateContext.Provider value={uiState}>
|
||||||
|
<UIActionsContext.Provider value={uiActions}>
|
||||||
|
<AuthDialog />
|
||||||
|
</UIActionsContext.Provider>
|
||||||
|
</UIStateContext.Provider>,
|
||||||
|
{ settings },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
describe('AuthDialog', () => {
|
describe('AuthDialog', () => {
|
||||||
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
|
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
@@ -66,13 +113,9 @@ describe('AuthDialog', () => {
|
|||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { lastFrame } = renderWithProviders(
|
const { lastFrame } = renderAuthDialog(settings, {
|
||||||
<AuthDialog
|
authError: 'GEMINI_API_KEY environment variable not found',
|
||||||
onSelect={() => {}}
|
});
|
||||||
settings={settings}
|
|
||||||
initialErrorMessage="GEMINI_API_KEY environment variable not found"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(lastFrame()).toContain(
|
expect(lastFrame()).toContain(
|
||||||
'GEMINI_API_KEY environment variable not found',
|
'GEMINI_API_KEY environment variable not found',
|
||||||
@@ -116,9 +159,7 @@ describe('AuthDialog', () => {
|
|||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { lastFrame } = renderWithProviders(
|
const { lastFrame } = renderAuthDialog(settings);
|
||||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Since the auth dialog only shows OpenAI option now,
|
// Since the auth dialog only shows OpenAI option now,
|
||||||
// it won't show GEMINI_API_KEY messages
|
// it won't show GEMINI_API_KEY messages
|
||||||
@@ -162,9 +203,7 @@ describe('AuthDialog', () => {
|
|||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { lastFrame } = renderWithProviders(
|
const { lastFrame } = renderAuthDialog(settings);
|
||||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(lastFrame()).not.toContain(
|
expect(lastFrame()).not.toContain(
|
||||||
'Existing API key detected (GEMINI_API_KEY)',
|
'Existing API key detected (GEMINI_API_KEY)',
|
||||||
@@ -208,9 +247,7 @@ describe('AuthDialog', () => {
|
|||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { lastFrame } = renderWithProviders(
|
const { lastFrame } = renderAuthDialog(settings);
|
||||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Since the auth dialog only shows OpenAI option now,
|
// Since the auth dialog only shows OpenAI option now,
|
||||||
// it won't show GEMINI_API_KEY messages
|
// it won't show GEMINI_API_KEY messages
|
||||||
@@ -255,9 +292,7 @@ describe('AuthDialog', () => {
|
|||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { lastFrame } = renderWithProviders(
|
const { lastFrame } = renderAuthDialog(settings);
|
||||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
// This is a bit brittle, but it's the best way to check which item is selected.
|
// This is a bit brittle, but it's the best way to check which item is selected.
|
||||||
expect(lastFrame()).toContain('● 2. OpenAI');
|
expect(lastFrame()).toContain('● 2. OpenAI');
|
||||||
@@ -297,9 +332,7 @@ describe('AuthDialog', () => {
|
|||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { lastFrame } = renderWithProviders(
|
const { lastFrame } = renderAuthDialog(settings);
|
||||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Default is Qwen OAuth (first option)
|
// Default is Qwen OAuth (first option)
|
||||||
expect(lastFrame()).toContain('● 1. Qwen OAuth');
|
expect(lastFrame()).toContain('● 1. Qwen OAuth');
|
||||||
@@ -341,9 +374,7 @@ describe('AuthDialog', () => {
|
|||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { lastFrame } = renderWithProviders(
|
const { lastFrame } = renderAuthDialog(settings);
|
||||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Since the auth dialog doesn't show QWEN_DEFAULT_AUTH_TYPE errors anymore,
|
// Since the auth dialog doesn't show QWEN_DEFAULT_AUTH_TYPE errors anymore,
|
||||||
// it will just show the default Qwen OAuth option
|
// it will just show the default Qwen OAuth option
|
||||||
@@ -352,7 +383,7 @@ describe('AuthDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should prevent exiting when no auth method is selected and show error message', async () => {
|
it('should prevent exiting when no auth method is selected and show error message', async () => {
|
||||||
const onSelect = vi.fn();
|
const handleAuthSelect = vi.fn();
|
||||||
const settings: LoadedSettings = new LoadedSettings(
|
const settings: LoadedSettings = new LoadedSettings(
|
||||||
{
|
{
|
||||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||||
@@ -386,8 +417,10 @@ describe('AuthDialog', () => {
|
|||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { lastFrame, stdin, unmount } = renderWithProviders(
|
const { lastFrame, stdin, unmount } = renderAuthDialog(
|
||||||
<AuthDialog onSelect={onSelect} settings={settings} />,
|
settings,
|
||||||
|
{},
|
||||||
|
{ handleAuthSelect },
|
||||||
);
|
);
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
@@ -395,16 +428,16 @@ describe('AuthDialog', () => {
|
|||||||
stdin.write('\u001b'); // ESC key
|
stdin.write('\u001b'); // ESC key
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
// Should show error message instead of calling onSelect
|
// Should show error message instead of calling handleAuthSelect
|
||||||
expect(lastFrame()).toContain(
|
expect(lastFrame()).toContain(
|
||||||
'You must select an auth method to proceed. Press Ctrl+C again to exit.',
|
'You must select an auth method to proceed. Press Ctrl+C again to exit.',
|
||||||
);
|
);
|
||||||
expect(onSelect).not.toHaveBeenCalled();
|
expect(handleAuthSelect).not.toHaveBeenCalled();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not exit if there is already an error message', async () => {
|
it('should not exit if there is already an error message', async () => {
|
||||||
const onSelect = vi.fn();
|
const handleAuthSelect = vi.fn();
|
||||||
const settings: LoadedSettings = new LoadedSettings(
|
const settings: LoadedSettings = new LoadedSettings(
|
||||||
{
|
{
|
||||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||||
@@ -438,12 +471,10 @@ describe('AuthDialog', () => {
|
|||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { lastFrame, stdin, unmount } = renderWithProviders(
|
const { lastFrame, stdin, unmount } = renderAuthDialog(
|
||||||
<AuthDialog
|
settings,
|
||||||
onSelect={onSelect}
|
{ authError: 'Initial error' },
|
||||||
settings={settings}
|
{ handleAuthSelect },
|
||||||
initialErrorMessage="Initial error"
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
@@ -453,13 +484,13 @@ describe('AuthDialog', () => {
|
|||||||
stdin.write('\u001b'); // ESC key
|
stdin.write('\u001b'); // ESC key
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
// Should not call onSelect
|
// Should not call handleAuthSelect
|
||||||
expect(onSelect).not.toHaveBeenCalled();
|
expect(handleAuthSelect).not.toHaveBeenCalled();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow exiting when auth method is already selected', async () => {
|
it('should allow exiting when auth method is already selected', async () => {
|
||||||
const onSelect = vi.fn();
|
const handleAuthSelect = vi.fn();
|
||||||
const settings: LoadedSettings = new LoadedSettings(
|
const settings: LoadedSettings = new LoadedSettings(
|
||||||
{
|
{
|
||||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||||
@@ -493,8 +524,10 @@ describe('AuthDialog', () => {
|
|||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { stdin, unmount } = renderWithProviders(
|
const { stdin, unmount } = renderAuthDialog(
|
||||||
<AuthDialog onSelect={onSelect} settings={settings} />,
|
settings,
|
||||||
|
{},
|
||||||
|
{ handleAuthSelect },
|
||||||
);
|
);
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
@@ -502,8 +535,8 @@ describe('AuthDialog', () => {
|
|||||||
stdin.write('\u001b'); // ESC key
|
stdin.write('\u001b'); // ESC key
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
// Should call onSelect with undefined to exit
|
// Should call handleAuthSelect with undefined to exit
|
||||||
expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
|
expect(handleAuthSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,26 +8,13 @@ import type React from 'react';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { validateAuthMethod } from '../../config/auth.js';
|
import { SettingScope } from '../../config/settings.js';
|
||||||
import { type LoadedSettings, SettingScope } from '../../config/settings.js';
|
|
||||||
import { Colors } from '../colors.js';
|
import { Colors } from '../colors.js';
|
||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
import { OpenAIKeyPrompt } from '../components/OpenAIKeyPrompt.js';
|
|
||||||
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
|
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
|
||||||
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
interface AuthDialogProps {
|
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||||
onSelect: (
|
import { useSettings } from '../contexts/SettingsContext.js';
|
||||||
authMethod: AuthType | undefined,
|
|
||||||
scope: SettingScope,
|
|
||||||
credentials?: {
|
|
||||||
apiKey?: string;
|
|
||||||
baseUrl?: string;
|
|
||||||
model?: string;
|
|
||||||
},
|
|
||||||
) => void;
|
|
||||||
settings: LoadedSettings;
|
|
||||||
initialErrorMessage?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDefaultAuthType(
|
function parseDefaultAuthType(
|
||||||
defaultAuthType: string | undefined,
|
defaultAuthType: string | undefined,
|
||||||
@@ -41,15 +28,14 @@ function parseDefaultAuthType(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthDialog({
|
export function AuthDialog(): React.JSX.Element {
|
||||||
onSelect,
|
const { pendingAuthType, authError } = useUIState();
|
||||||
settings,
|
const { handleAuthSelect: onAuthSelect } = useUIActions();
|
||||||
initialErrorMessage,
|
const settings = useSettings();
|
||||||
}: AuthDialogProps): React.JSX.Element {
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
initialErrorMessage || null,
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||||
);
|
|
||||||
const [showOpenAIKeyPrompt, setShowOpenAIKeyPrompt] = useState(false);
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
key: AuthType.QWEN_OAUTH,
|
key: AuthType.QWEN_OAUTH,
|
||||||
@@ -62,10 +48,17 @@ export function AuthDialog({
|
|||||||
const initialAuthIndex = Math.max(
|
const initialAuthIndex = Math.max(
|
||||||
0,
|
0,
|
||||||
items.findIndex((item) => {
|
items.findIndex((item) => {
|
||||||
|
// Priority 1: pendingAuthType
|
||||||
|
if (pendingAuthType) {
|
||||||
|
return item.value === pendingAuthType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: settings.merged.security?.auth?.selectedType
|
||||||
if (settings.merged.security?.auth?.selectedType) {
|
if (settings.merged.security?.auth?.selectedType) {
|
||||||
return item.value === settings.merged.security?.auth?.selectedType;
|
return item.value === settings.merged.security?.auth?.selectedType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Priority 3: QWEN_DEFAULT_AUTH_TYPE env var
|
||||||
const defaultAuthType = parseDefaultAuthType(
|
const defaultAuthType = parseDefaultAuthType(
|
||||||
process.env['QWEN_DEFAULT_AUTH_TYPE'],
|
process.env['QWEN_DEFAULT_AUTH_TYPE'],
|
||||||
);
|
);
|
||||||
@@ -73,49 +66,29 @@ export function AuthDialog({
|
|||||||
return item.value === defaultAuthType;
|
return item.value === defaultAuthType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Priority 4: default to QWEN_OAUTH
|
||||||
return item.value === AuthType.QWEN_OAUTH;
|
return item.value === AuthType.QWEN_OAUTH;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAuthSelect = (authMethod: AuthType) => {
|
const hasApiKey = Boolean(settings.merged.security?.auth?.apiKey);
|
||||||
if (authMethod === AuthType.USE_OPENAI) {
|
const currentSelectedAuthType =
|
||||||
setShowOpenAIKeyPrompt(true);
|
selectedIndex !== null
|
||||||
|
? items[selectedIndex]?.value
|
||||||
|
: items[initialAuthIndex]?.value;
|
||||||
|
|
||||||
|
const handleAuthSelect = async (authMethod: AuthType) => {
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
} else {
|
await onAuthSelect(authMethod, SettingScope.User);
|
||||||
const error = validateAuthMethod(authMethod);
|
|
||||||
if (error) {
|
|
||||||
setErrorMessage(error);
|
|
||||||
} else {
|
|
||||||
setErrorMessage(null);
|
|
||||||
onSelect(authMethod, SettingScope.User);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenAIKeySubmit = (
|
const handleHighlight = (authMethod: AuthType) => {
|
||||||
apiKey: string,
|
const index = items.findIndex((item) => item.value === authMethod);
|
||||||
baseUrl: string,
|
setSelectedIndex(index);
|
||||||
model: string,
|
|
||||||
) => {
|
|
||||||
setShowOpenAIKeyPrompt(false);
|
|
||||||
onSelect(AuthType.USE_OPENAI, SettingScope.User, {
|
|
||||||
apiKey,
|
|
||||||
baseUrl,
|
|
||||||
model,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenAIKeyCancel = () => {
|
|
||||||
setShowOpenAIKeyPrompt(false);
|
|
||||||
setErrorMessage('OpenAI API key is required to use OpenAI authentication.');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useKeypress(
|
useKeypress(
|
||||||
(key) => {
|
(key) => {
|
||||||
if (showOpenAIKeyPrompt) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === 'escape') {
|
if (key.name === 'escape') {
|
||||||
// Prevent exit if there is an error message.
|
// Prevent exit if there is an error message.
|
||||||
// This means they user is not authenticated yet.
|
// This means they user is not authenticated yet.
|
||||||
@@ -129,33 +102,11 @@ export function AuthDialog({
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSelect(undefined, SettingScope.User);
|
onAuthSelect(undefined, SettingScope.User);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ isActive: true },
|
{ isActive: true },
|
||||||
);
|
);
|
||||||
const getDefaultOpenAIConfig = () => {
|
|
||||||
const fromSettings = settings.merged.security?.auth;
|
|
||||||
const modelSettings = settings.merged.model;
|
|
||||||
return {
|
|
||||||
apiKey: fromSettings?.apiKey || process.env['OPENAI_API_KEY'] || '',
|
|
||||||
baseUrl: fromSettings?.baseUrl || process.env['OPENAI_BASE_URL'] || '',
|
|
||||||
model: modelSettings?.name || process.env['OPENAI_MODEL'] || '',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
if (showOpenAIKeyPrompt) {
|
|
||||||
const defaults = getDefaultOpenAIConfig();
|
|
||||||
return (
|
|
||||||
<OpenAIKeyPrompt
|
|
||||||
defaultApiKey={defaults.apiKey}
|
|
||||||
defaultBaseUrl={defaults.baseUrl}
|
|
||||||
defaultModel={defaults.model}
|
|
||||||
onSubmit={handleOpenAIKeySubmit}
|
|
||||||
onCancel={handleOpenAIKeyCancel}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -174,16 +125,26 @@ export function AuthDialog({
|
|||||||
items={items}
|
items={items}
|
||||||
initialIndex={initialAuthIndex}
|
initialIndex={initialAuthIndex}
|
||||||
onSelect={handleAuthSelect}
|
onSelect={handleAuthSelect}
|
||||||
|
onHighlight={handleHighlight}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{errorMessage && (
|
{(authError || errorMessage) && (
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={Colors.AccentRed}>{errorMessage}</Text>
|
<Text color={Colors.AccentRed}>{authError || errorMessage}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={Colors.AccentPurple}>(Use Enter to Set Auth)</Text>
|
<Text color={Colors.AccentPurple}>(Use Enter to Set Auth)</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
{hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={Colors.Gray}>
|
||||||
|
Note: Your existing API key in settings.json will not be cleared
|
||||||
|
when using Qwen OAuth. You can switch back to OpenAI authentication
|
||||||
|
later if needed.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text>Terms of Services and Privacy Notice for Qwen Code</Text>
|
<Text>Terms of Services and Privacy Notice for Qwen Code</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -6,27 +6,19 @@
|
|||||||
|
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
|
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||||
import type { AuthType, Config } from '@qwen-code/qwen-code-core';
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
import {
|
import {
|
||||||
|
AuthType,
|
||||||
clearCachedCredentialFile,
|
clearCachedCredentialFile,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
|
logAuth,
|
||||||
|
AuthEvent,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { AuthState } from '../types.js';
|
import { AuthState } from '../types.js';
|
||||||
import { validateAuthMethod } from '../../config/auth.js';
|
import { useQwenAuth } from '../hooks/useQwenAuth.js';
|
||||||
|
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
|
||||||
|
|
||||||
export function validateAuthMethodWithSettings(
|
export type { QwenAuthState } from '../hooks/useQwenAuth.js';
|
||||||
authType: AuthType,
|
|
||||||
settings: LoadedSettings,
|
|
||||||
): string | null {
|
|
||||||
const enforcedType = settings.merged.security?.auth?.enforcedType;
|
|
||||||
if (enforcedType && enforcedType !== authType) {
|
|
||||||
return `Authentication is enforced to be ${enforcedType}, but you are currently using ${authType}.`;
|
|
||||||
}
|
|
||||||
if (settings.merged.security?.auth?.useExternal) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return validateAuthMethod(authType);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
||||||
const unAuthenticated =
|
const unAuthenticated =
|
||||||
@@ -40,6 +32,14 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
|||||||
|
|
||||||
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
||||||
const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(unAuthenticated);
|
const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(unAuthenticated);
|
||||||
|
const [pendingAuthType, setPendingAuthType] = useState<AuthType | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { qwenAuthState, cancelQwenAuth } = useQwenAuth(
|
||||||
|
pendingAuthType,
|
||||||
|
isAuthenticating,
|
||||||
|
);
|
||||||
|
|
||||||
const onAuthError = useCallback(
|
const onAuthError = useCallback(
|
||||||
(error: string | null) => {
|
(error: string | null) => {
|
||||||
@@ -52,90 +52,123 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
|||||||
[setAuthError, setAuthState],
|
[setAuthError, setAuthState],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Authentication flow
|
const handleAuthFailure = useCallback(
|
||||||
useEffect(() => {
|
(error: unknown) => {
|
||||||
const authFlow = async () => {
|
|
||||||
const authType = settings.merged.security?.auth?.selectedType;
|
|
||||||
if (isAuthDialogOpen || !authType) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const validationError = validateAuthMethodWithSettings(
|
|
||||||
authType,
|
|
||||||
settings,
|
|
||||||
);
|
|
||||||
if (validationError) {
|
|
||||||
onAuthError(validationError);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsAuthenticating(true);
|
|
||||||
await config.refreshAuth(authType);
|
|
||||||
console.log(`Authenticated via "${authType}".`);
|
|
||||||
setAuthError(null);
|
|
||||||
setAuthState(AuthState.Authenticated);
|
|
||||||
} catch (e) {
|
|
||||||
onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`);
|
|
||||||
} finally {
|
|
||||||
setIsAuthenticating(false);
|
setIsAuthenticating(false);
|
||||||
|
const errorMessage = `Failed to authenticate. Message: ${getErrorMessage(error)}`;
|
||||||
|
onAuthError(errorMessage);
|
||||||
|
|
||||||
|
// Log authentication failure
|
||||||
|
if (pendingAuthType) {
|
||||||
|
const authEvent = new AuthEvent(
|
||||||
|
pendingAuthType,
|
||||||
|
'manual',
|
||||||
|
'error',
|
||||||
|
errorMessage,
|
||||||
|
);
|
||||||
|
logAuth(config, authEvent);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
void authFlow();
|
|
||||||
}, [isAuthDialogOpen, settings, config, onAuthError]);
|
|
||||||
|
|
||||||
// Handle auth selection from dialog
|
|
||||||
const handleAuthSelect = useCallback(
|
|
||||||
async (
|
|
||||||
authType: AuthType | undefined,
|
|
||||||
scope: SettingScope,
|
|
||||||
credentials?: {
|
|
||||||
apiKey?: string;
|
|
||||||
baseUrl?: string;
|
|
||||||
model?: string;
|
|
||||||
},
|
},
|
||||||
|
[onAuthError, pendingAuthType, config],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAuthSuccess = useCallback(
|
||||||
|
async (
|
||||||
|
authType: AuthType,
|
||||||
|
scope: SettingScope,
|
||||||
|
credentials?: OpenAICredentials,
|
||||||
) => {
|
) => {
|
||||||
if (authType) {
|
try {
|
||||||
await clearCachedCredentialFile();
|
settings.setValue(scope, 'security.auth.selectedType', authType);
|
||||||
|
|
||||||
// Save OpenAI credentials if provided
|
// Only update credentials if not switching to QWEN_OAUTH,
|
||||||
if (credentials) {
|
// so that OpenAI credentials are preserved when switching to QWEN_OAUTH.
|
||||||
// Update Config's internal generationConfig before calling refreshAuth
|
if (authType !== AuthType.QWEN_OAUTH && credentials) {
|
||||||
// This ensures refreshAuth has access to the new credentials
|
if (credentials?.apiKey != null) {
|
||||||
config.updateCredentials({
|
|
||||||
apiKey: credentials.apiKey,
|
|
||||||
baseUrl: credentials.baseUrl,
|
|
||||||
model: credentials.model,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also set environment variables for compatibility with other parts of the code
|
|
||||||
if (credentials.apiKey) {
|
|
||||||
settings.setValue(
|
settings.setValue(
|
||||||
scope,
|
scope,
|
||||||
'security.auth.apiKey',
|
'security.auth.apiKey',
|
||||||
credentials.apiKey,
|
credentials.apiKey,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (credentials.baseUrl) {
|
if (credentials?.baseUrl != null) {
|
||||||
settings.setValue(
|
settings.setValue(
|
||||||
scope,
|
scope,
|
||||||
'security.auth.baseUrl',
|
'security.auth.baseUrl',
|
||||||
credentials.baseUrl,
|
credentials.baseUrl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (credentials.model) {
|
if (credentials?.model != null) {
|
||||||
settings.setValue(scope, 'model.name', credentials.model);
|
settings.setValue(scope, 'model.name', credentials.model);
|
||||||
}
|
}
|
||||||
|
await clearCachedCredentialFile();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
handleAuthFailure(error);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
settings.setValue(scope, 'security.auth.selectedType', authType);
|
setAuthError(null);
|
||||||
}
|
setAuthState(AuthState.Authenticated);
|
||||||
|
setPendingAuthType(undefined);
|
||||||
|
setIsAuthDialogOpen(false);
|
||||||
|
setIsAuthenticating(false);
|
||||||
|
|
||||||
|
// Log authentication success
|
||||||
|
const authEvent = new AuthEvent(authType, 'manual', 'success');
|
||||||
|
logAuth(config, authEvent);
|
||||||
|
},
|
||||||
|
[settings, handleAuthFailure, config],
|
||||||
|
);
|
||||||
|
|
||||||
|
const performAuth = useCallback(
|
||||||
|
async (
|
||||||
|
authType: AuthType,
|
||||||
|
scope: SettingScope,
|
||||||
|
credentials?: OpenAICredentials,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await config.refreshAuth(authType);
|
||||||
|
handleAuthSuccess(authType, scope, credentials);
|
||||||
|
} catch (e) {
|
||||||
|
handleAuthFailure(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[config, handleAuthSuccess, handleAuthFailure],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAuthSelect = useCallback(
|
||||||
|
async (
|
||||||
|
authType: AuthType | undefined,
|
||||||
|
scope: SettingScope,
|
||||||
|
credentials?: OpenAICredentials,
|
||||||
|
) => {
|
||||||
|
if (!authType) {
|
||||||
setIsAuthDialogOpen(false);
|
setIsAuthDialogOpen(false);
|
||||||
setAuthError(null);
|
setAuthError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingAuthType(authType);
|
||||||
|
setAuthError(null);
|
||||||
|
setIsAuthDialogOpen(false);
|
||||||
|
setIsAuthenticating(true);
|
||||||
|
|
||||||
|
if (authType === AuthType.USE_OPENAI) {
|
||||||
|
if (credentials) {
|
||||||
|
config.updateCredentials({
|
||||||
|
apiKey: credentials.apiKey,
|
||||||
|
baseUrl: credentials.baseUrl,
|
||||||
|
model: credentials.model,
|
||||||
|
});
|
||||||
|
await performAuth(authType, scope, credentials);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await performAuth(authType, scope);
|
||||||
},
|
},
|
||||||
[settings, config],
|
[config, performAuth],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openAuthDialog = useCallback(() => {
|
const openAuthDialog = useCallback(() => {
|
||||||
@@ -143,8 +176,45 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const cancelAuthentication = useCallback(() => {
|
const cancelAuthentication = useCallback(() => {
|
||||||
|
if (isAuthenticating && pendingAuthType === AuthType.QWEN_OAUTH) {
|
||||||
|
cancelQwenAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log authentication cancellation
|
||||||
|
if (isAuthenticating && pendingAuthType) {
|
||||||
|
const authEvent = new AuthEvent(pendingAuthType, 'manual', 'cancelled');
|
||||||
|
logAuth(config, authEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not reset pendingAuthType here, persist the previously selected type.
|
||||||
setIsAuthenticating(false);
|
setIsAuthenticating(false);
|
||||||
}, []);
|
setIsAuthDialogOpen(true);
|
||||||
|
setAuthError(null);
|
||||||
|
}, [isAuthenticating, pendingAuthType, cancelQwenAuth, config]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
/**
|
||||||
|
* We previously used a useEffect to trigger authentication automatically when
|
||||||
|
* settings.security.auth.selectedType changed. This caused problems: if authentication failed,
|
||||||
|
* the UI could get stuck, since settings.json would update before success. Now, we
|
||||||
|
* update selectedType in settings only when authentication fully succeeds.
|
||||||
|
* Authentication is triggered explicitly—either during initial app startup or when the
|
||||||
|
* user switches methods—not reactively through settings changes. This avoids repeated
|
||||||
|
* or broken authentication cycles.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const defaultAuthType = process.env['QWEN_DEFAULT_AUTH_TYPE'];
|
||||||
|
if (
|
||||||
|
defaultAuthType &&
|
||||||
|
![AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].includes(
|
||||||
|
defaultAuthType as AuthType,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
onAuthError(
|
||||||
|
`Invalid QWEN_DEFAULT_AUTH_TYPE value: "${defaultAuthType}". Valid values are: ${[AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [onAuthError]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
authState,
|
authState,
|
||||||
@@ -153,6 +223,8 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
|||||||
onAuthError,
|
onAuthError,
|
||||||
isAuthDialogOpen,
|
isAuthDialogOpen,
|
||||||
isAuthenticating,
|
isAuthenticating,
|
||||||
|
pendingAuthType,
|
||||||
|
qwenAuthState,
|
||||||
handleAuthSelect,
|
handleAuthSelect,
|
||||||
openAuthDialog,
|
openAuthDialog,
|
||||||
cancelAuthentication,
|
cancelAuthentication,
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
|
|||||||
import { ConsentPrompt } from './ConsentPrompt.js';
|
import { ConsentPrompt } from './ConsentPrompt.js';
|
||||||
import { ThemeDialog } from './ThemeDialog.js';
|
import { ThemeDialog } from './ThemeDialog.js';
|
||||||
import { SettingsDialog } from './SettingsDialog.js';
|
import { SettingsDialog } from './SettingsDialog.js';
|
||||||
import { AuthInProgress } from '../auth/AuthInProgress.js';
|
|
||||||
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
|
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
|
||||||
import { AuthDialog } from '../auth/AuthDialog.js';
|
import { AuthDialog } from '../auth/AuthDialog.js';
|
||||||
|
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
|
||||||
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
|
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
|
||||||
import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js';
|
import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js';
|
||||||
import { ProQuotaDialog } from './ProQuotaDialog.js';
|
import { ProQuotaDialog } from './ProQuotaDialog.js';
|
||||||
@@ -26,6 +26,9 @@ import { useUIState } from '../contexts/UIStateContext.js';
|
|||||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||||
import { useConfig } from '../contexts/ConfigContext.js';
|
import { useConfig } from '../contexts/ConfigContext.js';
|
||||||
import { useSettings } from '../contexts/SettingsContext.js';
|
import { useSettings } from '../contexts/SettingsContext.js';
|
||||||
|
import { SettingScope } from '../../config/settings.js';
|
||||||
|
import { AuthState } from '../types.js';
|
||||||
|
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||||
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
|
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
|
||||||
@@ -56,6 +59,16 @@ export const DialogManager = ({
|
|||||||
const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } =
|
const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } =
|
||||||
uiState;
|
uiState;
|
||||||
|
|
||||||
|
const getDefaultOpenAIConfig = () => {
|
||||||
|
const fromSettings = settings.merged.security?.auth;
|
||||||
|
const modelSettings = settings.merged.model;
|
||||||
|
return {
|
||||||
|
apiKey: fromSettings?.apiKey || process.env['OPENAI_API_KEY'] || '',
|
||||||
|
baseUrl: fromSettings?.baseUrl || process.env['OPENAI_BASE_URL'] || '',
|
||||||
|
model: modelSettings?.name || process.env['OPENAI_MODEL'] || '',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
if (uiState.showWelcomeBackDialog && uiState.welcomeBackInfo?.hasHistory) {
|
if (uiState.showWelcomeBackDialog && uiState.welcomeBackInfo?.hasHistory) {
|
||||||
return (
|
return (
|
||||||
<WelcomeBackDialog
|
<WelcomeBackDialog
|
||||||
@@ -207,39 +220,56 @@ export const DialogManager = ({
|
|||||||
if (uiState.isVisionSwitchDialogOpen) {
|
if (uiState.isVisionSwitchDialogOpen) {
|
||||||
return <ModelSwitchDialog onSelect={uiActions.handleVisionSwitchSelect} />;
|
return <ModelSwitchDialog onSelect={uiActions.handleVisionSwitchSelect} />;
|
||||||
}
|
}
|
||||||
if (uiState.isAuthenticating) {
|
|
||||||
// Show Qwen OAuth progress if it's Qwen auth and OAuth is active
|
if (uiState.isAuthDialogOpen || uiState.authError) {
|
||||||
if (uiState.isQwenAuth && uiState.isQwenAuthenticating) {
|
|
||||||
return (
|
return (
|
||||||
<QwenOAuthProgress
|
<Box flexDirection="column">
|
||||||
deviceAuth={uiState.deviceAuth || undefined}
|
<AuthDialog />
|
||||||
authStatus={uiState.authStatus}
|
</Box>
|
||||||
authMessage={uiState.authMessage}
|
);
|
||||||
onTimeout={uiActions.handleQwenAuthTimeout}
|
}
|
||||||
onCancel={uiActions.handleQwenAuthCancel}
|
|
||||||
|
if (uiState.isAuthenticating) {
|
||||||
|
if (uiState.pendingAuthType === AuthType.USE_OPENAI) {
|
||||||
|
const defaults = getDefaultOpenAIConfig();
|
||||||
|
return (
|
||||||
|
<OpenAIKeyPrompt
|
||||||
|
onSubmit={(apiKey, baseUrl, model) => {
|
||||||
|
uiActions.handleAuthSelect(AuthType.USE_OPENAI, SettingScope.User, {
|
||||||
|
apiKey,
|
||||||
|
baseUrl,
|
||||||
|
model,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
uiActions.cancelAuthentication();
|
||||||
|
uiActions.setAuthState(AuthState.Updating);
|
||||||
|
}}
|
||||||
|
defaultApiKey={defaults.apiKey}
|
||||||
|
defaultBaseUrl={defaults.baseUrl}
|
||||||
|
defaultModel={defaults.model}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default auth progress for other auth types
|
if (uiState.pendingAuthType === AuthType.QWEN_OAUTH) {
|
||||||
return (
|
return (
|
||||||
<AuthInProgress
|
<QwenOAuthProgress
|
||||||
|
deviceAuth={uiState.qwenAuthState.deviceAuth || undefined}
|
||||||
|
authStatus={uiState.qwenAuthState.authStatus}
|
||||||
|
authMessage={uiState.qwenAuthState.authMessage}
|
||||||
onTimeout={() => {
|
onTimeout={() => {
|
||||||
uiActions.onAuthError('Authentication cancelled.');
|
uiActions.onAuthError('Qwen OAuth authentication timed out.');
|
||||||
|
uiActions.cancelAuthentication();
|
||||||
|
uiActions.setAuthState(AuthState.Updating);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
uiActions.cancelAuthentication();
|
||||||
|
uiActions.setAuthState(AuthState.Updating);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (uiState.isAuthDialogOpen) {
|
|
||||||
return (
|
|
||||||
<Box flexDirection="column">
|
|
||||||
<AuthDialog
|
|
||||||
onSelect={uiActions.handleAuthSelect}
|
|
||||||
settings={settings}
|
|
||||||
initialErrorMessage={uiState.authError}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (uiState.isEditorDialogOpen) {
|
if (uiState.isEditorDialogOpen) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { z } from 'zod';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { Colors } from '../colors.js';
|
import { Colors } from '../colors.js';
|
||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
@@ -18,6 +19,16 @@ interface OpenAIKeyPromptProps {
|
|||||||
defaultModel?: string;
|
defaultModel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const credentialSchema = z.object({
|
||||||
|
apiKey: z.string().min(1, 'API key is required'),
|
||||||
|
baseUrl: z
|
||||||
|
.union([z.string().url('Base URL must be a valid URL'), z.literal('')])
|
||||||
|
.optional(),
|
||||||
|
model: z.string().min(1, 'Model must be a non-empty string').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OpenAICredentials = z.infer<typeof credentialSchema>;
|
||||||
|
|
||||||
export function OpenAIKeyPrompt({
|
export function OpenAIKeyPrompt({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
@@ -31,6 +42,34 @@ export function OpenAIKeyPrompt({
|
|||||||
const [currentField, setCurrentField] = useState<
|
const [currentField, setCurrentField] = useState<
|
||||||
'apiKey' | 'baseUrl' | 'model'
|
'apiKey' | 'baseUrl' | 'model'
|
||||||
>('apiKey');
|
>('apiKey');
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const validateAndSubmit = () => {
|
||||||
|
setValidationError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const validated = credentialSchema.parse({
|
||||||
|
apiKey: apiKey.trim(),
|
||||||
|
baseUrl: baseUrl.trim() || undefined,
|
||||||
|
model: model.trim() || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
onSubmit(
|
||||||
|
validated.apiKey,
|
||||||
|
validated.baseUrl === '' ? '' : validated.baseUrl || '',
|
||||||
|
validated.model || '',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
const errorMessage = error.errors
|
||||||
|
.map((e) => `${e.path.join('.')}: ${e.message}`)
|
||||||
|
.join(', ');
|
||||||
|
setValidationError(`Invalid credentials: ${errorMessage}`);
|
||||||
|
} else {
|
||||||
|
setValidationError('Failed to validate credentials');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useKeypress(
|
useKeypress(
|
||||||
(key) => {
|
(key) => {
|
||||||
@@ -52,7 +91,7 @@ export function OpenAIKeyPrompt({
|
|||||||
} else if (currentField === 'model') {
|
} else if (currentField === 'model') {
|
||||||
// 只有在提交时才检查 API key 是否为空
|
// 只有在提交时才检查 API key 是否为空
|
||||||
if (apiKey.trim()) {
|
if (apiKey.trim()) {
|
||||||
onSubmit(apiKey.trim(), baseUrl.trim(), model.trim());
|
validateAndSubmit();
|
||||||
} else {
|
} else {
|
||||||
// 如果 API key 为空,回到 API key 字段
|
// 如果 API key 为空,回到 API key 字段
|
||||||
setCurrentField('apiKey');
|
setCurrentField('apiKey');
|
||||||
@@ -168,6 +207,11 @@ export function OpenAIKeyPrompt({
|
|||||||
<Text bold color={Colors.AccentBlue}>
|
<Text bold color={Colors.AccentBlue}>
|
||||||
OpenAI Configuration Required
|
OpenAI Configuration Required
|
||||||
</Text>
|
</Text>
|
||||||
|
{validationError && (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={Colors.AccentRed}>{validationError}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text>
|
<Text>
|
||||||
Please enter your OpenAI configuration. You can get an API key from{' '}
|
Please enter your OpenAI configuration. You can get an API key from{' '}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import { render } from 'ink-testing-library';
|
import { render } from 'ink-testing-library';
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
|
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
|
||||||
import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
|
import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core';
|
||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
import type { Key } from '../contexts/KeypressContext.js';
|
import type { Key } from '../contexts/KeypressContext.js';
|
||||||
|
|
||||||
@@ -42,12 +42,13 @@ describe('QwenOAuthProgress', () => {
|
|||||||
let keypressHandler: ((key: Key) => void) | null = null;
|
let keypressHandler: ((key: Key) => void) | null = null;
|
||||||
|
|
||||||
const createMockDeviceAuth = (
|
const createMockDeviceAuth = (
|
||||||
overrides: Partial<DeviceAuthorizationInfo> = {},
|
overrides: Partial<DeviceAuthorizationData> = {},
|
||||||
): DeviceAuthorizationInfo => ({
|
): DeviceAuthorizationData => ({
|
||||||
verification_uri: 'https://example.com/device',
|
verification_uri: 'https://example.com/device',
|
||||||
verification_uri_complete: 'https://example.com/device?user_code=ABC123',
|
verification_uri_complete: 'https://example.com/device?user_code=ABC123',
|
||||||
user_code: 'ABC123',
|
user_code: 'ABC123',
|
||||||
expires_in: 300,
|
expires_in: 300,
|
||||||
|
device_code: 'test-device-code',
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ describe('QwenOAuthProgress', () => {
|
|||||||
|
|
||||||
const renderComponent = (
|
const renderComponent = (
|
||||||
props: Partial<{
|
props: Partial<{
|
||||||
deviceAuth: DeviceAuthorizationInfo;
|
deviceAuth: DeviceAuthorizationData;
|
||||||
authStatus:
|
authStatus:
|
||||||
| 'idle'
|
| 'idle'
|
||||||
| 'polling'
|
| 'polling'
|
||||||
@@ -158,7 +159,7 @@ describe('QwenOAuthProgress', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should format time correctly', () => {
|
it('should format time correctly', () => {
|
||||||
const deviceAuthWithCustomTime: DeviceAuthorizationInfo = {
|
const deviceAuthWithCustomTime: DeviceAuthorizationData = {
|
||||||
...mockDeviceAuth,
|
...mockDeviceAuth,
|
||||||
expires_in: 125, // 2 minutes and 5 seconds
|
expires_in: 125, // 2 minutes and 5 seconds
|
||||||
};
|
};
|
||||||
@@ -176,7 +177,7 @@ describe('QwenOAuthProgress', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should format single digit seconds with leading zero', () => {
|
it('should format single digit seconds with leading zero', () => {
|
||||||
const deviceAuthWithCustomTime: DeviceAuthorizationInfo = {
|
const deviceAuthWithCustomTime: DeviceAuthorizationData = {
|
||||||
...mockDeviceAuth,
|
...mockDeviceAuth,
|
||||||
expires_in: 67, // 1 minute and 7 seconds
|
expires_in: 67, // 1 minute and 7 seconds
|
||||||
};
|
};
|
||||||
@@ -196,7 +197,7 @@ describe('QwenOAuthProgress', () => {
|
|||||||
|
|
||||||
describe('Timer functionality', () => {
|
describe('Timer functionality', () => {
|
||||||
it('should countdown and call onTimeout when timer expires', async () => {
|
it('should countdown and call onTimeout when timer expires', async () => {
|
||||||
const deviceAuthWithShortTime: DeviceAuthorizationInfo = {
|
const deviceAuthWithShortTime: DeviceAuthorizationData = {
|
||||||
...mockDeviceAuth,
|
...mockDeviceAuth,
|
||||||
expires_in: 2, // 2 seconds
|
expires_in: 2, // 2 seconds
|
||||||
};
|
};
|
||||||
@@ -520,7 +521,7 @@ describe('QwenOAuthProgress', () => {
|
|||||||
|
|
||||||
describe('Props changes', () => {
|
describe('Props changes', () => {
|
||||||
it('should display initial timer value from deviceAuth', () => {
|
it('should display initial timer value from deviceAuth', () => {
|
||||||
const deviceAuthWith10Min: DeviceAuthorizationInfo = {
|
const deviceAuthWith10Min: DeviceAuthorizationData = {
|
||||||
...mockDeviceAuth,
|
...mockDeviceAuth,
|
||||||
expires_in: 600, // 10 minutes
|
expires_in: 600, // 10 minutes
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ import Spinner from 'ink-spinner';
|
|||||||
import Link from 'ink-link';
|
import Link from 'ink-link';
|
||||||
import qrcode from 'qrcode-terminal';
|
import qrcode from 'qrcode-terminal';
|
||||||
import { Colors } from '../colors.js';
|
import { Colors } from '../colors.js';
|
||||||
import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
|
import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core';
|
||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
|
|
||||||
interface QwenOAuthProgressProps {
|
interface QwenOAuthProgressProps {
|
||||||
onTimeout: () => void;
|
onTimeout: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
deviceAuth?: DeviceAuthorizationInfo;
|
deviceAuth?: DeviceAuthorizationData;
|
||||||
authStatus?:
|
authStatus?:
|
||||||
| 'idle'
|
| 'idle'
|
||||||
| 'polling'
|
| 'polling'
|
||||||
@@ -131,8 +131,8 @@ export function QwenOAuthProgress({
|
|||||||
|
|
||||||
useKeypress(
|
useKeypress(
|
||||||
(key) => {
|
(key) => {
|
||||||
if (authStatus === 'timeout') {
|
if (authStatus === 'timeout' || authStatus === 'error') {
|
||||||
// Any key press in timeout state should trigger cancel to return to auth dialog
|
// Any key press in timeout or error state should trigger cancel to return to auth dialog
|
||||||
onCancel();
|
onCancel();
|
||||||
} else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
} else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
||||||
onCancel();
|
onCancel();
|
||||||
@@ -234,6 +234,35 @@ export function QwenOAuthProgress({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (authStatus === 'error') {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor={Colors.AccentRed}
|
||||||
|
flexDirection="column"
|
||||||
|
padding={1}
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<Text bold color={Colors.AccentRed}>
|
||||||
|
Qwen OAuth Authentication Error
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text>
|
||||||
|
{authMessage ||
|
||||||
|
'An error occurred during authentication. Please try again.'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={Colors.Gray}>
|
||||||
|
Press any key to return to authentication type selection.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Show loading state when no device auth is available yet
|
// Show loading state when no device auth is available yet
|
||||||
if (!deviceAuth) {
|
if (!deviceAuth) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -487,8 +487,11 @@ describe('SettingsDialog', () => {
|
|||||||
it('loops back when reaching the end of an enum', async () => {
|
it('loops back when reaching the end of an enum', async () => {
|
||||||
vi.mocked(saveModifiedSettings).mockClear();
|
vi.mocked(saveModifiedSettings).mockClear();
|
||||||
vi.mocked(getSettingsSchema).mockReturnValue(FAKE_SCHEMA);
|
vi.mocked(getSettingsSchema).mockReturnValue(FAKE_SCHEMA);
|
||||||
const settings = createMockSettings();
|
const settings = createMockSettings({
|
||||||
settings.setValue(SettingScope.User, 'ui.theme', StringEnum.BAZ);
|
ui: {
|
||||||
|
theme: StringEnum.BAZ,
|
||||||
|
},
|
||||||
|
});
|
||||||
const onSelect = vi.fn();
|
const onSelect = vi.fn();
|
||||||
const component = (
|
const component = (
|
||||||
<KeypressProvider kittyProtocolEnabled={false}>
|
<KeypressProvider kittyProtocolEnabled={false}>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function CompressionMessage({
|
|||||||
case CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR:
|
case CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR:
|
||||||
return 'Could not compress chat history due to a token counting error.';
|
return 'Could not compress chat history due to a token counting error.';
|
||||||
case CompressionStatus.NOOP:
|
case CompressionStatus.NOOP:
|
||||||
return 'Chat history is already compressed.';
|
return 'Nothing to compress.';
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { type SettingScope } from '../../config/settings.js';
|
import { type SettingScope } from '../../config/settings.js';
|
||||||
import type { AuthState } from '../types.js';
|
import type { AuthState } from '../types.js';
|
||||||
import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js';
|
import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js';
|
||||||
|
import { type OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
|
||||||
|
|
||||||
export interface UIActions {
|
export interface UIActions {
|
||||||
handleThemeSelect: (
|
handleThemeSelect: (
|
||||||
@@ -30,12 +31,11 @@ export interface UIActions {
|
|||||||
handleAuthSelect: (
|
handleAuthSelect: (
|
||||||
authType: AuthType | undefined,
|
authType: AuthType | undefined,
|
||||||
scope: SettingScope,
|
scope: SettingScope,
|
||||||
) => void;
|
credentials?: OpenAICredentials,
|
||||||
|
) => Promise<void>;
|
||||||
setAuthState: (state: AuthState) => void;
|
setAuthState: (state: AuthState) => void;
|
||||||
onAuthError: (error: string) => void;
|
onAuthError: (error: string) => void;
|
||||||
// Qwen OAuth handlers
|
cancelAuthentication: () => void;
|
||||||
handleQwenAuthTimeout: () => void;
|
|
||||||
handleQwenAuthCancel: () => void;
|
|
||||||
handleEditorSelect: (
|
handleEditorSelect: (
|
||||||
editorType: EditorType | undefined,
|
editorType: EditorType | undefined,
|
||||||
scope: SettingScope,
|
scope: SettingScope,
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ import type {
|
|||||||
HistoryItemWithoutId,
|
HistoryItemWithoutId,
|
||||||
StreamingState,
|
StreamingState,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
|
import type { QwenAuthState } from '../hooks/useQwenAuth.js';
|
||||||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||||
import type { TextBuffer } from '../components/shared/text-buffer.js';
|
import type { TextBuffer } from '../components/shared/text-buffer.js';
|
||||||
import type {
|
import type {
|
||||||
|
AuthType,
|
||||||
IdeContext,
|
IdeContext,
|
||||||
ApprovalMode,
|
ApprovalMode,
|
||||||
UserTierId,
|
UserTierId,
|
||||||
@@ -49,18 +50,9 @@ export interface UIState {
|
|||||||
isConfigInitialized: boolean;
|
isConfigInitialized: boolean;
|
||||||
authError: string | null;
|
authError: string | null;
|
||||||
isAuthDialogOpen: boolean;
|
isAuthDialogOpen: boolean;
|
||||||
|
pendingAuthType: AuthType | undefined;
|
||||||
// Qwen OAuth state
|
// Qwen OAuth state
|
||||||
isQwenAuth: boolean;
|
qwenAuthState: QwenAuthState;
|
||||||
isQwenAuthenticating: boolean;
|
|
||||||
deviceAuth: DeviceAuthorizationInfo | null;
|
|
||||||
authStatus:
|
|
||||||
| 'idle'
|
|
||||||
| 'polling'
|
|
||||||
| 'success'
|
|
||||||
| 'error'
|
|
||||||
| 'timeout'
|
|
||||||
| 'rate_limit';
|
|
||||||
authMessage: string | null;
|
|
||||||
editorError: string | null;
|
editorError: string | null;
|
||||||
isEditorDialogOpen: boolean;
|
isEditorDialogOpen: boolean;
|
||||||
corgiMode: boolean;
|
corgiMode: boolean;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { SettingScope } from '../../config/settings.js';
|
import { SettingScope } from '../../config/settings.js';
|
||||||
import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core';
|
import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||||
|
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
|
||||||
|
|
||||||
export interface DialogCloseOptions {
|
export interface DialogCloseOptions {
|
||||||
// Theme dialog
|
// Theme dialog
|
||||||
@@ -25,8 +26,9 @@ export interface DialogCloseOptions {
|
|||||||
handleAuthSelect: (
|
handleAuthSelect: (
|
||||||
authType: AuthType | undefined,
|
authType: AuthType | undefined,
|
||||||
scope: SettingScope,
|
scope: SettingScope,
|
||||||
|
credentials?: OpenAICredentials,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
selectedAuthType: AuthType | undefined;
|
pendingAuthType: AuthType | undefined;
|
||||||
|
|
||||||
// Editor dialog
|
// Editor dialog
|
||||||
isEditorDialogOpen: boolean;
|
isEditorDialogOpen: boolean;
|
||||||
|
|||||||
47
packages/cli/src/ui/hooks/useInitializationAuthError.ts
Normal file
47
packages/cli/src/ui/hooks/useInitializationAuthError.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that handles initialization authentication error only once.
|
||||||
|
* This ensures that if an auth error occurred during app initialization,
|
||||||
|
* it is reported to the user exactly once, even if the component re-renders.
|
||||||
|
*
|
||||||
|
* @param authError - The authentication error from initialization, or null if no error.
|
||||||
|
* @param onAuthError - Callback function to handle the authentication error.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* useInitializationAuthError(
|
||||||
|
* initializationResult.authError,
|
||||||
|
* onAuthError
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const useInitializationAuthError = (
|
||||||
|
authError: string | null,
|
||||||
|
onAuthError: (error: string) => void,
|
||||||
|
): void => {
|
||||||
|
const hasHandled = useRef(false);
|
||||||
|
const authErrorRef = useRef(authError);
|
||||||
|
const onAuthErrorRef = useRef(onAuthError);
|
||||||
|
|
||||||
|
// Update refs to always use latest values
|
||||||
|
authErrorRef.current = authError;
|
||||||
|
onAuthErrorRef.current = onAuthError;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasHandled.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authErrorRef.current) {
|
||||||
|
hasHandled.current = true;
|
||||||
|
onAuthErrorRef.current(authErrorRef.current);
|
||||||
|
}
|
||||||
|
}, [authError, onAuthError]);
|
||||||
|
};
|
||||||
@@ -6,14 +6,13 @@
|
|||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { renderHook, act } from '@testing-library/react';
|
import { renderHook, act } from '@testing-library/react';
|
||||||
import type { DeviceAuthorizationInfo } from './useQwenAuth.js';
|
import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core';
|
||||||
import { useQwenAuth } from './useQwenAuth.js';
|
import { useQwenAuth } from './useQwenAuth.js';
|
||||||
import {
|
import {
|
||||||
AuthType,
|
AuthType,
|
||||||
qwenOAuth2Events,
|
qwenOAuth2Events,
|
||||||
QwenOAuth2Event,
|
QwenOAuth2Event,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import type { LoadedSettings } from '../../config/settings.js';
|
|
||||||
|
|
||||||
// Mock the qwenOAuth2Events
|
// Mock the qwenOAuth2Events
|
||||||
vi.mock('@qwen-code/qwen-code-core', async () => {
|
vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||||
@@ -36,24 +35,14 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
|
|||||||
const mockQwenOAuth2Events = vi.mocked(qwenOAuth2Events);
|
const mockQwenOAuth2Events = vi.mocked(qwenOAuth2Events);
|
||||||
|
|
||||||
describe('useQwenAuth', () => {
|
describe('useQwenAuth', () => {
|
||||||
const mockDeviceAuth: DeviceAuthorizationInfo = {
|
const mockDeviceAuth: DeviceAuthorizationData = {
|
||||||
verification_uri: 'https://oauth.qwen.com/device',
|
verification_uri: 'https://oauth.qwen.com/device',
|
||||||
verification_uri_complete: 'https://oauth.qwen.com/device?user_code=ABC123',
|
verification_uri_complete: 'https://oauth.qwen.com/device?user_code=ABC123',
|
||||||
user_code: 'ABC123',
|
user_code: 'ABC123',
|
||||||
expires_in: 1800,
|
expires_in: 1800,
|
||||||
|
device_code: 'device_code_123',
|
||||||
};
|
};
|
||||||
|
|
||||||
const createMockSettings = (authType: AuthType): LoadedSettings =>
|
|
||||||
({
|
|
||||||
merged: {
|
|
||||||
security: {
|
|
||||||
auth: {
|
|
||||||
selectedType: authType,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}) as LoadedSettings;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
@@ -63,36 +52,33 @@ describe('useQwenAuth', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should initialize with default state when not Qwen auth', () => {
|
it('should initialize with default state when not Qwen auth', () => {
|
||||||
const settings = createMockSettings(AuthType.USE_GEMINI);
|
const { result } = renderHook(() =>
|
||||||
const { result } = renderHook(() => useQwenAuth(settings, false));
|
useQwenAuth(AuthType.USE_GEMINI, false),
|
||||||
|
);
|
||||||
|
|
||||||
expect(result.current).toEqual({
|
expect(result.current.qwenAuthState).toEqual({
|
||||||
isQwenAuthenticating: false,
|
|
||||||
deviceAuth: null,
|
deviceAuth: null,
|
||||||
authStatus: 'idle',
|
authStatus: 'idle',
|
||||||
authMessage: null,
|
authMessage: null,
|
||||||
isQwenAuth: false,
|
|
||||||
cancelQwenAuth: expect.any(Function),
|
|
||||||
});
|
});
|
||||||
|
expect(result.current.cancelQwenAuth).toBeInstanceOf(Function);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should initialize with default state when Qwen auth but not authenticating', () => {
|
it('should initialize with default state when Qwen auth but not authenticating', () => {
|
||||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
const { result } = renderHook(() =>
|
||||||
const { result } = renderHook(() => useQwenAuth(settings, false));
|
useQwenAuth(AuthType.QWEN_OAUTH, false),
|
||||||
|
);
|
||||||
|
|
||||||
expect(result.current).toEqual({
|
expect(result.current.qwenAuthState).toEqual({
|
||||||
isQwenAuthenticating: false,
|
|
||||||
deviceAuth: null,
|
deviceAuth: null,
|
||||||
authStatus: 'idle',
|
authStatus: 'idle',
|
||||||
authMessage: null,
|
authMessage: null,
|
||||||
isQwenAuth: true,
|
|
||||||
cancelQwenAuth: expect.any(Function),
|
|
||||||
});
|
});
|
||||||
|
expect(result.current.cancelQwenAuth).toBeInstanceOf(Function);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set up event listeners when Qwen auth and authenticating', () => {
|
it('should set up event listeners when Qwen auth and authenticating', () => {
|
||||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||||
renderHook(() => useQwenAuth(settings, true));
|
|
||||||
|
|
||||||
expect(mockQwenOAuth2Events.on).toHaveBeenCalledWith(
|
expect(mockQwenOAuth2Events.on).toHaveBeenCalledWith(
|
||||||
QwenOAuth2Event.AuthUri,
|
QwenOAuth2Event.AuthUri,
|
||||||
@@ -105,8 +91,7 @@ describe('useQwenAuth', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle device auth event', () => {
|
it('should handle device auth event', () => {
|
||||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
|
||||||
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
|
|
||||||
|
|
||||||
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
||||||
if (event === QwenOAuth2Event.AuthUri) {
|
if (event === QwenOAuth2Event.AuthUri) {
|
||||||
@@ -115,19 +100,17 @@ describe('useQwenAuth', () => {
|
|||||||
return mockQwenOAuth2Events;
|
return mockQwenOAuth2Events;
|
||||||
});
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
handleDeviceAuth!(mockDeviceAuth);
|
handleDeviceAuth!(mockDeviceAuth);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.deviceAuth).toEqual(mockDeviceAuth);
|
expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
|
||||||
expect(result.current.authStatus).toBe('polling');
|
expect(result.current.qwenAuthState.authStatus).toBe('polling');
|
||||||
expect(result.current.isQwenAuthenticating).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle auth progress event - success', () => {
|
it('should handle auth progress event - success', () => {
|
||||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
|
||||||
let handleAuthProgress: (
|
let handleAuthProgress: (
|
||||||
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
||||||
message?: string,
|
message?: string,
|
||||||
@@ -140,18 +123,19 @@ describe('useQwenAuth', () => {
|
|||||||
return mockQwenOAuth2Events;
|
return mockQwenOAuth2Events;
|
||||||
});
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
handleAuthProgress!('success', 'Authentication successful!');
|
handleAuthProgress!('success', 'Authentication successful!');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.authStatus).toBe('success');
|
expect(result.current.qwenAuthState.authStatus).toBe('success');
|
||||||
expect(result.current.authMessage).toBe('Authentication successful!');
|
expect(result.current.qwenAuthState.authMessage).toBe(
|
||||||
|
'Authentication successful!',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle auth progress event - error', () => {
|
it('should handle auth progress event - error', () => {
|
||||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
|
||||||
let handleAuthProgress: (
|
let handleAuthProgress: (
|
||||||
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
||||||
message?: string,
|
message?: string,
|
||||||
@@ -164,18 +148,19 @@ describe('useQwenAuth', () => {
|
|||||||
return mockQwenOAuth2Events;
|
return mockQwenOAuth2Events;
|
||||||
});
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
handleAuthProgress!('error', 'Authentication failed');
|
handleAuthProgress!('error', 'Authentication failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.authStatus).toBe('error');
|
expect(result.current.qwenAuthState.authStatus).toBe('error');
|
||||||
expect(result.current.authMessage).toBe('Authentication failed');
|
expect(result.current.qwenAuthState.authMessage).toBe(
|
||||||
|
'Authentication failed',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle auth progress event - polling', () => {
|
it('should handle auth progress event - polling', () => {
|
||||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
|
||||||
let handleAuthProgress: (
|
let handleAuthProgress: (
|
||||||
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
||||||
message?: string,
|
message?: string,
|
||||||
@@ -188,20 +173,19 @@ describe('useQwenAuth', () => {
|
|||||||
return mockQwenOAuth2Events;
|
return mockQwenOAuth2Events;
|
||||||
});
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
handleAuthProgress!('polling', 'Waiting for user authorization...');
|
handleAuthProgress!('polling', 'Waiting for user authorization...');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.authStatus).toBe('polling');
|
expect(result.current.qwenAuthState.authStatus).toBe('polling');
|
||||||
expect(result.current.authMessage).toBe(
|
expect(result.current.qwenAuthState.authMessage).toBe(
|
||||||
'Waiting for user authorization...',
|
'Waiting for user authorization...',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle auth progress event - rate_limit', () => {
|
it('should handle auth progress event - rate_limit', () => {
|
||||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
|
||||||
let handleAuthProgress: (
|
let handleAuthProgress: (
|
||||||
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
||||||
message?: string,
|
message?: string,
|
||||||
@@ -214,7 +198,7 @@ describe('useQwenAuth', () => {
|
|||||||
return mockQwenOAuth2Events;
|
return mockQwenOAuth2Events;
|
||||||
});
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
handleAuthProgress!(
|
handleAuthProgress!(
|
||||||
@@ -223,14 +207,13 @@ describe('useQwenAuth', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.authStatus).toBe('rate_limit');
|
expect(result.current.qwenAuthState.authStatus).toBe('rate_limit');
|
||||||
expect(result.current.authMessage).toBe(
|
expect(result.current.qwenAuthState.authMessage).toBe(
|
||||||
'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.',
|
'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle auth progress event without message', () => {
|
it('should handle auth progress event without message', () => {
|
||||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
|
||||||
let handleAuthProgress: (
|
let handleAuthProgress: (
|
||||||
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
status: 'success' | 'error' | 'polling' | 'timeout' | 'rate_limit',
|
||||||
message?: string,
|
message?: string,
|
||||||
@@ -243,27 +226,30 @@ describe('useQwenAuth', () => {
|
|||||||
return mockQwenOAuth2Events;
|
return mockQwenOAuth2Events;
|
||||||
});
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
handleAuthProgress!('success');
|
handleAuthProgress!('success');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.authStatus).toBe('success');
|
expect(result.current.qwenAuthState.authStatus).toBe('success');
|
||||||
expect(result.current.authMessage).toBe(null);
|
expect(result.current.qwenAuthState.authMessage).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clean up event listeners when auth type changes', () => {
|
it('should clean up event listeners when auth type changes', () => {
|
||||||
const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH);
|
|
||||||
const { rerender } = renderHook(
|
const { rerender } = renderHook(
|
||||||
({ settings, isAuthenticating }) =>
|
({ pendingAuthType, isAuthenticating }) =>
|
||||||
useQwenAuth(settings, isAuthenticating),
|
useQwenAuth(pendingAuthType, isAuthenticating),
|
||||||
{ initialProps: { settings: qwenSettings, isAuthenticating: true } },
|
{
|
||||||
|
initialProps: {
|
||||||
|
pendingAuthType: AuthType.QWEN_OAUTH,
|
||||||
|
isAuthenticating: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Change to non-Qwen auth
|
// Change to non-Qwen auth
|
||||||
const geminiSettings = createMockSettings(AuthType.USE_GEMINI);
|
rerender({ pendingAuthType: AuthType.USE_GEMINI, isAuthenticating: true });
|
||||||
rerender({ settings: geminiSettings, isAuthenticating: true });
|
|
||||||
|
|
||||||
expect(mockQwenOAuth2Events.off).toHaveBeenCalledWith(
|
expect(mockQwenOAuth2Events.off).toHaveBeenCalledWith(
|
||||||
QwenOAuth2Event.AuthUri,
|
QwenOAuth2Event.AuthUri,
|
||||||
@@ -276,9 +262,9 @@ describe('useQwenAuth', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should clean up event listeners when authentication stops', () => {
|
it('should clean up event listeners when authentication stops', () => {
|
||||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
|
||||||
const { rerender } = renderHook(
|
const { rerender } = renderHook(
|
||||||
({ isAuthenticating }) => useQwenAuth(settings, isAuthenticating),
|
({ isAuthenticating }) =>
|
||||||
|
useQwenAuth(AuthType.QWEN_OAUTH, isAuthenticating),
|
||||||
{ initialProps: { isAuthenticating: true } },
|
{ initialProps: { isAuthenticating: true } },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -296,8 +282,9 @@ describe('useQwenAuth', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should clean up event listeners on unmount', () => {
|
it('should clean up event listeners on unmount', () => {
|
||||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
const { unmount } = renderHook(() =>
|
||||||
const { unmount } = renderHook(() => useQwenAuth(settings, true));
|
useQwenAuth(AuthType.QWEN_OAUTH, true),
|
||||||
|
);
|
||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
|
|
||||||
@@ -312,8 +299,7 @@ describe('useQwenAuth', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should reset state when switching from Qwen auth to another auth type', () => {
|
it('should reset state when switching from Qwen auth to another auth type', () => {
|
||||||
const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH);
|
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
|
||||||
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
|
|
||||||
|
|
||||||
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
||||||
if (event === QwenOAuth2Event.AuthUri) {
|
if (event === QwenOAuth2Event.AuthUri) {
|
||||||
@@ -323,9 +309,14 @@ describe('useQwenAuth', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { result, rerender } = renderHook(
|
const { result, rerender } = renderHook(
|
||||||
({ settings, isAuthenticating }) =>
|
({ pendingAuthType, isAuthenticating }) =>
|
||||||
useQwenAuth(settings, isAuthenticating),
|
useQwenAuth(pendingAuthType, isAuthenticating),
|
||||||
{ initialProps: { settings: qwenSettings, isAuthenticating: true } },
|
{
|
||||||
|
initialProps: {
|
||||||
|
pendingAuthType: AuthType.QWEN_OAUTH,
|
||||||
|
isAuthenticating: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Simulate device auth
|
// Simulate device auth
|
||||||
@@ -333,22 +324,19 @@ describe('useQwenAuth', () => {
|
|||||||
handleDeviceAuth!(mockDeviceAuth);
|
handleDeviceAuth!(mockDeviceAuth);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.deviceAuth).toEqual(mockDeviceAuth);
|
expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
|
||||||
expect(result.current.authStatus).toBe('polling');
|
expect(result.current.qwenAuthState.authStatus).toBe('polling');
|
||||||
|
|
||||||
// Switch to different auth type
|
// Switch to different auth type
|
||||||
const geminiSettings = createMockSettings(AuthType.USE_GEMINI);
|
rerender({ pendingAuthType: AuthType.USE_GEMINI, isAuthenticating: true });
|
||||||
rerender({ settings: geminiSettings, isAuthenticating: true });
|
|
||||||
|
|
||||||
expect(result.current.isQwenAuthenticating).toBe(false);
|
expect(result.current.qwenAuthState.deviceAuth).toBe(null);
|
||||||
expect(result.current.deviceAuth).toBe(null);
|
expect(result.current.qwenAuthState.authStatus).toBe('idle');
|
||||||
expect(result.current.authStatus).toBe('idle');
|
expect(result.current.qwenAuthState.authMessage).toBe(null);
|
||||||
expect(result.current.authMessage).toBe(null);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reset state when authentication stops', () => {
|
it('should reset state when authentication stops', () => {
|
||||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
|
||||||
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
|
|
||||||
|
|
||||||
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
||||||
if (event === QwenOAuth2Event.AuthUri) {
|
if (event === QwenOAuth2Event.AuthUri) {
|
||||||
@@ -358,7 +346,8 @@ describe('useQwenAuth', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { result, rerender } = renderHook(
|
const { result, rerender } = renderHook(
|
||||||
({ isAuthenticating }) => useQwenAuth(settings, isAuthenticating),
|
({ isAuthenticating }) =>
|
||||||
|
useQwenAuth(AuthType.QWEN_OAUTH, isAuthenticating),
|
||||||
{ initialProps: { isAuthenticating: true } },
|
{ initialProps: { isAuthenticating: true } },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -367,21 +356,19 @@ describe('useQwenAuth', () => {
|
|||||||
handleDeviceAuth!(mockDeviceAuth);
|
handleDeviceAuth!(mockDeviceAuth);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.deviceAuth).toEqual(mockDeviceAuth);
|
expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
|
||||||
expect(result.current.authStatus).toBe('polling');
|
expect(result.current.qwenAuthState.authStatus).toBe('polling');
|
||||||
|
|
||||||
// Stop authentication
|
// Stop authentication
|
||||||
rerender({ isAuthenticating: false });
|
rerender({ isAuthenticating: false });
|
||||||
|
|
||||||
expect(result.current.isQwenAuthenticating).toBe(false);
|
expect(result.current.qwenAuthState.deviceAuth).toBe(null);
|
||||||
expect(result.current.deviceAuth).toBe(null);
|
expect(result.current.qwenAuthState.authStatus).toBe('idle');
|
||||||
expect(result.current.authStatus).toBe('idle');
|
expect(result.current.qwenAuthState.authMessage).toBe(null);
|
||||||
expect(result.current.authMessage).toBe(null);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle cancelQwenAuth function', () => {
|
it('should handle cancelQwenAuth function', () => {
|
||||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationData) => void;
|
||||||
let handleDeviceAuth: (deviceAuth: DeviceAuthorizationInfo) => void;
|
|
||||||
|
|
||||||
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
mockQwenOAuth2Events.on.mockImplementation((event, handler) => {
|
||||||
if (event === QwenOAuth2Event.AuthUri) {
|
if (event === QwenOAuth2Event.AuthUri) {
|
||||||
@@ -390,53 +377,49 @@ describe('useQwenAuth', () => {
|
|||||||
return mockQwenOAuth2Events;
|
return mockQwenOAuth2Events;
|
||||||
});
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||||
|
|
||||||
// Set up some state
|
// Set up some state
|
||||||
act(() => {
|
act(() => {
|
||||||
handleDeviceAuth!(mockDeviceAuth);
|
handleDeviceAuth!(mockDeviceAuth);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.deviceAuth).toEqual(mockDeviceAuth);
|
expect(result.current.qwenAuthState.deviceAuth).toEqual(mockDeviceAuth);
|
||||||
|
|
||||||
// Cancel auth
|
// Cancel auth
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.cancelQwenAuth();
|
result.current.cancelQwenAuth();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.isQwenAuthenticating).toBe(false);
|
expect(result.current.qwenAuthState.deviceAuth).toBe(null);
|
||||||
expect(result.current.deviceAuth).toBe(null);
|
expect(result.current.qwenAuthState.authStatus).toBe('idle');
|
||||||
expect(result.current.authStatus).toBe('idle');
|
expect(result.current.qwenAuthState.authMessage).toBe(null);
|
||||||
expect(result.current.authMessage).toBe(null);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should maintain isQwenAuth flag correctly', () => {
|
it('should handle different auth types correctly', () => {
|
||||||
// Test with Qwen OAuth
|
// Test with Qwen OAuth - should set up event listeners when authenticating
|
||||||
const qwenSettings = createMockSettings(AuthType.QWEN_OAUTH);
|
|
||||||
const { result: qwenResult } = renderHook(() =>
|
const { result: qwenResult } = renderHook(() =>
|
||||||
useQwenAuth(qwenSettings, false),
|
useQwenAuth(AuthType.QWEN_OAUTH, true),
|
||||||
);
|
);
|
||||||
expect(qwenResult.current.isQwenAuth).toBe(true);
|
expect(qwenResult.current.qwenAuthState.authStatus).toBe('idle');
|
||||||
|
expect(mockQwenOAuth2Events.on).toHaveBeenCalled();
|
||||||
|
|
||||||
// Test with other auth types
|
// Test with other auth types - should not set up event listeners
|
||||||
const geminiSettings = createMockSettings(AuthType.USE_GEMINI);
|
|
||||||
const { result: geminiResult } = renderHook(() =>
|
const { result: geminiResult } = renderHook(() =>
|
||||||
useQwenAuth(geminiSettings, false),
|
useQwenAuth(AuthType.USE_GEMINI, true),
|
||||||
);
|
);
|
||||||
expect(geminiResult.current.isQwenAuth).toBe(false);
|
expect(geminiResult.current.qwenAuthState.authStatus).toBe('idle');
|
||||||
|
|
||||||
const oauthSettings = createMockSettings(AuthType.LOGIN_WITH_GOOGLE);
|
|
||||||
const { result: oauthResult } = renderHook(() =>
|
const { result: oauthResult } = renderHook(() =>
|
||||||
useQwenAuth(oauthSettings, false),
|
useQwenAuth(AuthType.LOGIN_WITH_GOOGLE, true),
|
||||||
);
|
);
|
||||||
expect(oauthResult.current.isQwenAuth).toBe(false);
|
expect(oauthResult.current.qwenAuthState.authStatus).toBe('idle');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set isQwenAuthenticating to true when starting authentication with Qwen auth', () => {
|
it('should initialize with idle status when starting authentication with Qwen auth', () => {
|
||||||
const settings = createMockSettings(AuthType.QWEN_OAUTH);
|
const { result } = renderHook(() => useQwenAuth(AuthType.QWEN_OAUTH, true));
|
||||||
const { result } = renderHook(() => useQwenAuth(settings, true));
|
|
||||||
|
|
||||||
expect(result.current.isQwenAuthenticating).toBe(true);
|
expect(result.current.qwenAuthState.authStatus).toBe('idle');
|
||||||
expect(result.current.authStatus).toBe('idle');
|
expect(mockQwenOAuth2Events.on).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,23 +5,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import type { LoadedSettings } from '../../config/settings.js';
|
|
||||||
import {
|
import {
|
||||||
AuthType,
|
AuthType,
|
||||||
qwenOAuth2Events,
|
qwenOAuth2Events,
|
||||||
QwenOAuth2Event,
|
QwenOAuth2Event,
|
||||||
|
type DeviceAuthorizationData,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
|
||||||
export interface DeviceAuthorizationInfo {
|
export interface QwenAuthState {
|
||||||
verification_uri: string;
|
deviceAuth: DeviceAuthorizationData | null;
|
||||||
verification_uri_complete: string;
|
|
||||||
user_code: string;
|
|
||||||
expires_in: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface QwenAuthState {
|
|
||||||
isQwenAuthenticating: boolean;
|
|
||||||
deviceAuth: DeviceAuthorizationInfo | null;
|
|
||||||
authStatus:
|
authStatus:
|
||||||
| 'idle'
|
| 'idle'
|
||||||
| 'polling'
|
| 'polling'
|
||||||
@@ -33,25 +25,22 @@ interface QwenAuthState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useQwenAuth = (
|
export const useQwenAuth = (
|
||||||
settings: LoadedSettings,
|
pendingAuthType: AuthType | undefined,
|
||||||
isAuthenticating: boolean,
|
isAuthenticating: boolean,
|
||||||
) => {
|
) => {
|
||||||
const [qwenAuthState, setQwenAuthState] = useState<QwenAuthState>({
|
const [qwenAuthState, setQwenAuthState] = useState<QwenAuthState>({
|
||||||
isQwenAuthenticating: false,
|
|
||||||
deviceAuth: null,
|
deviceAuth: null,
|
||||||
authStatus: 'idle',
|
authStatus: 'idle',
|
||||||
authMessage: null,
|
authMessage: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isQwenAuth =
|
const isQwenAuth = pendingAuthType === AuthType.QWEN_OAUTH;
|
||||||
settings.merged.security?.auth?.selectedType === AuthType.QWEN_OAUTH;
|
|
||||||
|
|
||||||
// Set up event listeners when authentication starts
|
// Set up event listeners when authentication starts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isQwenAuth || !isAuthenticating) {
|
if (!isQwenAuth || !isAuthenticating) {
|
||||||
// Reset state when not authenticating or not Qwen auth
|
// Reset state when not authenticating or not Qwen auth
|
||||||
setQwenAuthState({
|
setQwenAuthState({
|
||||||
isQwenAuthenticating: false,
|
|
||||||
deviceAuth: null,
|
deviceAuth: null,
|
||||||
authStatus: 'idle',
|
authStatus: 'idle',
|
||||||
authMessage: null,
|
authMessage: null,
|
||||||
@@ -61,12 +50,11 @@ export const useQwenAuth = (
|
|||||||
|
|
||||||
setQwenAuthState((prev) => ({
|
setQwenAuthState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
isQwenAuthenticating: true,
|
|
||||||
authStatus: 'idle',
|
authStatus: 'idle',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Set up event listeners
|
// Set up event listeners
|
||||||
const handleDeviceAuth = (deviceAuth: DeviceAuthorizationInfo) => {
|
const handleDeviceAuth = (deviceAuth: DeviceAuthorizationData) => {
|
||||||
setQwenAuthState((prev) => ({
|
setQwenAuthState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
deviceAuth: {
|
deviceAuth: {
|
||||||
@@ -74,6 +62,7 @@ export const useQwenAuth = (
|
|||||||
verification_uri_complete: deviceAuth.verification_uri_complete,
|
verification_uri_complete: deviceAuth.verification_uri_complete,
|
||||||
user_code: deviceAuth.user_code,
|
user_code: deviceAuth.user_code,
|
||||||
expires_in: deviceAuth.expires_in,
|
expires_in: deviceAuth.expires_in,
|
||||||
|
device_code: deviceAuth.device_code,
|
||||||
},
|
},
|
||||||
authStatus: 'polling',
|
authStatus: 'polling',
|
||||||
}));
|
}));
|
||||||
@@ -106,7 +95,6 @@ export const useQwenAuth = (
|
|||||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthCancel);
|
qwenOAuth2Events.emit(QwenOAuth2Event.AuthCancel);
|
||||||
|
|
||||||
setQwenAuthState({
|
setQwenAuthState({
|
||||||
isQwenAuthenticating: false,
|
|
||||||
deviceAuth: null,
|
deviceAuth: null,
|
||||||
authStatus: 'idle',
|
authStatus: 'idle',
|
||||||
authMessage: null,
|
authMessage: null,
|
||||||
@@ -114,8 +102,7 @@ export const useQwenAuth = (
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...qwenAuthState,
|
qwenAuthState,
|
||||||
isQwenAuth,
|
|
||||||
cancelQwenAuth,
|
cancelQwenAuth,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export {
|
|||||||
logExtensionEnable,
|
logExtensionEnable,
|
||||||
logIdeConnection,
|
logIdeConnection,
|
||||||
logExtensionDisable,
|
logExtensionDisable,
|
||||||
|
logAuth,
|
||||||
} from './src/telemetry/loggers.js';
|
} from './src/telemetry/loggers.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -40,6 +41,7 @@ export {
|
|||||||
ExtensionEnableEvent,
|
ExtensionEnableEvent,
|
||||||
ExtensionUninstallEvent,
|
ExtensionUninstallEvent,
|
||||||
ModelSlashCommandEvent,
|
ModelSlashCommandEvent,
|
||||||
|
AuthEvent,
|
||||||
} from './src/telemetry/types.js';
|
} from './src/telemetry/types.js';
|
||||||
export { makeFakeConfig } from './src/test-utils/config.js';
|
export { makeFakeConfig } from './src/test-utils/config.js';
|
||||||
export * from './src/utils/pathReader.js';
|
export * from './src/utils/pathReader.js';
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ const vendorDir = path.join(packageRoot, 'vendor', 'ripgrep');
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove quarantine attribute and set executable permissions on macOS/Linux
|
* Remove quarantine attribute and set executable permissions on macOS/Linux
|
||||||
|
* This script never throws errors to avoid blocking npm workflows.
|
||||||
*/
|
*/
|
||||||
function setupRipgrepBinaries() {
|
function setupRipgrepBinaries() {
|
||||||
|
try {
|
||||||
if (!fs.existsSync(vendorDir)) {
|
if (!fs.existsSync(vendorDir)) {
|
||||||
console.log('Vendor directory not found, skipping ripgrep setup');
|
console.log('ℹ Vendor directory not found, skipping ripgrep setup');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,12 +41,13 @@ function setupRipgrepBinaries() {
|
|||||||
}
|
}
|
||||||
} else if (platform === 'win32') {
|
} else if (platform === 'win32') {
|
||||||
// Windows doesn't need these fixes
|
// Windows doesn't need these fixes
|
||||||
|
console.log('ℹ Windows detected, skipping ripgrep setup');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!binaryDir || !fs.existsSync(binaryDir)) {
|
if (!binaryDir || !fs.existsSync(binaryDir)) {
|
||||||
console.log(
|
console.log(
|
||||||
`Binary directory not found for ${platform}-${arch}, skipping ripgrep setup`,
|
`ℹ Binary directory not found for ${platform}-${arch}, skipping ripgrep setup`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -52,7 +55,7 @@ function setupRipgrepBinaries() {
|
|||||||
const rgBinary = path.join(binaryDir, 'rg');
|
const rgBinary = path.join(binaryDir, 'rg');
|
||||||
|
|
||||||
if (!fs.existsSync(rgBinary)) {
|
if (!fs.existsSync(rgBinary)) {
|
||||||
console.log(`Ripgrep binary not found at ${rgBinary}`);
|
console.log(`ℹ Ripgrep binary not found at ${rgBinary}, skipping setup`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,18 +71,30 @@ function setupRipgrepBinaries() {
|
|||||||
stdio: 'pipe',
|
stdio: 'pipe',
|
||||||
});
|
});
|
||||||
console.log(`✓ Removed quarantine attribute from ${rgBinary}`);
|
console.log(`✓ Removed quarantine attribute from ${rgBinary}`);
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Quarantine attribute might not exist, which is fine
|
// Quarantine attribute might not exist, which is fine
|
||||||
if (error.message && !error.message.includes('No such xattr')) {
|
console.log('ℹ Quarantine attribute not present or already removed');
|
||||||
console.warn(
|
|
||||||
`Warning: Could not remove quarantine attribute: ${error.message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error setting up ripgrep binary: ${error.message}`);
|
console.log(
|
||||||
|
`⚠ Could not complete ripgrep setup: ${error.message || 'Unknown error'}`,
|
||||||
|
);
|
||||||
|
console.log(' This is not critical - ripgrep may still work correctly');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(
|
||||||
|
`⚠ Ripgrep setup encountered an issue: ${error.message || 'Unknown error'}`,
|
||||||
|
);
|
||||||
|
console.log(' Continuing anyway - this should not affect functionality');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupRipgrepBinaries();
|
// Wrap the entire execution to ensure no errors escape to npm
|
||||||
|
try {
|
||||||
|
setupRipgrepBinaries();
|
||||||
|
} catch {
|
||||||
|
// Last resort catch - never let errors block npm
|
||||||
|
console.log('⚠ Postinstall script encountered an unexpected error');
|
||||||
|
console.log(' This will not affect the installation');
|
||||||
|
}
|
||||||
|
|||||||
@@ -589,7 +589,7 @@ export class Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshAuth(authMethod: AuthType) {
|
async refreshAuth(authMethod: AuthType, isInitialAuth?: boolean) {
|
||||||
// Vertex and Genai have incompatible encryption and sending history with
|
// Vertex and Genai have incompatible encryption and sending history with
|
||||||
// throughtSignature from Genai to Vertex will fail, we need to strip them
|
// throughtSignature from Genai to Vertex will fail, we need to strip them
|
||||||
if (
|
if (
|
||||||
@@ -609,6 +609,7 @@ export class Config {
|
|||||||
newContentGeneratorConfig,
|
newContentGeneratorConfig,
|
||||||
this,
|
this,
|
||||||
this.getSessionId(),
|
this.getSessionId(),
|
||||||
|
isInitialAuth,
|
||||||
);
|
);
|
||||||
// Only assign to instance properties after successful initialization
|
// Only assign to instance properties after successful initialization
|
||||||
this.contentGeneratorConfig = newContentGeneratorConfig;
|
this.contentGeneratorConfig = newContentGeneratorConfig;
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ export async function createContentGenerator(
|
|||||||
config: ContentGeneratorConfig,
|
config: ContentGeneratorConfig,
|
||||||
gcConfig: Config,
|
gcConfig: Config,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
|
isInitialAuth?: boolean,
|
||||||
): Promise<ContentGenerator> {
|
): Promise<ContentGenerator> {
|
||||||
const version = process.env['CLI_VERSION'] || process.version;
|
const version = process.env['CLI_VERSION'] || process.version;
|
||||||
const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`;
|
const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`;
|
||||||
@@ -191,13 +192,17 @@ export async function createContentGenerator(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the Qwen OAuth client (now includes integrated token management)
|
// Get the Qwen OAuth client (now includes integrated token management)
|
||||||
const qwenClient = await getQwenOauthClient(gcConfig);
|
// If this is initial auth, require cached credentials to detect missing credentials
|
||||||
|
const qwenClient = await getQwenOauthClient(
|
||||||
|
gcConfig,
|
||||||
|
isInitialAuth ? { requireCachedCredentials: true } : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
// Create the content generator with dynamic token management
|
// Create the content generator with dynamic token management
|
||||||
return new QwenContentGenerator(qwenClient, config, gcConfig);
|
return new QwenContentGenerator(qwenClient, config, gcConfig);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to initialize Qwen: ${error instanceof Error ? error.message : String(error)}`,
|
`${error instanceof Error ? error.message : String(error)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -825,7 +825,7 @@ describe('getQwenOAuthClient', () => {
|
|||||||
import('./qwenOAuth2.js').then((module) =>
|
import('./qwenOAuth2.js').then((module) =>
|
||||||
module.getQwenOAuthClient(mockConfig),
|
module.getQwenOAuthClient(mockConfig),
|
||||||
),
|
),
|
||||||
).rejects.toThrow('Qwen OAuth authentication failed');
|
).rejects.toThrow('Device authorization flow failed');
|
||||||
|
|
||||||
SharedTokenManager.getInstance = originalGetInstance;
|
SharedTokenManager.getInstance = originalGetInstance;
|
||||||
});
|
});
|
||||||
@@ -983,7 +983,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
|
|||||||
import('./qwenOAuth2.js').then((module) =>
|
import('./qwenOAuth2.js').then((module) =>
|
||||||
module.getQwenOAuthClient(mockConfig),
|
module.getQwenOAuthClient(mockConfig),
|
||||||
),
|
),
|
||||||
).rejects.toThrow('Qwen OAuth authentication failed');
|
).rejects.toThrow('Device authorization flow failed');
|
||||||
|
|
||||||
SharedTokenManager.getInstance = originalGetInstance;
|
SharedTokenManager.getInstance = originalGetInstance;
|
||||||
});
|
});
|
||||||
@@ -1032,7 +1032,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
|
|||||||
import('./qwenOAuth2.js').then((module) =>
|
import('./qwenOAuth2.js').then((module) =>
|
||||||
module.getQwenOAuthClient(mockConfig),
|
module.getQwenOAuthClient(mockConfig),
|
||||||
),
|
),
|
||||||
).rejects.toThrow('Qwen OAuth authentication timed out');
|
).rejects.toThrow('Authorization timeout, please restart the process.');
|
||||||
|
|
||||||
SharedTokenManager.getInstance = originalGetInstance;
|
SharedTokenManager.getInstance = originalGetInstance;
|
||||||
});
|
});
|
||||||
@@ -1082,7 +1082,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
|
|||||||
module.getQwenOAuthClient(mockConfig),
|
module.getQwenOAuthClient(mockConfig),
|
||||||
),
|
),
|
||||||
).rejects.toThrow(
|
).rejects.toThrow(
|
||||||
'Too many request for Qwen OAuth authentication, please try again later.',
|
'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.',
|
||||||
);
|
);
|
||||||
|
|
||||||
SharedTokenManager.getInstance = originalGetInstance;
|
SharedTokenManager.getInstance = originalGetInstance;
|
||||||
@@ -1119,7 +1119,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
|
|||||||
import('./qwenOAuth2.js').then((module) =>
|
import('./qwenOAuth2.js').then((module) =>
|
||||||
module.getQwenOAuthClient(mockConfig),
|
module.getQwenOAuthClient(mockConfig),
|
||||||
),
|
),
|
||||||
).rejects.toThrow('Qwen OAuth authentication failed');
|
).rejects.toThrow('Device authorization flow failed');
|
||||||
|
|
||||||
SharedTokenManager.getInstance = originalGetInstance;
|
SharedTokenManager.getInstance = originalGetInstance;
|
||||||
});
|
});
|
||||||
@@ -1177,7 +1177,7 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
|
|||||||
import('./qwenOAuth2.js').then((module) =>
|
import('./qwenOAuth2.js').then((module) =>
|
||||||
module.getQwenOAuthClient(mockConfig),
|
module.getQwenOAuthClient(mockConfig),
|
||||||
),
|
),
|
||||||
).rejects.toThrow('Qwen OAuth authentication failed');
|
).rejects.toThrow('Device authorization flow failed');
|
||||||
|
|
||||||
SharedTokenManager.getInstance = originalGetInstance;
|
SharedTokenManager.getInstance = originalGetInstance;
|
||||||
});
|
});
|
||||||
@@ -1264,7 +1264,9 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
|
|||||||
import('./qwenOAuth2.js').then((module) =>
|
import('./qwenOAuth2.js').then((module) =>
|
||||||
module.getQwenOAuthClient(mockConfig),
|
module.getQwenOAuthClient(mockConfig),
|
||||||
),
|
),
|
||||||
).rejects.toThrow('Qwen OAuth authentication failed');
|
).rejects.toThrow(
|
||||||
|
'Device code expired or invalid, please restart the authorization process.',
|
||||||
|
);
|
||||||
|
|
||||||
SharedTokenManager.getInstance = originalGetInstance;
|
SharedTokenManager.getInstance = originalGetInstance;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -467,6 +467,7 @@ export type AuthResult =
|
|||||||
| {
|
| {
|
||||||
success: false;
|
success: false;
|
||||||
reason: 'timeout' | 'cancelled' | 'error' | 'rate_limit';
|
reason: 'timeout' | 'cancelled' | 'error' | 'rate_limit';
|
||||||
|
message?: string; // Detailed error message for better error reporting
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -476,6 +477,7 @@ export const qwenOAuth2Events = new EventEmitter();
|
|||||||
|
|
||||||
export async function getQwenOAuthClient(
|
export async function getQwenOAuthClient(
|
||||||
config: Config,
|
config: Config,
|
||||||
|
options?: { requireCachedCredentials?: boolean },
|
||||||
): Promise<QwenOAuth2Client> {
|
): Promise<QwenOAuth2Client> {
|
||||||
const client = new QwenOAuth2Client();
|
const client = new QwenOAuth2Client();
|
||||||
|
|
||||||
@@ -488,11 +490,6 @@ export async function getQwenOAuthClient(
|
|||||||
client.setCredentials(credentials);
|
client.setCredentials(credentials);
|
||||||
return client;
|
return client;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.debug(
|
|
||||||
'Shared token manager failed, attempting device flow:',
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle specific token manager errors
|
// Handle specific token manager errors
|
||||||
if (error instanceof TokenManagerError) {
|
if (error instanceof TokenManagerError) {
|
||||||
switch (error.type) {
|
switch (error.type) {
|
||||||
@@ -520,12 +517,20 @@ export async function getQwenOAuthClient(
|
|||||||
// Try device flow instead of forcing refresh
|
// Try device flow instead of forcing refresh
|
||||||
const result = await authWithQwenDeviceFlow(client, config);
|
const result = await authWithQwenDeviceFlow(client, config);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error('Qwen OAuth authentication failed');
|
// Use detailed error message if available, otherwise use default
|
||||||
|
const errorMessage =
|
||||||
|
result.message || 'Qwen OAuth authentication failed';
|
||||||
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No cached credentials, use device authorization flow for authentication
|
if (options?.requireCachedCredentials) {
|
||||||
|
throw new Error(
|
||||||
|
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await authWithQwenDeviceFlow(client, config);
|
const result = await authWithQwenDeviceFlow(client, config);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
// Only emit timeout event if the failure reason is actually timeout
|
// Only emit timeout event if the failure reason is actually timeout
|
||||||
@@ -538,20 +543,24 @@ export async function getQwenOAuthClient(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Throw error with appropriate message based on failure reason
|
// Use detailed error message if available, otherwise use default based on reason
|
||||||
|
const errorMessage =
|
||||||
|
result.message ||
|
||||||
|
(() => {
|
||||||
switch (result.reason) {
|
switch (result.reason) {
|
||||||
case 'timeout':
|
case 'timeout':
|
||||||
throw new Error('Qwen OAuth authentication timed out');
|
return 'Qwen OAuth authentication timed out';
|
||||||
case 'cancelled':
|
case 'cancelled':
|
||||||
throw new Error('Qwen OAuth authentication was cancelled by user');
|
return 'Qwen OAuth authentication was cancelled by user';
|
||||||
case 'rate_limit':
|
case 'rate_limit':
|
||||||
throw new Error(
|
return 'Too many request for Qwen OAuth authentication, please try again later.';
|
||||||
'Too many request for Qwen OAuth authentication, please try again later.',
|
|
||||||
);
|
|
||||||
case 'error':
|
case 'error':
|
||||||
default:
|
default:
|
||||||
throw new Error('Qwen OAuth authentication failed');
|
return 'Qwen OAuth authentication failed';
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
return client;
|
return client;
|
||||||
@@ -644,13 +653,10 @@ async function authWithQwenDeviceFlow(
|
|||||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
// Check if authentication was cancelled
|
// Check if authentication was cancelled
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
console.debug('\nAuthentication cancelled by user.');
|
const message = 'Authentication cancelled by user.';
|
||||||
qwenOAuth2Events.emit(
|
console.debug('\n' + message);
|
||||||
QwenOAuth2Event.AuthProgress,
|
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
|
||||||
'error',
|
return { success: false, reason: 'cancelled', message };
|
||||||
'Authentication cancelled by user.',
|
|
||||||
);
|
|
||||||
return { success: false, reason: 'cancelled' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -738,13 +744,14 @@ async function authWithQwenDeviceFlow(
|
|||||||
|
|
||||||
// Check for cancellation after waiting
|
// Check for cancellation after waiting
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
console.debug('\nAuthentication cancelled by user.');
|
const message = 'Authentication cancelled by user.';
|
||||||
|
console.debug('\n' + message);
|
||||||
qwenOAuth2Events.emit(
|
qwenOAuth2Events.emit(
|
||||||
QwenOAuth2Event.AuthProgress,
|
QwenOAuth2Event.AuthProgress,
|
||||||
'error',
|
'error',
|
||||||
'Authentication cancelled by user.',
|
message,
|
||||||
);
|
);
|
||||||
return { success: false, reason: 'cancelled' };
|
return { success: false, reason: 'cancelled', message };
|
||||||
}
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@@ -758,7 +765,7 @@ async function authWithQwenDeviceFlow(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// Handle specific error cases
|
// Extract error information
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error ? error.message : String(error);
|
error instanceof Error ? error.message : String(error);
|
||||||
const statusCode =
|
const statusCode =
|
||||||
@@ -766,42 +773,49 @@ async function authWithQwenDeviceFlow(
|
|||||||
? (error as Error & { status?: number }).status
|
? (error as Error & { status?: number }).status
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (errorMessage.includes('401') || statusCode === 401) {
|
// Helper function to handle error and stop polling
|
||||||
const message =
|
const handleError = (
|
||||||
'Device code expired or invalid, please restart the authorization process.';
|
reason: 'error' | 'rate_limit',
|
||||||
|
message: string,
|
||||||
// Emit error event
|
eventType: 'error' | 'rate_limit' = 'error',
|
||||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
|
): AuthResult => {
|
||||||
|
|
||||||
return { success: false, reason: 'error' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle 429 Too Many Requests error
|
|
||||||
if (errorMessage.includes('429') || statusCode === 429) {
|
|
||||||
const message =
|
|
||||||
'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.';
|
|
||||||
|
|
||||||
// Emit rate limit event to notify user
|
|
||||||
qwenOAuth2Events.emit(
|
qwenOAuth2Events.emit(
|
||||||
QwenOAuth2Event.AuthProgress,
|
QwenOAuth2Event.AuthProgress,
|
||||||
'rate_limit',
|
eventType,
|
||||||
message,
|
message,
|
||||||
);
|
);
|
||||||
|
console.error('\n' + message);
|
||||||
|
return { success: false, reason, message };
|
||||||
|
};
|
||||||
|
|
||||||
console.log('\n' + message);
|
// Handle credential caching failures - stop polling immediately
|
||||||
|
if (errorMessage.includes('Failed to cache credentials')) {
|
||||||
|
return handleError('error', errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
// Return false to stop polling and go back to auth selection
|
// Handle 401 Unauthorized - device code expired or invalid
|
||||||
return { success: false, reason: 'rate_limit' };
|
if (errorMessage.includes('401') || statusCode === 401) {
|
||||||
|
return handleError(
|
||||||
|
'error',
|
||||||
|
'Device code expired or invalid, please restart the authorization process.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 429 Too Many Requests - rate limiting
|
||||||
|
if (errorMessage.includes('429') || statusCode === 429) {
|
||||||
|
return handleError(
|
||||||
|
'rate_limit',
|
||||||
|
'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.',
|
||||||
|
'rate_limit',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = `Error polling for token: ${errorMessage}`;
|
const message = `Error polling for token: ${errorMessage}`;
|
||||||
|
|
||||||
// Emit error event
|
|
||||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
|
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
|
||||||
|
|
||||||
// Check for cancellation before waiting
|
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
return { success: false, reason: 'cancelled' };
|
const message = 'Authentication cancelled by user.';
|
||||||
|
return { success: false, reason: 'cancelled', message };
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||||
@@ -818,11 +832,12 @@ async function authWithQwenDeviceFlow(
|
|||||||
);
|
);
|
||||||
|
|
||||||
console.error('\n' + timeoutMessage);
|
console.error('\n' + timeoutMessage);
|
||||||
return { success: false, reason: 'timeout' };
|
return { success: false, reason: 'timeout', message: timeoutMessage };
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
console.error('Device authorization flow failed:', errorMessage);
|
const message = `Device authorization flow failed: ${errorMessage}`;
|
||||||
return { success: false, reason: 'error' };
|
console.error(message);
|
||||||
|
return { success: false, reason: 'error', message };
|
||||||
} finally {
|
} finally {
|
||||||
// Clean up event listener
|
// Clean up event listener
|
||||||
qwenOAuth2Events.off(QwenOAuth2Event.AuthCancel, cancelHandler);
|
qwenOAuth2Events.off(QwenOAuth2Event.AuthCancel, cancelHandler);
|
||||||
@@ -852,10 +867,30 @@ async function loadCachedQwenCredentials(
|
|||||||
|
|
||||||
async function cacheQwenCredentials(credentials: QwenCredentials) {
|
async function cacheQwenCredentials(credentials: QwenCredentials) {
|
||||||
const filePath = getQwenCachedCredentialPath();
|
const filePath = getQwenCachedCredentialPath();
|
||||||
|
try {
|
||||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
|
||||||
const credString = JSON.stringify(credentials, null, 2);
|
const credString = JSON.stringify(credentials, null, 2);
|
||||||
await fs.writeFile(filePath, credString);
|
await fs.writeFile(filePath, credString);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// Handle file system errors (e.g., EACCES permission denied)
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
const errorCode =
|
||||||
|
error instanceof Error && 'code' in error
|
||||||
|
? (error as Error & { code?: string }).code
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (errorCode === 'EACCES') {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to cache credentials: Permission denied (EACCES). Current user has no permission to access \`${filePath}\`. Please check permissions.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throw error for other file system failures
|
||||||
|
throw new Error(
|
||||||
|
`Failed to cache credentials: error when creating folder \`${path.dirname(filePath)}\` and writing to \`${filePath}\`. ${errorMessage}. Please check permissions.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const EVENT_MALFORMED_JSON_RESPONSE =
|
|||||||
export const EVENT_FILE_OPERATION = 'qwen-code.file_operation';
|
export const EVENT_FILE_OPERATION = 'qwen-code.file_operation';
|
||||||
export const EVENT_MODEL_SLASH_COMMAND = 'qwen-code.slash_command.model';
|
export const EVENT_MODEL_SLASH_COMMAND = 'qwen-code.slash_command.model';
|
||||||
export const EVENT_SUBAGENT_EXECUTION = 'qwen-code.subagent_execution';
|
export const EVENT_SUBAGENT_EXECUTION = 'qwen-code.subagent_execution';
|
||||||
|
export const EVENT_AUTH = 'qwen-code.auth';
|
||||||
|
|
||||||
// Performance Events
|
// Performance Events
|
||||||
export const EVENT_STARTUP_PERFORMANCE = 'qwen-code.startup.performance';
|
export const EVENT_STARTUP_PERFORMANCE = 'qwen-code.startup.performance';
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export {
|
|||||||
logExtensionUninstall,
|
logExtensionUninstall,
|
||||||
logRipgrepFallback,
|
logRipgrepFallback,
|
||||||
logNextSpeakerCheck,
|
logNextSpeakerCheck,
|
||||||
|
logAuth,
|
||||||
} from './loggers.js';
|
} from './loggers.js';
|
||||||
export type { SlashCommandEvent, ChatCompressionEvent } from './types.js';
|
export type { SlashCommandEvent, ChatCompressionEvent } from './types.js';
|
||||||
export {
|
export {
|
||||||
@@ -61,6 +62,7 @@ export {
|
|||||||
ToolOutputTruncatedEvent,
|
ToolOutputTruncatedEvent,
|
||||||
RipgrepFallbackEvent,
|
RipgrepFallbackEvent,
|
||||||
NextSpeakerCheckEvent,
|
NextSpeakerCheckEvent,
|
||||||
|
AuthEvent,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js';
|
export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js';
|
||||||
export type { TelemetryEvent } from './types.js';
|
export type { TelemetryEvent } from './types.js';
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
EVENT_SUBAGENT_EXECUTION,
|
EVENT_SUBAGENT_EXECUTION,
|
||||||
EVENT_MALFORMED_JSON_RESPONSE,
|
EVENT_MALFORMED_JSON_RESPONSE,
|
||||||
EVENT_INVALID_CHUNK,
|
EVENT_INVALID_CHUNK,
|
||||||
|
EVENT_AUTH,
|
||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
import {
|
import {
|
||||||
recordApiErrorMetrics,
|
recordApiErrorMetrics,
|
||||||
@@ -83,6 +84,7 @@ import type {
|
|||||||
SubagentExecutionEvent,
|
SubagentExecutionEvent,
|
||||||
MalformedJsonResponseEvent,
|
MalformedJsonResponseEvent,
|
||||||
InvalidChunkEvent,
|
InvalidChunkEvent,
|
||||||
|
AuthEvent,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import type { UiEvent } from './uiTelemetry.js';
|
import type { UiEvent } from './uiTelemetry.js';
|
||||||
import { uiTelemetryService } from './uiTelemetry.js';
|
import { uiTelemetryService } from './uiTelemetry.js';
|
||||||
@@ -838,3 +840,29 @@ export function logExtensionDisable(
|
|||||||
};
|
};
|
||||||
logger.emit(logRecord);
|
logger.emit(logRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function logAuth(config: Config, event: AuthEvent): void {
|
||||||
|
QwenLogger.getInstance(config)?.logAuthEvent(event);
|
||||||
|
if (!isTelemetrySdkInitialized()) return;
|
||||||
|
|
||||||
|
const attributes: LogAttributes = {
|
||||||
|
...getCommonAttributes(config),
|
||||||
|
...event,
|
||||||
|
'event.name': EVENT_AUTH,
|
||||||
|
'event.timestamp': new Date().toISOString(),
|
||||||
|
auth_type: event.auth_type,
|
||||||
|
action_type: event.action_type,
|
||||||
|
status: event.status,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (event.error_message) {
|
||||||
|
attributes['error.message'] = event.error_message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logger = logs.getLogger(SERVICE_NAME);
|
||||||
|
const logRecord: LogRecord = {
|
||||||
|
body: `Auth event: ${event.action_type} ${event.status} for ${event.auth_type}`,
|
||||||
|
attributes,
|
||||||
|
};
|
||||||
|
logger.emit(logRecord);
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import type {
|
|||||||
ExtensionEnableEvent,
|
ExtensionEnableEvent,
|
||||||
ModelSlashCommandEvent,
|
ModelSlashCommandEvent,
|
||||||
ExtensionDisableEvent,
|
ExtensionDisableEvent,
|
||||||
|
AuthEvent,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { EndSessionEvent } from '../types.js';
|
import { EndSessionEvent } from '../types.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -746,6 +747,25 @@ export class QwenLogger {
|
|||||||
this.flushIfNeeded();
|
this.flushIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logAuthEvent(event: AuthEvent): void {
|
||||||
|
const snapshots: Record<string, unknown> = {
|
||||||
|
auth_type: event.auth_type,
|
||||||
|
action_type: event.action_type,
|
||||||
|
status: event.status,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (event.error_message) {
|
||||||
|
snapshots['error_message'] = event.error_message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rumEvent = this.createActionEvent('auth', 'auth', {
|
||||||
|
snapshots: JSON.stringify(snapshots),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.enqueueLogEvent(rumEvent);
|
||||||
|
this.flushIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
// misc events
|
// misc events
|
||||||
logFlashFallbackEvent(event: FlashFallbackEvent): void {
|
logFlashFallbackEvent(event: FlashFallbackEvent): void {
|
||||||
const rumEvent = this.createActionEvent('misc', 'flash_fallback', {
|
const rumEvent = this.createActionEvent('misc', 'flash_fallback', {
|
||||||
|
|||||||
@@ -686,6 +686,29 @@ export class SubagentExecutionEvent implements BaseTelemetryEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AuthEvent implements BaseTelemetryEvent {
|
||||||
|
'event.name': 'auth';
|
||||||
|
'event.timestamp': string;
|
||||||
|
auth_type: AuthType;
|
||||||
|
action_type: 'auto' | 'manual';
|
||||||
|
status: 'success' | 'error' | 'cancelled';
|
||||||
|
error_message?: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
auth_type: AuthType,
|
||||||
|
action_type: 'auto' | 'manual',
|
||||||
|
status: 'success' | 'error' | 'cancelled',
|
||||||
|
error_message?: string,
|
||||||
|
) {
|
||||||
|
this['event.name'] = 'auth';
|
||||||
|
this['event.timestamp'] = new Date().toISOString();
|
||||||
|
this.auth_type = auth_type;
|
||||||
|
this.action_type = action_type;
|
||||||
|
this.status = status;
|
||||||
|
this.error_message = error_message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type TelemetryEvent =
|
export type TelemetryEvent =
|
||||||
| StartSessionEvent
|
| StartSessionEvent
|
||||||
| EndSessionEvent
|
| EndSessionEvent
|
||||||
@@ -713,7 +736,8 @@ export type TelemetryEvent =
|
|||||||
| ExtensionInstallEvent
|
| ExtensionInstallEvent
|
||||||
| ExtensionUninstallEvent
|
| ExtensionUninstallEvent
|
||||||
| ToolOutputTruncatedEvent
|
| ToolOutputTruncatedEvent
|
||||||
| ModelSlashCommandEvent;
|
| ModelSlashCommandEvent
|
||||||
|
| AuthEvent;
|
||||||
|
|
||||||
export class ExtensionDisableEvent implements BaseTelemetryEvent {
|
export class ExtensionDisableEvent implements BaseTelemetryEvent {
|
||||||
'event.name': 'extension_disable';
|
'event.name': 'extension_disable';
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ import type { Config } from '../config/config.js';
|
|||||||
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
|
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
|
||||||
import type { ChildProcess } from 'node:child_process';
|
import type { ChildProcess } from 'node:child_process';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { ensureRipgrepPath } from '../utils/ripgrepUtils.js';
|
import { getRipgrepCommand } from '../utils/ripgrepUtils.js';
|
||||||
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
|
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
|
||||||
|
|
||||||
// Mock ripgrepUtils
|
// Mock ripgrepUtils
|
||||||
vi.mock('../utils/ripgrepUtils.js', () => ({
|
vi.mock('../utils/ripgrepUtils.js', () => ({
|
||||||
ensureRipgrepPath: vi.fn(),
|
getRipgrepCommand: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock child_process for ripgrep calls
|
// Mock child_process for ripgrep calls
|
||||||
@@ -109,7 +109,7 @@ describe('RipGrepTool', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
(ensureRipgrepPath as Mock).mockResolvedValue('/mock/path/to/rg');
|
(getRipgrepCommand as Mock).mockResolvedValue('/mock/path/to/rg');
|
||||||
mockSpawn.mockReset();
|
mockSpawn.mockReset();
|
||||||
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-'));
|
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-'));
|
||||||
fileExclusionsMock = {
|
fileExclusionsMock = {
|
||||||
@@ -588,18 +588,15 @@ describe('RipGrepTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error if ripgrep is not available', async () => {
|
it('should throw an error if ripgrep is not available', async () => {
|
||||||
// Make ensureRipgrepBinary throw
|
(getRipgrepCommand as Mock).mockResolvedValue(null);
|
||||||
(ensureRipgrepPath as Mock).mockRejectedValue(
|
|
||||||
new Error('Ripgrep binary not found'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const params: RipGrepToolParams = { pattern: 'world' };
|
const params: RipGrepToolParams = { pattern: 'world' };
|
||||||
const invocation = grepTool.build(params);
|
const invocation = grepTool.build(params);
|
||||||
|
|
||||||
expect(await invocation.execute(abortSignal)).toStrictEqual({
|
expect(await invocation.execute(abortSignal)).toStrictEqual({
|
||||||
llmContent:
|
llmContent:
|
||||||
'Error during grep search operation: Ripgrep binary not found',
|
'Error during grep search operation: ripgrep binary not found.',
|
||||||
returnDisplay: 'Error: Ripgrep binary not found',
|
returnDisplay: 'Error: ripgrep binary not found.',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { EOL } from 'node:os';
|
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import type { ToolInvocation, ToolResult } from './tools.js';
|
import type { ToolInvocation, ToolResult } from './tools.js';
|
||||||
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
|
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
|
||||||
@@ -14,7 +13,7 @@ import { ToolNames } from './tool-names.js';
|
|||||||
import { resolveAndValidatePath } from '../utils/paths.js';
|
import { resolveAndValidatePath } from '../utils/paths.js';
|
||||||
import { getErrorMessage } from '../utils/errors.js';
|
import { getErrorMessage } from '../utils/errors.js';
|
||||||
import type { Config } from '../config/config.js';
|
import type { Config } from '../config/config.js';
|
||||||
import { ensureRipgrepPath } from '../utils/ripgrepUtils.js';
|
import { getRipgrepCommand } from '../utils/ripgrepUtils.js';
|
||||||
import { SchemaValidator } from '../utils/schemaValidator.js';
|
import { SchemaValidator } from '../utils/schemaValidator.js';
|
||||||
import type { FileFilteringOptions } from '../config/constants.js';
|
import type { FileFilteringOptions } from '../config/constants.js';
|
||||||
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
|
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
|
||||||
@@ -88,7 +87,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Split into lines and count total matches
|
// Split into lines and count total matches
|
||||||
const allLines = rawOutput.split(EOL).filter((line) => line.trim());
|
const allLines = rawOutput.split('\n').filter((line) => line.trim());
|
||||||
const totalMatches = allLines.length;
|
const totalMatches = allLines.length;
|
||||||
const matchTerm = totalMatches === 1 ? 'match' : 'matches';
|
const matchTerm = totalMatches === 1 ? 'match' : 'matches';
|
||||||
|
|
||||||
@@ -159,7 +158,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
|||||||
returnDisplay: displayMessage,
|
returnDisplay: displayMessage,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error during GrepLogic execution: ${error}`);
|
console.error(`Error during ripgrep search operation: ${error}`);
|
||||||
const errorMessage = getErrorMessage(error);
|
const errorMessage = getErrorMessage(error);
|
||||||
return {
|
return {
|
||||||
llmContent: `Error during grep search operation: ${errorMessage}`,
|
llmContent: `Error during grep search operation: ${errorMessage}`,
|
||||||
@@ -210,11 +209,15 @@ class GrepToolInvocation extends BaseToolInvocation<
|
|||||||
rgArgs.push(absolutePath);
|
rgArgs.push(absolutePath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rgPath = this.config.getUseBuiltinRipgrep()
|
const rgCommand = await getRipgrepCommand(
|
||||||
? await ensureRipgrepPath()
|
this.config.getUseBuiltinRipgrep(),
|
||||||
: 'rg';
|
);
|
||||||
|
if (!rgCommand) {
|
||||||
|
throw new Error('ripgrep binary not found.');
|
||||||
|
}
|
||||||
|
|
||||||
const output = await new Promise<string>((resolve, reject) => {
|
const output = await new Promise<string>((resolve, reject) => {
|
||||||
const child = spawn(rgPath, rgArgs, {
|
const child = spawn(rgCommand, rgArgs, {
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -234,7 +237,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
|||||||
|
|
||||||
child.on('error', (err) => {
|
child.on('error', (err) => {
|
||||||
options.signal.removeEventListener('abort', cleanup);
|
options.signal.removeEventListener('abort', cleanup);
|
||||||
reject(new Error(`Failed to start ripgrep: ${err.message}.`));
|
reject(new Error(`failed to start ripgrep: ${err.message}.`));
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
@@ -256,7 +259,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
|||||||
|
|
||||||
return output;
|
return output;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error(`GrepLogic: ripgrep failed: ${getErrorMessage(error)}`);
|
console.error(`Ripgrep failed: ${getErrorMessage(error)}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
|
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
|
||||||
import {
|
import {
|
||||||
canUseRipgrep,
|
canUseRipgrep,
|
||||||
ensureRipgrepPath,
|
getRipgrepCommand,
|
||||||
getRipgrepPath,
|
getBuiltinRipgrep,
|
||||||
} from './ripgrepUtils.js';
|
} from './ripgrepUtils.js';
|
||||||
import { fileExists } from './fileUtils.js';
|
import { fileExists } from './fileUtils.js';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -27,7 +27,7 @@ describe('ripgrepUtils', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getRipgrepPath', () => {
|
describe('getBulltinRipgrepPath', () => {
|
||||||
it('should return path with .exe extension on Windows', () => {
|
it('should return path with .exe extension on Windows', () => {
|
||||||
const originalPlatform = process.platform;
|
const originalPlatform = process.platform;
|
||||||
const originalArch = process.arch;
|
const originalArch = process.arch;
|
||||||
@@ -36,7 +36,7 @@ describe('ripgrepUtils', () => {
|
|||||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||||
Object.defineProperty(process, 'arch', { value: 'x64' });
|
Object.defineProperty(process, 'arch', { value: 'x64' });
|
||||||
|
|
||||||
const rgPath = getRipgrepPath();
|
const rgPath = getBuiltinRipgrep();
|
||||||
|
|
||||||
expect(rgPath).toContain('x64-win32');
|
expect(rgPath).toContain('x64-win32');
|
||||||
expect(rgPath).toContain('rg.exe');
|
expect(rgPath).toContain('rg.exe');
|
||||||
@@ -55,7 +55,7 @@ describe('ripgrepUtils', () => {
|
|||||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||||
Object.defineProperty(process, 'arch', { value: 'arm64' });
|
Object.defineProperty(process, 'arch', { value: 'arm64' });
|
||||||
|
|
||||||
const rgPath = getRipgrepPath();
|
const rgPath = getBuiltinRipgrep();
|
||||||
|
|
||||||
expect(rgPath).toContain('arm64-darwin');
|
expect(rgPath).toContain('arm64-darwin');
|
||||||
expect(rgPath).toContain('rg');
|
expect(rgPath).toContain('rg');
|
||||||
@@ -75,7 +75,7 @@ describe('ripgrepUtils', () => {
|
|||||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||||
Object.defineProperty(process, 'arch', { value: 'x64' });
|
Object.defineProperty(process, 'arch', { value: 'x64' });
|
||||||
|
|
||||||
const rgPath = getRipgrepPath();
|
const rgPath = getBuiltinRipgrep();
|
||||||
|
|
||||||
expect(rgPath).toContain('x64-linux');
|
expect(rgPath).toContain('x64-linux');
|
||||||
expect(rgPath).toContain('rg');
|
expect(rgPath).toContain('rg');
|
||||||
@@ -87,7 +87,7 @@ describe('ripgrepUtils', () => {
|
|||||||
Object.defineProperty(process, 'arch', { value: originalArch });
|
Object.defineProperty(process, 'arch', { value: originalArch });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error for unsupported platform', () => {
|
it('should return null for unsupported platform', () => {
|
||||||
const originalPlatform = process.platform;
|
const originalPlatform = process.platform;
|
||||||
const originalArch = process.arch;
|
const originalArch = process.arch;
|
||||||
|
|
||||||
@@ -95,14 +95,14 @@ describe('ripgrepUtils', () => {
|
|||||||
Object.defineProperty(process, 'platform', { value: 'freebsd' });
|
Object.defineProperty(process, 'platform', { value: 'freebsd' });
|
||||||
Object.defineProperty(process, 'arch', { value: 'x64' });
|
Object.defineProperty(process, 'arch', { value: 'x64' });
|
||||||
|
|
||||||
expect(() => getRipgrepPath()).toThrow('Unsupported platform: freebsd');
|
expect(getBuiltinRipgrep()).toBeNull();
|
||||||
|
|
||||||
// Restore original values
|
// Restore original values
|
||||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||||
Object.defineProperty(process, 'arch', { value: originalArch });
|
Object.defineProperty(process, 'arch', { value: originalArch });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error for unsupported architecture', () => {
|
it('should return null for unsupported architecture', () => {
|
||||||
const originalPlatform = process.platform;
|
const originalPlatform = process.platform;
|
||||||
const originalArch = process.arch;
|
const originalArch = process.arch;
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ describe('ripgrepUtils', () => {
|
|||||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||||
Object.defineProperty(process, 'arch', { value: 'ia32' });
|
Object.defineProperty(process, 'arch', { value: 'ia32' });
|
||||||
|
|
||||||
expect(() => getRipgrepPath()).toThrow('Unsupported architecture: ia32');
|
expect(getBuiltinRipgrep()).toBeNull();
|
||||||
|
|
||||||
// Restore original values
|
// Restore original values
|
||||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||||
@@ -136,7 +136,7 @@ describe('ripgrepUtils', () => {
|
|||||||
Object.defineProperty(process, 'platform', { value: platform });
|
Object.defineProperty(process, 'platform', { value: platform });
|
||||||
Object.defineProperty(process, 'arch', { value: arch });
|
Object.defineProperty(process, 'arch', { value: arch });
|
||||||
|
|
||||||
const rgPath = getRipgrepPath();
|
const rgPath = getBuiltinRipgrep();
|
||||||
const binaryName = platform === 'win32' ? 'rg.exe' : 'rg';
|
const binaryName = platform === 'win32' ? 'rg.exe' : 'rg';
|
||||||
const expectedPathSegment = path.join(
|
const expectedPathSegment = path.join(
|
||||||
`${arch}-${platform}`,
|
`${arch}-${platform}`,
|
||||||
@@ -169,107 +169,77 @@ describe('ripgrepUtils', () => {
|
|||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(fileExists).toHaveBeenCalledOnce();
|
expect(fileExists).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fall back to system rg if bundled ripgrep binary does not exist', async () => {
|
|
||||||
(fileExists as Mock).mockResolvedValue(false);
|
|
||||||
// When useBuiltin is true but bundled binary doesn't exist,
|
|
||||||
// it should fall back to checking system rg (which will spawn a process)
|
|
||||||
// In this test environment, system rg is likely available, so result should be true
|
|
||||||
// unless spawn fails
|
|
||||||
|
|
||||||
const result = await canUseRipgrep();
|
|
||||||
|
|
||||||
// The test may pass or fail depending on system rg availability
|
|
||||||
// Just verify that fileExists was called to check bundled binary first
|
|
||||||
expect(fileExists).toHaveBeenCalledOnce();
|
|
||||||
// Result depends on whether system rg is installed
|
|
||||||
expect(typeof result).toBe('boolean');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Note: Tests for system ripgrep detection (useBuiltin=false) would require mocking
|
describe('ensureRipgrepPath', () => {
|
||||||
// the child_process spawn function, which is complex in ESM. These cases are tested
|
it('should return bundled ripgrep path if binary exists (useBuiltin=true)', async () => {
|
||||||
// indirectly through integration tests.
|
|
||||||
|
|
||||||
it('should return false if platform is unsupported', async () => {
|
|
||||||
const originalPlatform = process.platform;
|
|
||||||
|
|
||||||
// Mock unsupported platform
|
|
||||||
Object.defineProperty(process, 'platform', { value: 'aix' });
|
|
||||||
|
|
||||||
const result = await canUseRipgrep();
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
expect(fileExists).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Restore original value
|
|
||||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false if architecture is unsupported', async () => {
|
|
||||||
const originalArch = process.arch;
|
|
||||||
|
|
||||||
// Mock unsupported architecture
|
|
||||||
Object.defineProperty(process, 'arch', { value: 's390x' });
|
|
||||||
|
|
||||||
const result = await canUseRipgrep();
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
expect(fileExists).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Restore original value
|
|
||||||
Object.defineProperty(process, 'arch', { value: originalArch });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ensureRipgrepBinary', () => {
|
|
||||||
it('should return ripgrep path if binary exists', async () => {
|
|
||||||
(fileExists as Mock).mockResolvedValue(true);
|
(fileExists as Mock).mockResolvedValue(true);
|
||||||
|
|
||||||
const rgPath = await ensureRipgrepPath();
|
const rgPath = await getRipgrepCommand(true);
|
||||||
|
|
||||||
expect(rgPath).toBeDefined();
|
expect(rgPath).toBeDefined();
|
||||||
expect(rgPath).toContain('rg');
|
expect(rgPath).toContain('rg');
|
||||||
|
expect(rgPath).not.toBe('rg'); // Should be full path, not just 'rg'
|
||||||
expect(fileExists).toHaveBeenCalledOnce();
|
expect(fileExists).toHaveBeenCalledOnce();
|
||||||
expect(fileExists).toHaveBeenCalledWith(rgPath);
|
expect(fileExists).toHaveBeenCalledWith(rgPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error if binary does not exist', async () => {
|
it('should return bundled ripgrep path if binary exists (default)', async () => {
|
||||||
(fileExists as Mock).mockResolvedValue(false);
|
(fileExists as Mock).mockResolvedValue(true);
|
||||||
|
|
||||||
await expect(ensureRipgrepPath()).rejects.toThrow(
|
const rgPath = await getRipgrepCommand();
|
||||||
/Ripgrep binary not found/,
|
|
||||||
);
|
|
||||||
await expect(ensureRipgrepPath()).rejects.toThrow(/Platform:/);
|
|
||||||
await expect(ensureRipgrepPath()).rejects.toThrow(/Architecture:/);
|
|
||||||
|
|
||||||
expect(fileExists).toHaveBeenCalled();
|
expect(rgPath).toBeDefined();
|
||||||
|
expect(rgPath).toContain('rg');
|
||||||
|
expect(fileExists).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error with correct path information', async () => {
|
it('should fall back to system rg if bundled binary does not exist', async () => {
|
||||||
(fileExists as Mock).mockResolvedValue(false);
|
(fileExists as Mock).mockResolvedValue(false);
|
||||||
|
// When useBuiltin is true but bundled binary doesn't exist,
|
||||||
|
// it should fall back to checking system rg
|
||||||
|
// The test result depends on whether system rg is actually available
|
||||||
|
|
||||||
|
const rgPath = await getRipgrepCommand(true);
|
||||||
|
|
||||||
|
expect(fileExists).toHaveBeenCalledOnce();
|
||||||
|
// If system rg is available, it should return 'rg' (or 'rg.exe' on Windows)
|
||||||
|
// This test will pass if system ripgrep is installed
|
||||||
|
expect(rgPath).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use system rg when useBuiltin=false', async () => {
|
||||||
|
// When useBuiltin is false, should skip bundled check and go straight to system rg
|
||||||
|
const rgPath = await getRipgrepCommand(false);
|
||||||
|
|
||||||
|
// Should not check for bundled binary
|
||||||
|
expect(fileExists).not.toHaveBeenCalled();
|
||||||
|
// If system rg is available, it should return 'rg' (or 'rg.exe' on Windows)
|
||||||
|
expect(rgPath).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if neither bundled nor system ripgrep is available', async () => {
|
||||||
|
// This test only makes sense in an environment where system rg is not installed
|
||||||
|
// We'll skip this test in CI/local environments where rg might be available
|
||||||
|
// Instead, we test the error message format
|
||||||
|
const originalPlatform = process.platform;
|
||||||
|
|
||||||
|
// Use an unsupported platform to trigger the error path
|
||||||
|
Object.defineProperty(process, 'platform', { value: 'freebsd' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureRipgrepPath();
|
await getRipgrepCommand();
|
||||||
// Should not reach here
|
// If we get here without error, system rg was available, which is fine
|
||||||
expect(true).toBe(false);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(error).toBeInstanceOf(Error);
|
expect(error).toBeInstanceOf(Error);
|
||||||
const errorMessage = (error as Error).message;
|
const errorMessage = (error as Error).message;
|
||||||
expect(errorMessage).toContain('Ripgrep binary not found at');
|
// Should contain helpful error information
|
||||||
expect(errorMessage).toContain(process.platform);
|
expect(
|
||||||
expect(errorMessage).toContain(process.arch);
|
errorMessage.includes('Ripgrep binary not found') ||
|
||||||
|
errorMessage.includes('Failed to locate ripgrep') ||
|
||||||
|
errorMessage.includes('Unsupported platform'),
|
||||||
|
).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error if platform is unsupported', async () => {
|
|
||||||
const originalPlatform = process.platform;
|
|
||||||
|
|
||||||
// Mock unsupported platform
|
|
||||||
Object.defineProperty(process, 'platform', { value: 'openbsd' });
|
|
||||||
|
|
||||||
await expect(ensureRipgrepPath()).rejects.toThrow(
|
|
||||||
'Unsupported platform: openbsd',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Restore original value
|
// Restore original value
|
||||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||||
|
|||||||
@@ -18,37 +18,42 @@ type Architecture = 'x64' | 'arm64';
|
|||||||
/**
|
/**
|
||||||
* Maps process.platform values to vendor directory names
|
* Maps process.platform values to vendor directory names
|
||||||
*/
|
*/
|
||||||
function getPlatformString(platform: string): Platform {
|
function getPlatformString(platform: string): Platform | undefined {
|
||||||
switch (platform) {
|
switch (platform) {
|
||||||
case 'darwin':
|
case 'darwin':
|
||||||
case 'linux':
|
case 'linux':
|
||||||
case 'win32':
|
case 'win32':
|
||||||
return platform;
|
return platform;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported platform: ${platform}`);
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps process.arch values to vendor directory names
|
* Maps process.arch values to vendor directory names
|
||||||
*/
|
*/
|
||||||
function getArchitectureString(arch: string): Architecture {
|
function getArchitectureString(arch: string): Architecture | undefined {
|
||||||
switch (arch) {
|
switch (arch) {
|
||||||
case 'x64':
|
case 'x64':
|
||||||
case 'arm64':
|
case 'arm64':
|
||||||
return arch;
|
return arch;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported architecture: ${arch}`);
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the path to the bundled ripgrep binary for the current platform
|
* Returns the path to the bundled ripgrep binary for the current platform
|
||||||
|
* @returns The path to the bundled ripgrep binary, or null if not available
|
||||||
*/
|
*/
|
||||||
export function getRipgrepPath(): string {
|
export function getBuiltinRipgrep(): string | null {
|
||||||
const platform = getPlatformString(process.platform);
|
const platform = getPlatformString(process.platform);
|
||||||
const arch = getArchitectureString(process.arch);
|
const arch = getArchitectureString(process.arch);
|
||||||
|
|
||||||
|
if (!platform || !arch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Binary name includes .exe on Windows
|
// Binary name includes .exe on Windows
|
||||||
const binaryName = platform === 'win32' ? 'rg.exe' : 'rg';
|
const binaryName = platform === 'win32' ? 'rg.exe' : 'rg';
|
||||||
|
|
||||||
@@ -83,6 +88,51 @@ export function getRipgrepPath(): string {
|
|||||||
return vendorPath;
|
return vendorPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if system ripgrep is available and returns the command to use
|
||||||
|
* @returns The ripgrep command ('rg' or 'rg.exe') if available, or null if not found
|
||||||
|
*/
|
||||||
|
export async function getSystemRipgrep(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { spawn } = await import('node:child_process');
|
||||||
|
const rgCommand = process.platform === 'win32' ? 'rg.exe' : 'rg';
|
||||||
|
const isAvailable = await new Promise<boolean>((resolve) => {
|
||||||
|
const proc = spawn(rgCommand, ['--version']);
|
||||||
|
proc.on('error', () => resolve(false));
|
||||||
|
proc.on('exit', (code) => resolve(code === 0));
|
||||||
|
});
|
||||||
|
return isAvailable ? rgCommand : null;
|
||||||
|
} catch (_error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if ripgrep binary exists and returns its path
|
||||||
|
* @param useBuiltin If true, tries bundled ripgrep first, then falls back to system ripgrep.
|
||||||
|
* If false, only checks for system ripgrep.
|
||||||
|
* @returns The path to ripgrep binary ('rg' or 'rg.exe' for system ripgrep, or full path for bundled), or null if not available
|
||||||
|
*/
|
||||||
|
export async function getRipgrepCommand(
|
||||||
|
useBuiltin: boolean = true,
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
if (useBuiltin) {
|
||||||
|
// Try bundled ripgrep first
|
||||||
|
const rgPath = getBuiltinRipgrep();
|
||||||
|
if (rgPath && (await fileExists(rgPath))) {
|
||||||
|
return rgPath;
|
||||||
|
}
|
||||||
|
// Fallback to system rg if bundled binary is not available
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for system ripgrep
|
||||||
|
return await getSystemRipgrep();
|
||||||
|
} catch (_error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if ripgrep binary is available
|
* Checks if ripgrep binary is available
|
||||||
* @param useBuiltin If true, tries bundled ripgrep first, then falls back to system ripgrep.
|
* @param useBuiltin If true, tries bundled ripgrep first, then falls back to system ripgrep.
|
||||||
@@ -91,42 +141,6 @@ export function getRipgrepPath(): string {
|
|||||||
export async function canUseRipgrep(
|
export async function canUseRipgrep(
|
||||||
useBuiltin: boolean = true,
|
useBuiltin: boolean = true,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
const rgPath = await getRipgrepCommand(useBuiltin);
|
||||||
if (useBuiltin) {
|
return rgPath !== null;
|
||||||
// Try bundled ripgrep first
|
|
||||||
const rgPath = getRipgrepPath();
|
|
||||||
if (await fileExists(rgPath)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Fallback to system rg if bundled binary is not available
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for system ripgrep by trying to spawn 'rg --version'
|
|
||||||
const { spawn } = await import('node:child_process');
|
|
||||||
return await new Promise<boolean>((resolve) => {
|
|
||||||
const proc = spawn('rg', ['--version']);
|
|
||||||
proc.on('error', () => resolve(false));
|
|
||||||
proc.on('exit', (code) => resolve(code === 0));
|
|
||||||
});
|
|
||||||
} catch (_error) {
|
|
||||||
// Unsupported platform/arch or other error
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensures ripgrep binary exists and returns its path
|
|
||||||
* @throws Error if ripgrep binary is not available
|
|
||||||
*/
|
|
||||||
export async function ensureRipgrepPath(): Promise<string> {
|
|
||||||
const rgPath = getRipgrepPath();
|
|
||||||
|
|
||||||
if (!(await fileExists(rgPath))) {
|
|
||||||
throw new Error(
|
|
||||||
`Ripgrep binary not found at ${rgPath}. ` +
|
|
||||||
`Platform: ${process.platform}, Architecture: ${process.arch}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return rgPath;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user