mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 18:18:43 +00:00
Merge pull request #528 from kaitranntt/kai/feat/521-cursor-config-dashboard
feat(cursor): integrate cursor provider into config, dashboard, and reserved names
This commit is contained in:
@@ -220,6 +220,25 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
]
|
||||
);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// MAJOR SECTION 5: Cursor IDE Integration
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
printMajorSection(
|
||||
'Cursor IDE Integration',
|
||||
[
|
||||
'Use Cursor IDE with Claude Code via cursor proxy daemon',
|
||||
'Auto-detects token from Cursor installation',
|
||||
],
|
||||
[
|
||||
['ccs cursor <cmd>', 'Use Cursor IDE integration'],
|
||||
['ccs cursor auth', 'Import Cursor token'],
|
||||
['ccs cursor status', 'Show connection status'],
|
||||
['ccs cursor models', 'List available models'],
|
||||
['ccs cursor start', 'Start proxy daemon'],
|
||||
['ccs cursor stop', 'Stop proxy daemon'],
|
||||
]
|
||||
);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// SUB-SECTIONS (simpler styling)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -11,6 +11,8 @@ export const RESERVED_PROFILE_NAMES = [
|
||||
'iflow',
|
||||
// Copilot API (GitHub Copilot proxy)
|
||||
'copilot',
|
||||
// Cursor IDE (Cursor proxy daemon)
|
||||
'cursor',
|
||||
// CLI commands and special names
|
||||
'default',
|
||||
'config',
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
createEmptyUnifiedConfig,
|
||||
UNIFIED_CONFIG_VERSION,
|
||||
DEFAULT_COPILOT_CONFIG,
|
||||
DEFAULT_CURSOR_CONFIG,
|
||||
DEFAULT_GLOBAL_ENV,
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG,
|
||||
DEFAULT_QUOTA_MANAGEMENT_CONFIG,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
ThinkingConfig,
|
||||
DashboardAuthConfig,
|
||||
ImageAnalysisConfig,
|
||||
CursorConfig,
|
||||
} from './unified-config-types';
|
||||
import { isUnifiedConfigEnabled } from './feature-flags';
|
||||
|
||||
@@ -276,6 +278,13 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
||||
wait_on_limit: partial.copilot?.wait_on_limit ?? DEFAULT_COPILOT_CONFIG.wait_on_limit,
|
||||
model: partial.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model,
|
||||
},
|
||||
// Cursor config - disabled by default, merge with defaults
|
||||
cursor: {
|
||||
enabled: partial.cursor?.enabled ?? DEFAULT_CURSOR_CONFIG.enabled,
|
||||
port: partial.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port,
|
||||
auto_start: partial.cursor?.auto_start ?? DEFAULT_CURSOR_CONFIG.auto_start,
|
||||
ghost_mode: partial.cursor?.ghost_mode ?? DEFAULT_CURSOR_CONFIG.ghost_mode,
|
||||
},
|
||||
// Global env - injected into all non-Claude subscription profiles
|
||||
global_env: {
|
||||
enabled: partial.global_env?.enabled ?? true,
|
||||
@@ -559,6 +568,23 @@ function generateYamlWithComments(config: UnifiedConfig): string {
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Cursor section (Cursor IDE proxy daemon)
|
||||
if (config.cursor) {
|
||||
lines.push('# ----------------------------------------------------------------------------');
|
||||
lines.push('# Cursor: Cursor IDE proxy daemon');
|
||||
lines.push('# Enables Cursor IDE integration via local proxy daemon.');
|
||||
lines.push('#');
|
||||
lines.push('# enabled: Enable/disable Cursor integration (default: false)');
|
||||
lines.push('# port: Port for cursor proxy daemon (default: 20129)');
|
||||
lines.push('# auto_start: Auto-start daemon when CCS starts (default: false)');
|
||||
lines.push('# ghost_mode: Disable telemetry for privacy (default: true)');
|
||||
lines.push('# ----------------------------------------------------------------------------');
|
||||
lines.push(
|
||||
yaml.dump({ cursor: config.cursor }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
|
||||
);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Global env section
|
||||
if (config.global_env) {
|
||||
lines.push('# ----------------------------------------------------------------------------');
|
||||
@@ -900,3 +926,12 @@ export function getImageAnalysisConfig(): ImageAnalysisConfig {
|
||||
config.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cursor configuration.
|
||||
* Returns defaults if not configured.
|
||||
*/
|
||||
export function getCursorConfig(): CursorConfig {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
return config.cursor ?? { ...DEFAULT_CURSOR_CONFIG };
|
||||
}
|
||||
|
||||
@@ -231,6 +231,21 @@ export interface CopilotConfig {
|
||||
haiku_model?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor IDE integration configuration.
|
||||
* Enables Cursor IDE usage via cursor proxy daemon.
|
||||
*/
|
||||
export interface CursorConfig {
|
||||
/** Enable Cursor integration (default: false) */
|
||||
enabled: boolean;
|
||||
/** Port for cursor proxy daemon (default: 20129) */
|
||||
port: number;
|
||||
/** Auto-start daemon when CCS starts (default: false) */
|
||||
auto_start: boolean;
|
||||
/** Enable ghost mode to disable telemetry (default: true) */
|
||||
ghost_mode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote proxy configuration.
|
||||
* Connect to a remote CLIProxyAPI instance instead of spawning local binary.
|
||||
@@ -610,6 +625,8 @@ export interface UnifiedConfig {
|
||||
global_env?: GlobalEnvConfig;
|
||||
/** Copilot API configuration (GitHub Copilot proxy) */
|
||||
copilot?: CopilotConfig;
|
||||
/** Cursor IDE configuration (Cursor proxy daemon) */
|
||||
cursor?: CursorConfig;
|
||||
/** CLIProxy server configuration for remote/local mode */
|
||||
cliproxy_server?: CliproxyServerConfig;
|
||||
/** Quota management configuration (v7+) */
|
||||
@@ -637,6 +654,17 @@ export const DEFAULT_COPILOT_CONFIG: CopilotConfig = {
|
||||
model: 'gpt-4.1', // Free tier compatible
|
||||
};
|
||||
|
||||
/**
|
||||
* Default Cursor configuration.
|
||||
* Disabled by default, ghost mode enabled for privacy.
|
||||
*/
|
||||
export const DEFAULT_CURSOR_CONFIG: CursorConfig = {
|
||||
enabled: false,
|
||||
port: 20129,
|
||||
auto_start: false,
|
||||
ghost_mode: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default CLIProxy server configuration.
|
||||
* Local mode by default - remote must be explicitly enabled.
|
||||
@@ -709,6 +737,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
|
||||
env: { ...DEFAULT_GLOBAL_ENV },
|
||||
},
|
||||
copilot: { ...DEFAULT_COPILOT_CONFIG },
|
||||
cursor: { ...DEFAULT_CURSOR_CONFIG },
|
||||
cliproxy_server: { ...DEFAULT_CLIPROXY_SERVER_CONFIG },
|
||||
quota_management: { ...DEFAULT_QUOTA_MANAGEMENT_CONFIG },
|
||||
thinking: { ...DEFAULT_THINKING_CONFIG },
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Cursor Routes - Cursor IDE integration via cursor proxy daemon
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
checkAuthStatus,
|
||||
autoDetectTokens,
|
||||
saveCredentials,
|
||||
validateToken,
|
||||
} from '../../cursor/cursor-auth';
|
||||
import { getCursorConfig } from '../../config/unified-config-loader';
|
||||
import cursorSettingsRoutes from './cursor-settings-routes';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Mount settings sub-routes
|
||||
router.use('/settings', cursorSettingsRoutes);
|
||||
|
||||
/**
|
||||
* Get daemon status
|
||||
* TODO: Implement in cursor-executor.ts (#520)
|
||||
*/
|
||||
async function getDaemonStatus(port: number): Promise<{ running: boolean; port?: number }> {
|
||||
// Stub - will be implemented in #520
|
||||
return { running: false, port };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available models
|
||||
* TODO: Implement in cursor-executor.ts (#520)
|
||||
*/
|
||||
async function getAvailableModels(): Promise<string[]> {
|
||||
// Stub - will be implemented in #520
|
||||
return []; // TODO: populated by cursor-models.ts (#520)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start daemon
|
||||
* TODO: Implement in cursor-executor.ts (#520)
|
||||
*/
|
||||
async function startDaemon(
|
||||
port: number,
|
||||
ghostMode: boolean
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
// Stub - will be implemented in #520
|
||||
return {
|
||||
success: false,
|
||||
message: `Daemon start not implemented (port: ${port}, ghost: ${ghostMode})`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop daemon
|
||||
* TODO: Implement in cursor-executor.ts (#520)
|
||||
*/
|
||||
async function stopDaemon(): Promise<{ success: boolean; message: string }> {
|
||||
// Stub - will be implemented in #520
|
||||
return { success: false, message: 'Daemon stop not implemented' };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/cursor/status - Get Cursor status (auth + daemon)
|
||||
*/
|
||||
router.get('/status', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const cursorConfig = getCursorConfig();
|
||||
const authStatus = checkAuthStatus();
|
||||
const daemonStatus = await getDaemonStatus(cursorConfig.port);
|
||||
|
||||
res.json({
|
||||
enabled: cursorConfig.enabled,
|
||||
authenticated: authStatus.authenticated,
|
||||
daemon_running: daemonStatus.running,
|
||||
port: cursorConfig.port,
|
||||
auto_start: cursorConfig.auto_start,
|
||||
ghost_mode: cursorConfig.ghost_mode,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cursor/auth/import - Import Cursor token manually
|
||||
*/
|
||||
router.post('/auth/import', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { accessToken, machineId } = req.body;
|
||||
if (!accessToken || !machineId) {
|
||||
res.status(400).json({ error: 'Missing accessToken or machineId' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate token format
|
||||
if (!validateToken(accessToken, machineId)) {
|
||||
res.status(400).json({ error: 'Invalid token or machine ID format' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Save credentials
|
||||
saveCredentials({
|
||||
accessToken,
|
||||
machineId,
|
||||
authMethod: 'manual',
|
||||
importedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Token imported successfully' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cursor/auth/auto-detect - Auto-detect token from SQLite
|
||||
*/
|
||||
router.post('/auth/auto-detect', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const result = autoDetectTokens();
|
||||
|
||||
if (!result.found || !result.accessToken || !result.machineId) {
|
||||
res.status(404).json({ error: result.error ?? 'Token not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Save credentials
|
||||
saveCredentials({
|
||||
accessToken: result.accessToken,
|
||||
machineId: result.machineId,
|
||||
authMethod: 'auto-detect',
|
||||
importedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Token auto-detected and imported' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/cursor/models - List available models
|
||||
*/
|
||||
router.get('/models', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const models = await getAvailableModels();
|
||||
res.json({ models });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cursor/daemon/start - Start cursor proxy daemon
|
||||
* Path matches copilot convention: /api/{provider}/daemon/{action}
|
||||
*/
|
||||
router.post('/daemon/start', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const cursorConfig = getCursorConfig();
|
||||
const result = await startDaemon(cursorConfig.port, cursorConfig.ghost_mode);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cursor/daemon/stop - Stop cursor proxy daemon
|
||||
* Path matches copilot convention: /api/{provider}/daemon/{action}
|
||||
*/
|
||||
router.post('/daemon/stop', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const result = await stopDaemon();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Cursor Settings Routes - Settings editor and raw settings for Cursor IDE
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { Router } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from '../../utils/config-manager';
|
||||
import { DEFAULT_CURSOR_CONFIG } from '../../config/unified-config-types';
|
||||
import {
|
||||
loadOrCreateUnifiedConfig,
|
||||
saveUnifiedConfig,
|
||||
getCursorConfig,
|
||||
} from '../../config/unified-config-loader';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/cursor/settings - Get cursor config (port, auto_start, ghost_mode)
|
||||
*/
|
||||
router.get('/', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const cursorConfig = getCursorConfig();
|
||||
res.json(cursorConfig);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/cursor/settings - Update cursor config
|
||||
*/
|
||||
router.put('/', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const updates = req.body;
|
||||
|
||||
// Reject non-object bodies
|
||||
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
|
||||
res.status(400).json({ error: 'Request body must be a JSON object' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate input types
|
||||
if ('port' in updates) {
|
||||
if (typeof updates.port !== 'number' || !Number.isInteger(updates.port)) {
|
||||
res.status(400).json({ error: 'port must be an integer' });
|
||||
return;
|
||||
}
|
||||
if (updates.port < 1 || updates.port > 65535) {
|
||||
res.status(400).json({ error: 'port must be between 1 and 65535' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ('enabled' in updates && typeof updates.enabled !== 'boolean') {
|
||||
res.status(400).json({ error: 'enabled must be a boolean' });
|
||||
return;
|
||||
}
|
||||
if ('auto_start' in updates && typeof updates.auto_start !== 'boolean') {
|
||||
res.status(400).json({ error: 'auto_start must be a boolean' });
|
||||
return;
|
||||
}
|
||||
if ('ghost_mode' in updates && typeof updates.ghost_mode !== 'boolean') {
|
||||
res.status(400).json({ error: 'ghost_mode must be a boolean' });
|
||||
return;
|
||||
}
|
||||
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
|
||||
// Merge updates with existing config
|
||||
// Only known fields are merged — unknown properties are ignored
|
||||
config.cursor = {
|
||||
enabled: updates.enabled ?? config.cursor?.enabled ?? DEFAULT_CURSOR_CONFIG.enabled,
|
||||
port: updates.port ?? config.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port,
|
||||
auto_start:
|
||||
updates.auto_start ?? config.cursor?.auto_start ?? DEFAULT_CURSOR_CONFIG.auto_start,
|
||||
ghost_mode:
|
||||
updates.ghost_mode ?? config.cursor?.ghost_mode ?? DEFAULT_CURSOR_CONFIG.ghost_mode,
|
||||
};
|
||||
|
||||
saveUnifiedConfig(config);
|
||||
res.json({ success: true, cursor: config.cursor });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/cursor/settings/raw - Get raw cursor.settings.json
|
||||
* Returns the raw JSON content for editing in the code editor
|
||||
*/
|
||||
router.get('/raw', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const settingsPath = path.join(getCcsDir(), 'cursor.settings.json');
|
||||
const cursorConfig = getCursorConfig();
|
||||
|
||||
// If file doesn't exist, return default structure
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
// Create settings structure matching Cursor pattern
|
||||
// Use 127.0.0.1 instead of localhost for more reliable local connections
|
||||
const defaultSettings = {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${cursorConfig.port}`,
|
||||
ANTHROPIC_AUTH_TOKEN: 'cursor-managed',
|
||||
},
|
||||
};
|
||||
|
||||
res.json({
|
||||
settings: defaultSettings,
|
||||
mtime: Date.now(),
|
||||
path: settingsPath,
|
||||
exists: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(settingsPath, 'utf-8');
|
||||
const settings = JSON.parse(content);
|
||||
const stat = fs.statSync(settingsPath);
|
||||
|
||||
res.json({
|
||||
settings,
|
||||
mtime: stat.mtimeMs,
|
||||
path: settingsPath,
|
||||
exists: true,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/cursor/settings/raw - Save raw cursor.settings.json
|
||||
* Saves the raw JSON content from the code editor
|
||||
*/
|
||||
router.put('/raw', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { settings, expectedMtime } = req.body;
|
||||
|
||||
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
|
||||
res.status(400).json({ error: 'settings must be a JSON object' });
|
||||
return;
|
||||
}
|
||||
|
||||
const settingsPath = path.join(getCcsDir(), 'cursor.settings.json');
|
||||
|
||||
// Check for conflict if file exists and expectedMtime provided
|
||||
if (fs.existsSync(settingsPath) && expectedMtime) {
|
||||
const stat = fs.statSync(settingsPath);
|
||||
if (Math.abs(stat.mtimeMs - expectedMtime) > 1000) {
|
||||
res.status(409).json({ error: 'File modified externally', mtime: stat.mtimeMs });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Write settings file atomically
|
||||
const tempPath = settingsPath + '.tmp';
|
||||
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
fs.renameSync(tempPath, settingsPath);
|
||||
|
||||
// TODO: Sync raw settings back to unified config when cursor-daemon is integrated (#520)
|
||||
|
||||
const stat = fs.statSync(settingsPath);
|
||||
res.json({ success: true, mtime: stat.mtimeMs });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -20,6 +20,7 @@ import cliproxyAuthRoutes from './cliproxy-auth-routes';
|
||||
import cliproxyStatsRoutes from './cliproxy-stats-routes';
|
||||
import cliproxySyncRoutes from './cliproxy-sync-routes';
|
||||
import copilotRoutes from './copilot-routes';
|
||||
import cursorRoutes from './cursor-routes';
|
||||
import miscRoutes from './misc-routes';
|
||||
import cliproxyServerRoutes from './proxy-routes';
|
||||
import authRoutes from './auth-routes';
|
||||
@@ -63,6 +64,9 @@ apiRoutes.use('/websearch', websearchRoutes);
|
||||
// ==================== Copilot ====================
|
||||
apiRoutes.use('/copilot', copilotRoutes);
|
||||
|
||||
// ==================== Cursor ====================
|
||||
apiRoutes.use('/cursor', cursorRoutes);
|
||||
|
||||
// ==================== CLIProxy Server Settings ====================
|
||||
apiRoutes.use('/cliproxy-server', cliproxyServerRoutes);
|
||||
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Cursor Settings Routes Tests
|
||||
* Tests for Cursor configuration API endpoints.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
// Setup test environment BEFORE any imports
|
||||
const TEST_CCS_DIR = path.join(os.tmpdir(), `ccs-test-cursor-settings-${Date.now()}`);
|
||||
process.env.CCS_HOME = TEST_CCS_DIR;
|
||||
|
||||
// Import after setting env var
|
||||
import type { CursorConfig } from '../../../src/config/unified-config-types';
|
||||
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||
import { getCcsDir } from '../../../src/utils/config-manager';
|
||||
|
||||
describe('Cursor Settings Routes Logic', () => {
|
||||
beforeEach(() => {
|
||||
// Ensure test directory exists
|
||||
const ccsDir = getCcsDir();
|
||||
if (!fs.existsSync(ccsDir)) {
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up test directory
|
||||
if (fs.existsSync(TEST_CCS_DIR)) {
|
||||
fs.rmSync(TEST_CCS_DIR, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('PUT /settings validation logic', () => {
|
||||
it('validates null body', () => {
|
||||
const updates = null;
|
||||
const isValid = !!(updates && typeof updates === 'object' && !Array.isArray(updates));
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('validates non-object body', () => {
|
||||
const updates = 'string';
|
||||
const isValid = !!(updates && typeof updates === 'object' && !Array.isArray(updates));
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('validates array body', () => {
|
||||
const updates = [1, 2, 3];
|
||||
const isValid = !!(updates && typeof updates === 'object' && !Array.isArray(updates));
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('validates valid object', () => {
|
||||
const updates = { port: 4000 };
|
||||
const isValid = !!(updates && typeof updates === 'object' && !Array.isArray(updates));
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('validates integer port', () => {
|
||||
const port = 4000;
|
||||
const isInteger = typeof port === 'number' && Number.isInteger(port);
|
||||
expect(isInteger).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-integer port', () => {
|
||||
const port = 3.14;
|
||||
const isInteger = typeof port === 'number' && Number.isInteger(port);
|
||||
expect(isInteger).toBe(false);
|
||||
});
|
||||
|
||||
it('validates port range (valid)', () => {
|
||||
const port = 3000;
|
||||
const inRange = port >= 1 && port <= 65535;
|
||||
expect(inRange).toBe(true);
|
||||
});
|
||||
|
||||
it('validates port range (below)', () => {
|
||||
const port = 0;
|
||||
const inRange = port >= 1 && port <= 65535;
|
||||
expect(inRange).toBe(false);
|
||||
});
|
||||
|
||||
it('validates port range (above)', () => {
|
||||
const port = 65536;
|
||||
const inRange = port >= 1 && port <= 65535;
|
||||
expect(inRange).toBe(false);
|
||||
});
|
||||
|
||||
it('validates boolean auto_start', () => {
|
||||
const auto_start = true;
|
||||
const isBoolean = typeof auto_start === 'boolean';
|
||||
expect(isBoolean).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-boolean auto_start', () => {
|
||||
const auto_start = 'yes';
|
||||
const isBoolean = typeof auto_start === 'boolean';
|
||||
expect(isBoolean).toBe(false);
|
||||
});
|
||||
|
||||
it('validates boolean ghost_mode', () => {
|
||||
const ghost_mode = false;
|
||||
const isBoolean = typeof ghost_mode === 'boolean';
|
||||
expect(isBoolean).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-boolean ghost_mode', () => {
|
||||
const ghost_mode = 1;
|
||||
const isBoolean = typeof ghost_mode === 'boolean';
|
||||
expect(isBoolean).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /settings whitelist merge pattern', () => {
|
||||
it('merges known fields only (ignores unknown)', () => {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const updates = {
|
||||
port: 5000,
|
||||
malicious_key: 'should be ignored',
|
||||
another_unknown: true,
|
||||
};
|
||||
|
||||
// Simulate the whitelist merge from the route
|
||||
const cursorConfig: CursorConfig = {
|
||||
enabled: updates.enabled ?? config.cursor?.enabled ?? false,
|
||||
port: updates.port ?? config.cursor?.port ?? 3000,
|
||||
auto_start: updates.auto_start ?? config.cursor?.auto_start ?? false,
|
||||
ghost_mode: updates.ghost_mode ?? config.cursor?.ghost_mode ?? false,
|
||||
};
|
||||
|
||||
expect(cursorConfig.port).toBe(5000);
|
||||
expect(cursorConfig).not.toHaveProperty('malicious_key');
|
||||
expect(cursorConfig).not.toHaveProperty('another_unknown');
|
||||
});
|
||||
|
||||
it('updates port only', () => {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
config.cursor = { enabled: false, port: 3000, auto_start: false, ghost_mode: false };
|
||||
saveUnifiedConfig(config);
|
||||
|
||||
const updates = { port: 4000 };
|
||||
const cursorConfig: CursorConfig = {
|
||||
enabled: updates.enabled ?? config.cursor?.enabled ?? false,
|
||||
port: updates.port ?? config.cursor?.port ?? 3000,
|
||||
auto_start: updates.auto_start ?? config.cursor?.auto_start ?? false,
|
||||
ghost_mode: updates.ghost_mode ?? config.cursor?.ghost_mode ?? false,
|
||||
};
|
||||
|
||||
expect(cursorConfig.port).toBe(4000);
|
||||
expect(cursorConfig.auto_start).toBe(false);
|
||||
expect(cursorConfig.ghost_mode).toBe(false);
|
||||
});
|
||||
|
||||
it('updates auto_start only', () => {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
config.cursor = { enabled: false, port: 3000, auto_start: false, ghost_mode: false };
|
||||
saveUnifiedConfig(config);
|
||||
|
||||
const updates = { auto_start: true };
|
||||
const cursorConfig: CursorConfig = {
|
||||
enabled: updates.enabled ?? config.cursor?.enabled ?? false,
|
||||
port: updates.port ?? config.cursor?.port ?? 3000,
|
||||
auto_start: updates.auto_start ?? config.cursor?.auto_start ?? false,
|
||||
ghost_mode: updates.ghost_mode ?? config.cursor?.ghost_mode ?? false,
|
||||
};
|
||||
|
||||
expect(cursorConfig.port).toBe(3000);
|
||||
expect(cursorConfig.auto_start).toBe(true);
|
||||
expect(cursorConfig.ghost_mode).toBe(false);
|
||||
});
|
||||
|
||||
it('updates ghost_mode only', () => {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
config.cursor = { enabled: false, port: 3000, auto_start: false, ghost_mode: false };
|
||||
saveUnifiedConfig(config);
|
||||
|
||||
const updates = { ghost_mode: true };
|
||||
const cursorConfig: CursorConfig = {
|
||||
enabled: updates.enabled ?? config.cursor?.enabled ?? false,
|
||||
port: updates.port ?? config.cursor?.port ?? 3000,
|
||||
auto_start: updates.auto_start ?? config.cursor?.auto_start ?? false,
|
||||
ghost_mode: updates.ghost_mode ?? config.cursor?.ghost_mode ?? false,
|
||||
};
|
||||
|
||||
expect(cursorConfig.port).toBe(3000);
|
||||
expect(cursorConfig.auto_start).toBe(false);
|
||||
expect(cursorConfig.ghost_mode).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /settings/raw logic', () => {
|
||||
it('returns defaults when file does not exist', () => {
|
||||
const settingsPath = path.join(getCcsDir(), 'cursor.settings.json');
|
||||
const exists = fs.existsSync(settingsPath);
|
||||
|
||||
expect(exists).toBe(false);
|
||||
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const cursorPort = config.cursor?.port ?? 3000;
|
||||
const defaultSettings = {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${cursorPort}`,
|
||||
ANTHROPIC_AUTH_TOKEN: 'cursor-managed',
|
||||
},
|
||||
};
|
||||
|
||||
expect(defaultSettings.env.ANTHROPIC_BASE_URL).toContain('http://127.0.0.1:');
|
||||
expect(defaultSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('cursor-managed');
|
||||
});
|
||||
|
||||
it('reads existing file', () => {
|
||||
const settingsPath = path.join(getCcsDir(), 'cursor.settings.json');
|
||||
const testSettings = {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:4000',
|
||||
ANTHROPIC_AUTH_TOKEN: 'test-token',
|
||||
},
|
||||
};
|
||||
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(testSettings, null, 2));
|
||||
const exists = fs.existsSync(settingsPath);
|
||||
|
||||
expect(exists).toBe(true);
|
||||
|
||||
const content = fs.readFileSync(settingsPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
expect(parsed).toEqual(testSettings);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /settings/raw validation logic', () => {
|
||||
it('validates missing settings field', () => {
|
||||
const body: { expectedMtime: number; settings?: unknown } = { expectedMtime: Date.now() };
|
||||
const isValid = !!(body.settings && typeof body.settings === 'object');
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('validates non-object settings', () => {
|
||||
const body = { settings: 'not an object' };
|
||||
const isValid = !!(body.settings && typeof body.settings === 'object');
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('validates valid settings', () => {
|
||||
const body = { settings: { env: { test: 'value' } } };
|
||||
const isValid = !!(body.settings && typeof body.settings === 'object');
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('writes settings file atomically', () => {
|
||||
const settingsPath = path.join(getCcsDir(), 'cursor.settings.json');
|
||||
const testSettings = {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:5000',
|
||||
ANTHROPIC_AUTH_TOKEN: 'new-token',
|
||||
},
|
||||
};
|
||||
|
||||
// Simulate atomic write
|
||||
const tempPath = settingsPath + '.tmp';
|
||||
fs.writeFileSync(tempPath, JSON.stringify(testSettings, null, 2) + '\n');
|
||||
fs.renameSync(tempPath, settingsPath);
|
||||
|
||||
const exists = fs.existsSync(settingsPath);
|
||||
expect(exists).toBe(true);
|
||||
|
||||
const written = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||
expect(written).toEqual(testSettings);
|
||||
});
|
||||
|
||||
it('detects mtime conflict', () => {
|
||||
const settingsPath = path.join(getCcsDir(), 'cursor.settings.json');
|
||||
const initialSettings = { env: { test: 'initial' } };
|
||||
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(initialSettings));
|
||||
const stat = fs.statSync(settingsPath);
|
||||
|
||||
const expectedMtime = stat.mtimeMs - 5000; // 5 seconds in the past
|
||||
const hasConflict = Math.abs(stat.mtimeMs - expectedMtime) > 1000;
|
||||
|
||||
expect(hasConflict).toBe(true);
|
||||
});
|
||||
|
||||
it('allows write when mtime matches', () => {
|
||||
const settingsPath = path.join(getCcsDir(), 'cursor.settings.json');
|
||||
const initialSettings = { env: { test: 'initial' } };
|
||||
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(initialSettings));
|
||||
const stat = fs.statSync(settingsPath);
|
||||
|
||||
const expectedMtime = stat.mtimeMs;
|
||||
const hasConflict = Math.abs(stat.mtimeMs - expectedMtime) > 1000;
|
||||
|
||||
expect(hasConflict).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user