mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
feat: implement Codex CLI authentication check and integrate with provider
- Added a new utility for checking Codex CLI authentication status using the 'codex login status' command. - Integrated the authentication check into the CodexProvider's installation detection and authentication methods. - Updated Codex CLI status display in the UI to reflect authentication status and method. - Enhanced error handling and logging for better debugging during authentication checks. - Refactored related components to ensure consistent handling of authentication across the application.
This commit is contained in:
98
apps/server/src/lib/codex-auth.ts
Normal file
98
apps/server/src/lib/codex-auth.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Shared utility for checking Codex CLI authentication status
|
||||||
|
*
|
||||||
|
* Uses 'codex login status' command to verify authentication.
|
||||||
|
* Never assumes authenticated - only returns true if CLI confirms.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawnProcess, getCodexAuthPath } from '@automaker/platform';
|
||||||
|
import { findCodexCliPath } from '@automaker/platform';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
const CODEX_COMMAND = 'codex';
|
||||||
|
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
|
||||||
|
|
||||||
|
export interface CodexAuthCheckResult {
|
||||||
|
authenticated: boolean;
|
||||||
|
method: 'api_key_env' | 'cli_authenticated' | 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check Codex authentication status using 'codex login status' command
|
||||||
|
*
|
||||||
|
* @param cliPath Optional CLI path. If not provided, will attempt to find it.
|
||||||
|
* @returns Authentication status and method
|
||||||
|
*/
|
||||||
|
export async function checkCodexAuthentication(
|
||||||
|
cliPath?: string | null
|
||||||
|
): Promise<CodexAuthCheckResult> {
|
||||||
|
console.log('[CodexAuth] checkCodexAuthentication called with cliPath:', cliPath);
|
||||||
|
|
||||||
|
const resolvedCliPath = cliPath || (await findCodexCliPath());
|
||||||
|
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
|
||||||
|
|
||||||
|
console.log('[CodexAuth] resolvedCliPath:', resolvedCliPath);
|
||||||
|
console.log('[CodexAuth] hasApiKey:', hasApiKey);
|
||||||
|
|
||||||
|
// Debug: Check auth file
|
||||||
|
const authFilePath = getCodexAuthPath();
|
||||||
|
console.log('[CodexAuth] Auth file path:', authFilePath);
|
||||||
|
try {
|
||||||
|
const authFileExists = fs.existsSync(authFilePath);
|
||||||
|
console.log('[CodexAuth] Auth file exists:', authFileExists);
|
||||||
|
if (authFileExists) {
|
||||||
|
const authContent = fs.readFileSync(authFilePath, 'utf-8');
|
||||||
|
console.log('[CodexAuth] Auth file content:', authContent.substring(0, 500)); // First 500 chars
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[CodexAuth] Error reading auth file:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If CLI is not installed, cannot be authenticated
|
||||||
|
if (!resolvedCliPath) {
|
||||||
|
console.log('[CodexAuth] No CLI path found, returning not authenticated');
|
||||||
|
return { authenticated: false, method: 'none' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[CodexAuth] Running: ' + resolvedCliPath + ' login status');
|
||||||
|
const result = await spawnProcess({
|
||||||
|
command: resolvedCliPath || CODEX_COMMAND,
|
||||||
|
args: ['login', 'status'],
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
TERM: 'dumb', // Avoid interactive output
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[CodexAuth] Command result:');
|
||||||
|
console.log('[CodexAuth] exitCode:', result.exitCode);
|
||||||
|
console.log('[CodexAuth] stdout:', JSON.stringify(result.stdout));
|
||||||
|
console.log('[CodexAuth] stderr:', JSON.stringify(result.stderr));
|
||||||
|
|
||||||
|
// Check both stdout and stderr for "logged in" - Codex CLI outputs to stderr
|
||||||
|
const combinedOutput = (result.stdout + result.stderr).toLowerCase();
|
||||||
|
const isLoggedIn = combinedOutput.includes('logged in');
|
||||||
|
console.log('[CodexAuth] isLoggedIn (contains "logged in" in stdout or stderr):', isLoggedIn);
|
||||||
|
|
||||||
|
if (result.exitCode === 0 && isLoggedIn) {
|
||||||
|
// Determine auth method based on what we know
|
||||||
|
const method = hasApiKey ? 'api_key_env' : 'cli_authenticated';
|
||||||
|
console.log('[CodexAuth] Authenticated! method:', method);
|
||||||
|
return { authenticated: true, method };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'[CodexAuth] Not authenticated. exitCode:',
|
||||||
|
result.exitCode,
|
||||||
|
'isLoggedIn:',
|
||||||
|
isLoggedIn
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[CodexAuth] Error running command:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[CodexAuth] Returning not authenticated');
|
||||||
|
return { authenticated: false, method: 'none' };
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
getDataDirectory,
|
getDataDirectory,
|
||||||
getCodexConfigDir,
|
getCodexConfigDir,
|
||||||
} from '@automaker/platform';
|
} from '@automaker/platform';
|
||||||
|
import { checkCodexAuthentication } from '../lib/codex-auth.js';
|
||||||
import {
|
import {
|
||||||
formatHistoryAsText,
|
formatHistoryAsText,
|
||||||
extractTextFromContent,
|
extractTextFromContent,
|
||||||
@@ -963,11 +964,21 @@ export class CodexProvider extends BaseProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async detectInstallation(): Promise<InstallationStatus> {
|
async detectInstallation(): Promise<InstallationStatus> {
|
||||||
|
console.log('[CodexProvider.detectInstallation] Starting...');
|
||||||
|
|
||||||
const cliPath = await findCodexCliPath();
|
const cliPath = await findCodexCliPath();
|
||||||
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
|
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
|
||||||
const authIndicators = await getCodexAuthIndicators();
|
const authIndicators = await getCodexAuthIndicators();
|
||||||
const installed = !!cliPath;
|
const installed = !!cliPath;
|
||||||
|
|
||||||
|
console.log('[CodexProvider.detectInstallation] cliPath:', cliPath);
|
||||||
|
console.log('[CodexProvider.detectInstallation] hasApiKey:', hasApiKey);
|
||||||
|
console.log(
|
||||||
|
'[CodexProvider.detectInstallation] authIndicators:',
|
||||||
|
JSON.stringify(authIndicators)
|
||||||
|
);
|
||||||
|
console.log('[CodexProvider.detectInstallation] installed:', installed);
|
||||||
|
|
||||||
let version = '';
|
let version = '';
|
||||||
if (installed) {
|
if (installed) {
|
||||||
try {
|
try {
|
||||||
@@ -977,19 +988,29 @@ export class CodexProvider extends BaseProvider {
|
|||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
});
|
});
|
||||||
version = result.stdout.trim();
|
version = result.stdout.trim();
|
||||||
} catch {
|
console.log('[CodexProvider.detectInstallation] version:', version);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[CodexProvider.detectInstallation] Error getting version:', error);
|
||||||
version = '';
|
version = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
// Determine auth status - always verify with CLI, never assume authenticated
|
||||||
|
console.log('[CodexProvider.detectInstallation] Calling checkCodexAuthentication...');
|
||||||
|
const authCheck = await checkCodexAuthentication(cliPath);
|
||||||
|
console.log('[CodexProvider.detectInstallation] authCheck result:', JSON.stringify(authCheck));
|
||||||
|
const authenticated = authCheck.authenticated;
|
||||||
|
|
||||||
|
const result = {
|
||||||
installed,
|
installed,
|
||||||
path: cliPath || undefined,
|
path: cliPath || undefined,
|
||||||
version: version || undefined,
|
version: version || undefined,
|
||||||
method: 'cli',
|
method: 'cli' as const, // Installation method
|
||||||
hasApiKey,
|
hasApiKey,
|
||||||
authenticated: authIndicators.hasOAuthToken || authIndicators.hasApiKey || hasApiKey,
|
authenticated,
|
||||||
};
|
};
|
||||||
|
console.log('[CodexProvider.detectInstallation] Final result:', JSON.stringify(result));
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
getAvailableModels(): ModelDefinition[] {
|
getAvailableModels(): ModelDefinition[] {
|
||||||
@@ -1001,94 +1022,68 @@ export class CodexProvider extends BaseProvider {
|
|||||||
* Check authentication status for Codex CLI
|
* Check authentication status for Codex CLI
|
||||||
*/
|
*/
|
||||||
async checkAuth(): Promise<CodexAuthStatus> {
|
async checkAuth(): Promise<CodexAuthStatus> {
|
||||||
|
console.log('[CodexProvider.checkAuth] Starting auth check...');
|
||||||
|
|
||||||
const cliPath = await findCodexCliPath();
|
const cliPath = await findCodexCliPath();
|
||||||
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
|
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
|
||||||
const authIndicators = await getCodexAuthIndicators();
|
const authIndicators = await getCodexAuthIndicators();
|
||||||
|
|
||||||
|
console.log('[CodexProvider.checkAuth] cliPath:', cliPath);
|
||||||
|
console.log('[CodexProvider.checkAuth] hasApiKey:', hasApiKey);
|
||||||
|
console.log('[CodexProvider.checkAuth] authIndicators:', JSON.stringify(authIndicators));
|
||||||
|
|
||||||
// Check for API key in environment
|
// Check for API key in environment
|
||||||
if (hasApiKey) {
|
if (hasApiKey) {
|
||||||
|
console.log('[CodexProvider.checkAuth] Has API key, returning authenticated');
|
||||||
return { authenticated: true, method: 'api_key' };
|
return { authenticated: true, method: 'api_key' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for OAuth/token from Codex CLI
|
// Check for OAuth/token from Codex CLI
|
||||||
if (authIndicators.hasOAuthToken || authIndicators.hasApiKey) {
|
if (authIndicators.hasOAuthToken || authIndicators.hasApiKey) {
|
||||||
|
console.log(
|
||||||
|
'[CodexProvider.checkAuth] Has OAuth token or API key in auth file, returning authenticated'
|
||||||
|
);
|
||||||
return { authenticated: true, method: 'oauth' };
|
return { authenticated: true, method: 'oauth' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// CLI is installed but not authenticated
|
// CLI is installed but not authenticated via indicators - try CLI command
|
||||||
|
console.log('[CodexProvider.checkAuth] No indicators found, trying CLI command...');
|
||||||
if (cliPath) {
|
if (cliPath) {
|
||||||
try {
|
try {
|
||||||
|
// Try 'codex login status' first (same as checkCodexAuthentication)
|
||||||
|
console.log('[CodexProvider.checkAuth] Running: ' + cliPath + ' login status');
|
||||||
const result = await spawnProcess({
|
const result = await spawnProcess({
|
||||||
command: cliPath || CODEX_COMMAND,
|
command: cliPath || CODEX_COMMAND,
|
||||||
args: ['auth', 'status', '--json'],
|
args: ['login', 'status'],
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
TERM: 'dumb',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
// If auth command succeeds, we're authenticated
|
console.log('[CodexProvider.checkAuth] login status result:');
|
||||||
if (result.exitCode === 0) {
|
console.log('[CodexProvider.checkAuth] exitCode:', result.exitCode);
|
||||||
|
console.log('[CodexProvider.checkAuth] stdout:', JSON.stringify(result.stdout));
|
||||||
|
console.log('[CodexProvider.checkAuth] stderr:', JSON.stringify(result.stderr));
|
||||||
|
|
||||||
|
// Check both stdout and stderr - Codex CLI outputs to stderr
|
||||||
|
const combinedOutput = (result.stdout + result.stderr).toLowerCase();
|
||||||
|
const isLoggedIn = combinedOutput.includes('logged in');
|
||||||
|
console.log('[CodexProvider.checkAuth] isLoggedIn:', isLoggedIn);
|
||||||
|
|
||||||
|
if (result.exitCode === 0 && isLoggedIn) {
|
||||||
|
console.log('[CodexProvider.checkAuth] CLI says logged in, returning authenticated');
|
||||||
return { authenticated: true, method: 'oauth' };
|
return { authenticated: true, method: 'oauth' };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
// Auth command failed, not authenticated
|
console.log('[CodexProvider.checkAuth] Error running login status:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[CodexProvider.checkAuth] Not authenticated');
|
||||||
return { authenticated: false, method: 'none' };
|
return { authenticated: false, method: 'none' };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Deduplicate text blocks in Codex assistant messages
|
|
||||||
*
|
|
||||||
* Codex can send:
|
|
||||||
* 1. Duplicate consecutive text blocks (same text twice in a row)
|
|
||||||
* 2. A final accumulated block containing ALL previous text
|
|
||||||
*
|
|
||||||
* This method filters out these duplicates to prevent UI stuttering.
|
|
||||||
*/
|
|
||||||
private deduplicateTextBlocks(
|
|
||||||
content: Array<{ type: string; text?: string }>,
|
|
||||||
lastTextBlock: string,
|
|
||||||
accumulatedText: string
|
|
||||||
): { content: Array<{ type: string; text?: string }>; lastBlock: string; accumulated: string } {
|
|
||||||
const filtered: Array<{ type: string; text?: string }> = [];
|
|
||||||
let newLastBlock = lastTextBlock;
|
|
||||||
let newAccumulated = accumulatedText;
|
|
||||||
|
|
||||||
for (const block of content) {
|
|
||||||
if (block.type !== 'text' || !block.text) {
|
|
||||||
filtered.push(block);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = block.text;
|
|
||||||
|
|
||||||
// Skip empty text
|
|
||||||
if (!text.trim()) continue;
|
|
||||||
|
|
||||||
// Skip duplicate consecutive text blocks
|
|
||||||
if (text === newLastBlock) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip final accumulated text block
|
|
||||||
// Codex sends one large block containing ALL previous text at the end
|
|
||||||
if (newAccumulated.length > 100 && text.length > newAccumulated.length * 0.8) {
|
|
||||||
const normalizedAccum = newAccumulated.replace(/\s+/g, ' ').trim();
|
|
||||||
const normalizedNew = text.replace(/\s+/g, ' ').trim();
|
|
||||||
if (normalizedNew.includes(normalizedAccum.slice(0, 100))) {
|
|
||||||
// This is the final accumulated block, skip it
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is a valid new text block
|
|
||||||
newLastBlock = text;
|
|
||||||
newAccumulated += text;
|
|
||||||
filtered.push(block);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { content: filtered, lastBlock: newLastBlock, accumulated: newAccumulated };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the detected CLI path (public accessor for status endpoints)
|
* Get the detected CLI path (public accessor for status endpoints)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ export function createClaudeRoutes(service: ClaudeUsageService): Router {
|
|||||||
// Check if Claude CLI is available first
|
// Check if Claude CLI is available first
|
||||||
const isAvailable = await service.isAvailable();
|
const isAvailable = await service.isAvailable();
|
||||||
if (!isAvailable) {
|
if (!isAvailable) {
|
||||||
res.status(503).json({
|
// IMPORTANT: This endpoint is behind Automaker session auth already.
|
||||||
|
// Use a 200 + error payload for Claude CLI issues so the UI doesn't
|
||||||
|
// interpret it as an invalid Automaker session (401/403 triggers logout).
|
||||||
|
res.status(200).json({
|
||||||
error: 'Claude CLI not found',
|
error: 'Claude CLI not found',
|
||||||
message: "Please install Claude Code CLI and run 'claude login' to authenticate",
|
message: "Please install Claude Code CLI and run 'claude login' to authenticate",
|
||||||
});
|
});
|
||||||
@@ -26,12 +29,13 @@ export function createClaudeRoutes(service: ClaudeUsageService): Router {
|
|||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|
||||||
if (message.includes('Authentication required') || message.includes('token_expired')) {
|
if (message.includes('Authentication required') || message.includes('token_expired')) {
|
||||||
res.status(401).json({
|
// Do NOT use 401/403 here: that status code is reserved for Automaker session auth.
|
||||||
|
res.status(200).json({
|
||||||
error: 'Authentication required',
|
error: 'Authentication required',
|
||||||
message: "Please run 'claude login' to authenticate",
|
message: "Please run 'claude login' to authenticate",
|
||||||
});
|
});
|
||||||
} else if (message.includes('timed out')) {
|
} else if (message.includes('timed out')) {
|
||||||
res.status(504).json({
|
res.status(200).json({
|
||||||
error: 'Command timed out',
|
error: 'Command timed out',
|
||||||
message: 'The Claude CLI took too long to respond',
|
message: 'The Claude CLI took too long to respond',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ export function createCodexRoutes(service: CodexUsageService): Router {
|
|||||||
// Check if Codex CLI is available first
|
// Check if Codex CLI is available first
|
||||||
const isAvailable = await service.isAvailable();
|
const isAvailable = await service.isAvailable();
|
||||||
if (!isAvailable) {
|
if (!isAvailable) {
|
||||||
res.status(503).json({
|
// IMPORTANT: This endpoint is behind Automaker session auth already.
|
||||||
|
// Use a 200 + error payload for Codex CLI issues so the UI doesn't
|
||||||
|
// interpret it as an invalid Automaker session (401/403 triggers logout).
|
||||||
|
res.status(200).json({
|
||||||
error: 'Codex CLI not found',
|
error: 'Codex CLI not found',
|
||||||
message: "Please install Codex CLI and run 'codex login' to authenticate",
|
message: "Please install Codex CLI and run 'codex login' to authenticate",
|
||||||
});
|
});
|
||||||
@@ -26,18 +29,19 @@ export function createCodexRoutes(service: CodexUsageService): Router {
|
|||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|
||||||
if (message.includes('not authenticated') || message.includes('login')) {
|
if (message.includes('not authenticated') || message.includes('login')) {
|
||||||
res.status(401).json({
|
// Do NOT use 401/403 here: that status code is reserved for Automaker session auth.
|
||||||
|
res.status(200).json({
|
||||||
error: 'Authentication required',
|
error: 'Authentication required',
|
||||||
message: "Please run 'codex login' to authenticate",
|
message: "Please run 'codex login' to authenticate",
|
||||||
});
|
});
|
||||||
} else if (message.includes('not available') || message.includes('does not provide')) {
|
} else if (message.includes('not available') || message.includes('does not provide')) {
|
||||||
// This is the expected case - Codex doesn't provide usage stats
|
// This is the expected case - Codex doesn't provide usage stats
|
||||||
res.status(503).json({
|
res.status(200).json({
|
||||||
error: 'Usage statistics not available',
|
error: 'Usage statistics not available',
|
||||||
message: message,
|
message: message,
|
||||||
});
|
});
|
||||||
} else if (message.includes('timed out')) {
|
} else if (message.includes('timed out')) {
|
||||||
res.status(504).json({
|
res.status(200).json({
|
||||||
error: 'Command timed out',
|
error: 'Command timed out',
|
||||||
message: 'The Codex CLI took too long to respond',
|
message: 'The Codex CLI took too long to respond',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ export function createCodexStatusHandler() {
|
|||||||
const provider = new CodexProvider();
|
const provider = new CodexProvider();
|
||||||
const status = await provider.detectInstallation();
|
const status = await provider.detectInstallation();
|
||||||
|
|
||||||
|
// Derive auth method from authenticated status and API key presence
|
||||||
|
let authMethod = 'none';
|
||||||
|
if (status.authenticated) {
|
||||||
|
authMethod = status.hasApiKey ? 'api_key_env' : 'cli_authenticated';
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
installed: status.installed,
|
installed: status.installed,
|
||||||
@@ -26,7 +32,7 @@ export function createCodexStatusHandler() {
|
|||||||
path: status.path || null,
|
path: status.path || null,
|
||||||
auth: {
|
auth: {
|
||||||
authenticated: status.authenticated || false,
|
authenticated: status.authenticated || false,
|
||||||
method: status.method || 'cli',
|
method: authMethod,
|
||||||
hasApiKey: status.hasApiKey || false,
|
hasApiKey: status.hasApiKey || false,
|
||||||
},
|
},
|
||||||
installCommand,
|
installCommand,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { spawn } from 'child_process';
|
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
|
import { findCodexCliPath } from '@automaker/platform';
|
||||||
|
import { checkCodexAuthentication } from '../lib/codex-auth.js';
|
||||||
|
|
||||||
export interface CodexRateLimitWindow {
|
export interface CodexRateLimitWindow {
|
||||||
limit: number;
|
limit: number;
|
||||||
@@ -40,21 +41,16 @@ export interface CodexUsageData {
|
|||||||
export class CodexUsageService {
|
export class CodexUsageService {
|
||||||
private codexBinary = 'codex';
|
private codexBinary = 'codex';
|
||||||
private isWindows = os.platform() === 'win32';
|
private isWindows = os.platform() === 'win32';
|
||||||
|
private cachedCliPath: string | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if Codex CLI is available on the system
|
* Check if Codex CLI is available on the system
|
||||||
*/
|
*/
|
||||||
async isAvailable(): Promise<boolean> {
|
async isAvailable(): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
// Prefer our platform-aware resolver over `which/where` because the server
|
||||||
const checkCmd = this.isWindows ? 'where' : 'which';
|
// process PATH may not include npm global bins (nvm/fnm/volta/pnpm).
|
||||||
const proc = spawn(checkCmd, [this.codexBinary]);
|
this.cachedCliPath = await findCodexCliPath();
|
||||||
proc.on('close', (code) => {
|
return Boolean(this.cachedCliPath);
|
||||||
resolve(code === 0);
|
|
||||||
});
|
|
||||||
proc.on('error', () => {
|
|
||||||
resolve(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,29 +80,9 @@ export class CodexUsageService {
|
|||||||
* Check if Codex is authenticated
|
* Check if Codex is authenticated
|
||||||
*/
|
*/
|
||||||
private async checkAuthentication(): Promise<boolean> {
|
private async checkAuthentication(): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
// Use the cached CLI path if available, otherwise fall back to finding it
|
||||||
const proc = spawn(this.codexBinary, ['login', 'status'], {
|
const cliPath = this.cachedCliPath || (await findCodexCliPath());
|
||||||
env: {
|
const authCheck = await checkCodexAuthentication(cliPath);
|
||||||
...process.env,
|
return authCheck.authenticated;
|
||||||
TERM: 'dumb', // Avoid interactive output
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let output = '';
|
|
||||||
|
|
||||||
proc.stdout.on('data', (data) => {
|
|
||||||
output += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.on('close', (code) => {
|
|
||||||
// Check if output indicates logged in
|
|
||||||
const isLoggedIn = output.toLowerCase().includes('logged in');
|
|
||||||
resolve(code === 0 && isLoggedIn);
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.on('error', () => {
|
|
||||||
resolve(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { LogOut, RefreshCcw } from 'lucide-react';
|
import { LogOut } from 'lucide-react';
|
||||||
|
|
||||||
export function LoggedOutView() {
|
export function LoggedOutView() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -22,10 +22,6 @@ export function LoggedOutView() {
|
|||||||
<Button className="w-full" onClick={() => navigate({ to: '/login' })}>
|
<Button className="w-full" onClick={() => navigate({ to: '/login' })}>
|
||||||
Go to login
|
Go to login
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="w-full" variant="secondary" onClick={() => window.location.reload()}>
|
|
||||||
<RefreshCcw className="mr-2 h-4 w-4" />
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
|||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
|
|
||||||
import { useSettingsView } from './settings-view/hooks';
|
import { useSettingsView, type SettingsViewId } from './settings-view/hooks';
|
||||||
import { NAV_ITEMS } from './settings-view/config/navigation';
|
import { NAV_ITEMS } from './settings-view/config/navigation';
|
||||||
import { SettingsHeader } from './settings-view/components/settings-header';
|
import { SettingsHeader } from './settings-view/components/settings-header';
|
||||||
import { KeyboardMapDialog } from './settings-view/components/keyboard-map-dialog';
|
import { KeyboardMapDialog } from './settings-view/components/keyboard-map-dialog';
|
||||||
@@ -18,7 +18,7 @@ import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature
|
|||||||
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
|
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
|
||||||
import { AccountSection } from './settings-view/account';
|
import { AccountSection } from './settings-view/account';
|
||||||
import { SecuritySection } from './settings-view/security';
|
import { SecuritySection } from './settings-view/security';
|
||||||
import { ProviderTabs } from './settings-view/providers';
|
import { ClaudeSettingsTab, CursorSettingsTab, CodexSettingsTab } from './settings-view/providers';
|
||||||
import { MCPServersSection } from './settings-view/mcp-servers';
|
import { MCPServersSection } from './settings-view/mcp-servers';
|
||||||
import { PromptCustomizationSection } from './settings-view/prompts';
|
import { PromptCustomizationSection } from './settings-view/prompts';
|
||||||
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
|
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
|
||||||
@@ -88,15 +88,30 @@ export function SettingsView() {
|
|||||||
// Use settings view navigation hook
|
// Use settings view navigation hook
|
||||||
const { activeView, navigateTo } = useSettingsView();
|
const { activeView, navigateTo } = useSettingsView();
|
||||||
|
|
||||||
|
// Handle navigation - if navigating to 'providers', default to 'claude-provider'
|
||||||
|
const handleNavigate = (viewId: SettingsViewId) => {
|
||||||
|
if (viewId === 'providers') {
|
||||||
|
navigateTo('claude-provider');
|
||||||
|
} else {
|
||||||
|
navigateTo(viewId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
|
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
|
||||||
|
|
||||||
// Render the active section based on current view
|
// Render the active section based on current view
|
||||||
const renderActiveSection = () => {
|
const renderActiveSection = () => {
|
||||||
switch (activeView) {
|
switch (activeView) {
|
||||||
|
case 'claude-provider':
|
||||||
|
return <ClaudeSettingsTab />;
|
||||||
|
case 'cursor-provider':
|
||||||
|
return <CursorSettingsTab />;
|
||||||
|
case 'codex-provider':
|
||||||
|
return <CodexSettingsTab />;
|
||||||
case 'providers':
|
case 'providers':
|
||||||
case 'claude': // Backwards compatibility
|
case 'claude': // Backwards compatibility - redirect to claude-provider
|
||||||
return <ProviderTabs defaultTab={activeView === 'claude' ? 'claude' : undefined} />;
|
return <ClaudeSettingsTab />;
|
||||||
case 'mcp-servers':
|
case 'mcp-servers':
|
||||||
return <MCPServersSection />;
|
return <MCPServersSection />;
|
||||||
case 'prompts':
|
case 'prompts':
|
||||||
@@ -181,7 +196,7 @@ export function SettingsView() {
|
|||||||
navItems={NAV_ITEMS}
|
navItems={NAV_ITEMS}
|
||||||
activeSection={activeView}
|
activeSection={activeView}
|
||||||
currentProject={currentProject}
|
currentProject={currentProject}
|
||||||
onNavigate={navigateTo}
|
onNavigate={handleNavigate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content Panel - Shows only the active section */}
|
{/* Content Panel - Shows only the active section */}
|
||||||
|
|||||||
@@ -1,24 +1,237 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import type { CliStatus } from '../shared/types';
|
import type { CliStatus } from '../shared/types';
|
||||||
import { CliStatusCard } from './cli-status-card';
|
import type { CodexAuthStatus } from '@/store/setup-store';
|
||||||
import { OpenAIIcon } from '@/components/ui/provider-icon';
|
import { OpenAIIcon } from '@/components/ui/provider-icon';
|
||||||
|
|
||||||
interface CliStatusProps {
|
interface CliStatusProps {
|
||||||
status: CliStatus | null;
|
status: CliStatus | null;
|
||||||
|
authStatus?: CodexAuthStatus | null;
|
||||||
isChecking: boolean;
|
isChecking: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CodexCliStatus({ status, isChecking, onRefresh }: CliStatusProps) {
|
function getAuthMethodLabel(method: string): string {
|
||||||
|
switch (method) {
|
||||||
|
case 'api_key':
|
||||||
|
return 'API Key';
|
||||||
|
case 'api_key_env':
|
||||||
|
return 'API Key (Environment)';
|
||||||
|
case 'cli_authenticated':
|
||||||
|
case 'oauth':
|
||||||
|
return 'CLI Authentication';
|
||||||
|
default:
|
||||||
|
return method || 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonPulse({ className }: { className?: string }) {
|
||||||
|
return <div className={cn('animate-pulse bg-muted/50 rounded', className)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodexCliStatusSkeleton() {
|
||||||
return (
|
return (
|
||||||
<CliStatusCard
|
<div
|
||||||
title="Codex CLI"
|
className={cn(
|
||||||
description="Codex CLI powers OpenAI models for coding and automation workflows."
|
'rounded-2xl overflow-hidden',
|
||||||
status={status}
|
'border border-border/50',
|
||||||
isChecking={isChecking}
|
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||||
onRefresh={onRefresh}
|
'shadow-sm shadow-black/5'
|
||||||
refreshTestId="refresh-codex-cli"
|
)}
|
||||||
icon={OpenAIIcon}
|
>
|
||||||
fallbackRecommendation="Install Codex CLI to unlock OpenAI models with tool support."
|
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||||
/>
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<SkeletonPulse className="w-9 h-9 rounded-xl" />
|
||||||
|
<SkeletonPulse className="h-6 w-36" />
|
||||||
|
</div>
|
||||||
|
<SkeletonPulse className="w-9 h-9 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-12">
|
||||||
|
<SkeletonPulse className="h-4 w-80" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
{/* Installation status skeleton */}
|
||||||
|
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
|
||||||
|
<SkeletonPulse className="w-10 h-10 rounded-xl" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<SkeletonPulse className="h-4 w-40" />
|
||||||
|
<SkeletonPulse className="h-3 w-32" />
|
||||||
|
<SkeletonPulse className="h-3 w-48" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Auth status skeleton */}
|
||||||
|
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
|
||||||
|
<SkeletonPulse className="w-10 h-10 rounded-xl" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<SkeletonPulse className="h-4 w-28" />
|
||||||
|
<SkeletonPulse className="h-3 w-36" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodexCliStatus({ status, authStatus, isChecking, onRefresh }: CliStatusProps) {
|
||||||
|
if (!status) return <CodexCliStatusSkeleton />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-2xl overflow-hidden',
|
||||||
|
'border border-border/50',
|
||||||
|
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||||
|
'shadow-sm shadow-black/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||||
|
<OpenAIIcon className="w-5 h-5 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground tracking-tight">Codex CLI</h2>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={isChecking}
|
||||||
|
data-testid="refresh-codex-cli"
|
||||||
|
title="Refresh Codex CLI detection"
|
||||||
|
className={cn(
|
||||||
|
'h-9 w-9 rounded-lg',
|
||||||
|
'hover:bg-accent/50 hover:scale-105',
|
||||||
|
'transition-all duration-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('w-4 h-4', isChecking && 'animate-spin')} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||||
|
Codex CLI powers OpenAI models for coding and automation workflows.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
{status.success && status.status === 'installed' ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-emerald-400">Codex CLI Installed</p>
|
||||||
|
<div className="text-xs text-emerald-400/70 mt-1.5 space-y-0.5">
|
||||||
|
{status.method && (
|
||||||
|
<p>
|
||||||
|
Method: <span className="font-mono">{status.method}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{status.version && (
|
||||||
|
<p>
|
||||||
|
Version: <span className="font-mono">{status.version}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{status.path && (
|
||||||
|
<p className="truncate" title={status.path}>
|
||||||
|
Path: <span className="font-mono text-[10px]">{status.path}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Authentication Status */}
|
||||||
|
{authStatus?.authenticated ? (
|
||||||
|
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-emerald-400">Authenticated</p>
|
||||||
|
<div className="text-xs text-emerald-400/70 mt-1.5">
|
||||||
|
<p>
|
||||||
|
Method:{' '}
|
||||||
|
<span className="font-mono">{getAuthMethodLabel(authStatus.method)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
|
||||||
|
<XCircle className="w-5 h-5 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-amber-400">Not Authenticated</p>
|
||||||
|
<p className="text-xs text-amber-400/70 mt-1">
|
||||||
|
Run <code className="font-mono bg-amber-500/10 px-1 rounded">codex login</code>{' '}
|
||||||
|
or set an API key to authenticate.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status.recommendation && (
|
||||||
|
<p className="text-xs text-muted-foreground/70 ml-1">{status.recommendation}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
|
||||||
|
<AlertCircle className="w-5 h-5 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-amber-400">Codex CLI Not Detected</p>
|
||||||
|
<p className="text-xs text-amber-400/70 mt-1">
|
||||||
|
{status.recommendation ||
|
||||||
|
'Install Codex CLI to unlock OpenAI models with tool support.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{status.installCommands && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs font-medium text-foreground/80">Installation Commands:</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{status.installCommands.npm && (
|
||||||
|
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
|
||||||
|
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
|
||||||
|
npm
|
||||||
|
</p>
|
||||||
|
<code className="text-xs text-foreground/80 font-mono break-all">
|
||||||
|
{status.installCommands.npm}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status.installCommands.macos && (
|
||||||
|
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
|
||||||
|
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
|
||||||
|
macOS/Linux
|
||||||
|
</p>
|
||||||
|
<code className="text-xs text-foreground/80 font-mono break-all">
|
||||||
|
{status.installCommands.macos}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status.installCommands.windows && (
|
||||||
|
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
|
||||||
|
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
|
||||||
|
Windows (PowerShell)
|
||||||
|
</p>
|
||||||
|
<code className="text-xs text-foreground/80 font-mono break-all">
|
||||||
|
{status.installCommands.windows}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,85 @@ function NavButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NavItemWithSubItems({
|
||||||
|
item,
|
||||||
|
activeSection,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
item: NavigationItem;
|
||||||
|
activeSection: SettingsViewId;
|
||||||
|
onNavigate: (sectionId: SettingsViewId) => void;
|
||||||
|
}) {
|
||||||
|
const hasActiveSubItem = item.subItems?.some((subItem) => subItem.id === activeSection) ?? false;
|
||||||
|
const isParentActive = item.id === activeSection;
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Parent item - non-clickable label */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-sm font-medium text-muted-foreground',
|
||||||
|
isParentActive || (hasActiveSubItem && 'text-foreground')
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
'w-4 h-4 shrink-0 transition-all duration-200',
|
||||||
|
isParentActive || hasActiveSubItem ? 'text-brand-500' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
{/* Sub-items - always displayed */}
|
||||||
|
{item.subItems && (
|
||||||
|
<div className="ml-4 mt-1 space-y-1">
|
||||||
|
{item.subItems.map((subItem) => {
|
||||||
|
const SubIcon = subItem.icon;
|
||||||
|
const isSubActive = subItem.id === activeSection;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={subItem.id}
|
||||||
|
onClick={() => onNavigate(subItem.id)}
|
||||||
|
className={cn(
|
||||||
|
'group w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 ease-out text-left relative overflow-hidden',
|
||||||
|
isSubActive
|
||||||
|
? [
|
||||||
|
'bg-gradient-to-r from-brand-500/15 via-brand-500/10 to-brand-600/5',
|
||||||
|
'text-foreground',
|
||||||
|
'border border-brand-500/25',
|
||||||
|
'shadow-sm shadow-brand-500/5',
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
'text-muted-foreground hover:text-foreground',
|
||||||
|
'hover:bg-accent/50',
|
||||||
|
'border border-transparent hover:border-border/40',
|
||||||
|
],
|
||||||
|
'hover:scale-[1.01] active:scale-[0.98]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Active indicator bar */}
|
||||||
|
{isSubActive && (
|
||||||
|
<div className="absolute inset-y-0 left-0 w-0.5 bg-gradient-to-b from-brand-400 via-brand-500 to-brand-600 rounded-r-full" />
|
||||||
|
)}
|
||||||
|
<SubIcon
|
||||||
|
className={cn(
|
||||||
|
'w-4 h-4 shrink-0 transition-all duration-200',
|
||||||
|
isSubActive
|
||||||
|
? 'text-brand-500'
|
||||||
|
: 'group-hover:text-brand-400 group-hover:scale-110'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{subItem.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsNavigation({
|
export function SettingsNavigation({
|
||||||
activeSection,
|
activeSection,
|
||||||
currentProject,
|
currentProject,
|
||||||
@@ -78,14 +157,23 @@ export function SettingsNavigation({
|
|||||||
|
|
||||||
{/* Global Settings Items */}
|
{/* Global Settings Items */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{GLOBAL_NAV_ITEMS.map((item) => (
|
{GLOBAL_NAV_ITEMS.map((item) =>
|
||||||
<NavButton
|
item.subItems ? (
|
||||||
key={item.id}
|
<NavItemWithSubItems
|
||||||
item={item}
|
key={item.id}
|
||||||
isActive={activeSection === item.id}
|
item={item}
|
||||||
onNavigate={onNavigate}
|
activeSection={activeSection}
|
||||||
/>
|
onNavigate={onNavigate}
|
||||||
))}
|
/>
|
||||||
|
) : (
|
||||||
|
<NavButton
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
isActive={activeSection === item.id}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Project Settings - only show when a project is selected */}
|
{/* Project Settings - only show when a project is selected */}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from 'react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Key,
|
Key,
|
||||||
@@ -14,12 +15,14 @@ import {
|
|||||||
User,
|
User,
|
||||||
Shield,
|
Shield,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||||
import type { SettingsViewId } from '../hooks/use-settings-view';
|
import type { SettingsViewId } from '../hooks/use-settings-view';
|
||||||
|
|
||||||
export interface NavigationItem {
|
export interface NavigationItem {
|
||||||
id: SettingsViewId;
|
id: SettingsViewId;
|
||||||
label: string;
|
label: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon | React.ComponentType<{ className?: string }>;
|
||||||
|
subItems?: NavigationItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavigationGroup {
|
export interface NavigationGroup {
|
||||||
@@ -30,7 +33,16 @@ export interface NavigationGroup {
|
|||||||
// Global settings - always visible
|
// Global settings - always visible
|
||||||
export const GLOBAL_NAV_ITEMS: NavigationItem[] = [
|
export const GLOBAL_NAV_ITEMS: NavigationItem[] = [
|
||||||
{ id: 'api-keys', label: 'API Keys', icon: Key },
|
{ id: 'api-keys', label: 'API Keys', icon: Key },
|
||||||
{ id: 'providers', label: 'AI Providers', icon: Bot },
|
{
|
||||||
|
id: 'providers',
|
||||||
|
label: 'AI Providers',
|
||||||
|
icon: Bot,
|
||||||
|
subItems: [
|
||||||
|
{ id: 'claude-provider', label: 'Claude', icon: AnthropicIcon },
|
||||||
|
{ id: 'cursor-provider', label: 'Cursor', icon: CursorIcon },
|
||||||
|
{ id: 'codex-provider', label: 'Codex', icon: OpenAIIcon },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug },
|
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug },
|
||||||
{ id: 'prompts', label: 'Prompt Customization', icon: MessageSquareText },
|
{ id: 'prompts', label: 'Prompt Customization', icon: MessageSquareText },
|
||||||
{ id: 'model-defaults', label: 'Model Defaults', icon: Workflow },
|
{ id: 'model-defaults', label: 'Model Defaults', icon: Workflow },
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ export type SettingsViewId =
|
|||||||
| 'api-keys'
|
| 'api-keys'
|
||||||
| 'claude'
|
| 'claude'
|
||||||
| 'providers'
|
| 'providers'
|
||||||
|
| 'claude-provider'
|
||||||
|
| 'cursor-provider'
|
||||||
|
| 'codex-provider'
|
||||||
| 'mcp-servers'
|
| 'mcp-servers'
|
||||||
| 'prompts'
|
| 'prompts'
|
||||||
| 'model-defaults'
|
| 'model-defaults'
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function CodexSettingsTab() {
|
|||||||
}
|
}
|
||||||
: null);
|
: null);
|
||||||
|
|
||||||
// Load Codex CLI status on mount
|
// Load Codex CLI status and auth status on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkCodexStatus = async () => {
|
const checkCodexStatus = async () => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
@@ -158,11 +158,13 @@ export function CodexSettingsTab() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const showUsageTracking = codexAuthStatus?.authenticated ?? false;
|
const showUsageTracking = codexAuthStatus?.authenticated ?? false;
|
||||||
|
const authStatusToDisplay = codexAuthStatus;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<CodexCliStatus
|
<CodexCliStatus
|
||||||
status={codexCliStatus}
|
status={codexCliStatus}
|
||||||
|
authStatus={authStatusToDisplay}
|
||||||
isChecking={isCheckingCodexCli}
|
isChecking={isCheckingCodexCli}
|
||||||
onRefresh={handleRefreshCodexCli}
|
onRefresh={handleRefreshCodexCli}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { getHttpApiClient, waitForApiKeyInit } from '@/lib/http-api-client';
|
|||||||
import { setItem } from '@/lib/storage';
|
import { setItem } from '@/lib/storage';
|
||||||
import { useAppStore, type ThemeMode, THEME_STORAGE_KEY } from '@/store/app-store';
|
import { useAppStore, type ThemeMode, THEME_STORAGE_KEY } from '@/store/app-store';
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
import { waitForMigrationComplete } from './use-settings-migration';
|
import { waitForMigrationComplete } from './use-settings-migration';
|
||||||
import type { GlobalSettings } from '@automaker/types';
|
import type { GlobalSettings } from '@automaker/types';
|
||||||
|
|
||||||
@@ -90,6 +91,9 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
syncing: false,
|
syncing: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
const authChecked = useAuthStore((s) => s.authChecked);
|
||||||
|
|
||||||
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const lastSyncedRef = useRef<string>('');
|
const lastSyncedRef = useRef<string>('');
|
||||||
const isInitializedRef = useRef(false);
|
const isInitializedRef = useRef(false);
|
||||||
@@ -160,6 +164,9 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
|
|
||||||
// Initialize sync - WAIT for migration to complete first
|
// Initialize sync - WAIT for migration to complete first
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Don't initialize syncing until we know auth status and are authenticated.
|
||||||
|
// Prevents accidental overwrites when the app boots before settings are hydrated.
|
||||||
|
if (!authChecked || !isAuthenticated) return;
|
||||||
if (isInitializedRef.current) return;
|
if (isInitializedRef.current) return;
|
||||||
isInitializedRef.current = true;
|
isInitializedRef.current = true;
|
||||||
|
|
||||||
@@ -204,7 +211,7 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initializeSync();
|
initializeSync();
|
||||||
}, []);
|
}, [authChecked, isAuthenticated]);
|
||||||
|
|
||||||
// Subscribe to store changes and sync to server
|
// Subscribe to store changes and sync to server
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -251,44 +251,67 @@ function RootLayoutContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
// 2. Check Settings if valid
|
// 2. Load settings (and hydrate stores) before marking auth as checked.
|
||||||
|
// This prevents useSettingsSync from pushing default/empty state to the server
|
||||||
|
// when the backend is still starting up or temporarily unavailable.
|
||||||
const api = getHttpApiClient();
|
const api = getHttpApiClient();
|
||||||
try {
|
try {
|
||||||
const settingsResult = await api.settings.getGlobal();
|
const maxAttempts = 8;
|
||||||
if (settingsResult.success && settingsResult.settings) {
|
const baseDelayMs = 250;
|
||||||
// Perform migration from localStorage if needed (first-time migration)
|
let lastError: unknown = null;
|
||||||
// This checks if localStorage has projects/data that server doesn't have
|
|
||||||
// and merges them before hydrating the store
|
|
||||||
const { settings: finalSettings, migrated } = await performSettingsMigration(
|
|
||||||
settingsResult.settings as unknown as Parameters<typeof performSettingsMigration>[0]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (migrated) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
logger.info('Settings migration from localStorage completed');
|
try {
|
||||||
|
const settingsResult = await api.settings.getGlobal();
|
||||||
|
if (settingsResult.success && settingsResult.settings) {
|
||||||
|
const { settings: finalSettings, migrated } = await performSettingsMigration(
|
||||||
|
settingsResult.settings as unknown as Parameters<
|
||||||
|
typeof performSettingsMigration
|
||||||
|
>[0]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (migrated) {
|
||||||
|
logger.info('Settings migration from localStorage completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hydrate store with the final settings (merged if migration occurred)
|
||||||
|
hydrateStoreFromSettings(finalSettings);
|
||||||
|
|
||||||
|
// Signal that settings hydration is complete so useSettingsSync can start
|
||||||
|
signalMigrationComplete();
|
||||||
|
|
||||||
|
// Mark auth as checked only after settings hydration succeeded.
|
||||||
|
useAuthStore
|
||||||
|
.getState()
|
||||||
|
.setAuthState({ isAuthenticated: true, authChecked: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastError = settingsResult;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hydrate store with the final settings (merged if migration occurred)
|
const delayMs = Math.min(1500, baseDelayMs * attempt);
|
||||||
hydrateStoreFromSettings(finalSettings);
|
logger.warn(
|
||||||
|
`Settings not ready (attempt ${attempt}/${maxAttempts}); retrying in ${delayMs}ms...`,
|
||||||
// Signal that settings hydration is complete so useSettingsSync can start
|
lastError
|
||||||
signalMigrationComplete();
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||||
// Redirect based on setup status happens in the routing effect below
|
|
||||||
// but we can also hint navigation here if needed.
|
|
||||||
// The routing effect (lines 273+) is robust enough.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw lastError ?? new Error('Failed to load settings');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to fetch settings after valid session:', error);
|
logger.error('Failed to fetch settings after valid session:', error);
|
||||||
// If settings fail, we might still be authenticated but can't determine setup status.
|
// If we can't load settings, we must NOT start syncing defaults to the server.
|
||||||
// We should probably treat as authenticated but setup unknown?
|
// Treat as not authenticated for now (backend likely unavailable) and unblock sync hook.
|
||||||
// Or fail safe to logged-out/error?
|
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||||
// Existing logic relies on setupComplete which defaults to false/true based on env.
|
|
||||||
// Let's assume we proceed as authenticated.
|
|
||||||
// Still signal migration complete so sync can start (will sync current store state)
|
|
||||||
signalMigrationComplete();
|
signalMigrationComplete();
|
||||||
|
if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
|
||||||
|
navigate({ to: '/logged-out' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
useAuthStore.getState().setAuthState({ isAuthenticated: true, authChecked: true });
|
|
||||||
} else {
|
} else {
|
||||||
// Session is invalid or expired - treat as not authenticated
|
// Session is invalid or expired - treat as not authenticated
|
||||||
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||||
|
|||||||
107
apps/ui/tests/settings/settings-startup-sync-race.spec.ts
Normal file
107
apps/ui/tests/settings/settings-startup-sync-race.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Settings Startup Race Regression Test
|
||||||
|
*
|
||||||
|
* Repro (historical bug):
|
||||||
|
* - UI verifies session successfully
|
||||||
|
* - Initial GET /api/settings/global fails transiently (backend still starting)
|
||||||
|
* - UI unblocks settings sync anyway and can push default empty state to server
|
||||||
|
* - Server persists projects: [] (and other defaults), wiping settings.json
|
||||||
|
*
|
||||||
|
* This test forces the first few /api/settings/global requests to fail and asserts that
|
||||||
|
* the server-side settings.json is NOT overwritten while the UI is waiting to hydrate.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { authenticateForTests } from '../utils';
|
||||||
|
|
||||||
|
const SETTINGS_PATH = path.resolve(process.cwd(), '../server/data/settings.json');
|
||||||
|
const WORKSPACE_ROOT = path.resolve(process.cwd(), '../..');
|
||||||
|
const FIXTURE_PROJECT_PATH = path.join(WORKSPACE_ROOT, 'test/fixtures/projectA');
|
||||||
|
|
||||||
|
test.describe('Settings startup sync race', () => {
|
||||||
|
let originalSettingsJson: string;
|
||||||
|
|
||||||
|
test.beforeAll(() => {
|
||||||
|
originalSettingsJson = fs.readFileSync(SETTINGS_PATH, 'utf-8');
|
||||||
|
|
||||||
|
const settings = JSON.parse(originalSettingsJson) as Record<string, unknown>;
|
||||||
|
settings.projects = [
|
||||||
|
{
|
||||||
|
id: `e2e-project-${Date.now()}`,
|
||||||
|
name: 'E2E Project (settings race)',
|
||||||
|
path: FIXTURE_PROJECT_PATH,
|
||||||
|
lastOpened: new Date().toISOString(),
|
||||||
|
theme: 'dark',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(() => {
|
||||||
|
// Restore original settings.json to avoid polluting other tests/dev state
|
||||||
|
fs.writeFileSync(SETTINGS_PATH, originalSettingsJson);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not overwrite projects when /api/settings/global is temporarily unavailable', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Gate the real settings request so we can assert file contents before allowing hydration.
|
||||||
|
let requestCount = 0;
|
||||||
|
let allowSettingsRequestResolve: (() => void) | null = null;
|
||||||
|
const allowSettingsRequest = new Promise<void>((resolve) => {
|
||||||
|
allowSettingsRequestResolve = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
let sawThreeFailuresResolve: (() => void) | null = null;
|
||||||
|
const sawThreeFailures = new Promise<void>((resolve) => {
|
||||||
|
sawThreeFailuresResolve = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route('**/api/settings/global', async (route) => {
|
||||||
|
requestCount++;
|
||||||
|
if (requestCount <= 3) {
|
||||||
|
if (requestCount === 3) {
|
||||||
|
sawThreeFailuresResolve?.();
|
||||||
|
}
|
||||||
|
await route.abort('failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Keep the 4th+ request pending until the test explicitly allows it.
|
||||||
|
await allowSettingsRequest;
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure we are authenticated (session cookie) before loading the app.
|
||||||
|
await authenticateForTests(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Wait until we have forced a few failures.
|
||||||
|
await sawThreeFailures;
|
||||||
|
|
||||||
|
// At this point, the UI should NOT have written defaults back to the server.
|
||||||
|
const settingsAfterFailures = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8')) as {
|
||||||
|
projects?: Array<{ path?: string }>;
|
||||||
|
};
|
||||||
|
expect(settingsAfterFailures.projects?.length).toBeGreaterThan(0);
|
||||||
|
expect(settingsAfterFailures.projects?.[0]?.path).toBe(FIXTURE_PROJECT_PATH);
|
||||||
|
|
||||||
|
// Allow the settings request to succeed so the app can hydrate and proceed.
|
||||||
|
allowSettingsRequestResolve?.();
|
||||||
|
|
||||||
|
// App should eventually render a main view after settings hydration.
|
||||||
|
await page
|
||||||
|
.locator('[data-testid="welcome-view"], [data-testid="board-view"]')
|
||||||
|
.first()
|
||||||
|
.waitFor({ state: 'visible', timeout: 30000 });
|
||||||
|
|
||||||
|
// Verify settings.json still contains the project after hydration completes.
|
||||||
|
const settingsAfterHydration = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8')) as {
|
||||||
|
projects?: Array<{ path?: string }>;
|
||||||
|
};
|
||||||
|
expect(settingsAfterHydration.projects?.length).toBeGreaterThan(0);
|
||||||
|
expect(settingsAfterHydration.projects?.[0]?.path).toBe(FIXTURE_PROJECT_PATH);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user