mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-05 12:25:35 +08:00
Merge branch 'main' into perf/parallel-file-tree
This commit is contained in:
@@ -24,9 +24,9 @@ import {
|
||||
notifyRunStopped,
|
||||
notifyUserIfEnabled
|
||||
} from './services/notification-orchestrator.js';
|
||||
import { claudeAdapter } from './providers/claude/adapter.js';
|
||||
import { createNormalizedMessage } from './providers/types.js';
|
||||
import { getStatusChecker } from './providers/registry.js';
|
||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||
import { createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
const activeSessions = new Map();
|
||||
const pendingToolApprovals = new Map();
|
||||
@@ -149,9 +149,15 @@ function mapCliOptionsToSDK(options = {}) {
|
||||
|
||||
const sdkOptions = {};
|
||||
|
||||
if (process.env.CLAUDE_CLI_PATH) {
|
||||
sdkOptions.pathToClaudeCodeExecutable = process.env.CLAUDE_CLI_PATH;
|
||||
}
|
||||
// Forward all host env vars (e.g. ANTHROPIC_BASE_URL) to the subprocess.
|
||||
// Since SDK 0.2.113, options.env replaces process.env instead of overlaying it.
|
||||
sdkOptions.env = { ...process.env };
|
||||
|
||||
// Use CLAUDE_CLI_PATH if explicitly set, otherwise fall back to 'claude' on PATH.
|
||||
// The SDK 0.2.113+ looks for a bundled native binary optional dep by default;
|
||||
// this fallback ensures users who installed via the official installer still work
|
||||
// even when npm prune --production has removed those optional deps.
|
||||
sdkOptions.pathToClaudeCodeExecutable = process.env.CLAUDE_CLI_PATH || 'claude';
|
||||
|
||||
// Map working directory
|
||||
if (cwd) {
|
||||
@@ -521,6 +527,12 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
}]
|
||||
};
|
||||
|
||||
// Caveat: in 'auto' and 'bypassPermissions' modes the SDK resolves approval
|
||||
// at the permission-mode step and skips this callback, so interactive tools
|
||||
// (AskUserQuestion, ExitPlanMode) won't reach the UI — the classifier/bypass
|
||||
// auto-approves them and the model acts on a generated answer. Move these
|
||||
// tools to a PreToolUse hook (runs before the mode check) if we need them
|
||||
// to work in those modes.
|
||||
sdkOptions.canUseTool = async (toolName, input, context) => {
|
||||
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
|
||||
|
||||
@@ -654,7 +666,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
const sid = capturedSessionId || sessionId || null;
|
||||
|
||||
// Use adapter to normalize SDK events into NormalizedMessage[]
|
||||
const normalized = claudeAdapter.normalizeMessage(transformedMessage, sid);
|
||||
const normalized = sessionsService.normalizeMessage('claude', transformedMessage, sid);
|
||||
for (const msg of normalized) {
|
||||
// Preserve parentToolUseId from SDK wrapper for subagent tool grouping
|
||||
if (transformedMessage.parentToolUseId && !msg.parentToolUseId) {
|
||||
@@ -707,7 +719,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
await cleanupTempFiles(tempImagePaths, tempDir);
|
||||
|
||||
// Check if Claude CLI is installed for a clearer error message
|
||||
const installed = getStatusChecker('claude')?.checkInstalled() ?? true;
|
||||
const installed = await providerAuthService.isProviderInstalled('claude');
|
||||
const errorContent = !installed
|
||||
? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
|
||||
: error.message;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { spawn } from 'child_process';
|
||||
import crossSpawn from 'cross-spawn';
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||
import { cursorAdapter } from './providers/cursor/adapter.js';
|
||||
import { createNormalizedMessage } from './providers/types.js';
|
||||
import { getStatusChecker } from './providers/registry.js';
|
||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||
import { createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
// Use cross-spawn on Windows for better command execution
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
@@ -190,7 +190,7 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
case 'assistant':
|
||||
// Accumulate assistant message chunks
|
||||
if (response.message && response.message.content && response.message.content.length > 0) {
|
||||
const normalized = cursorAdapter.normalizeMessage(response, capturedSessionId || sessionId || null);
|
||||
const normalized = sessionsService.normalizeMessage('cursor', response, capturedSessionId || sessionId || null);
|
||||
for (const msg of normalized) ws.send(msg);
|
||||
}
|
||||
break;
|
||||
@@ -220,7 +220,7 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
}
|
||||
|
||||
// If not JSON, send as stream delta via adapter
|
||||
const normalized = cursorAdapter.normalizeMessage(line, capturedSessionId || sessionId || null);
|
||||
const normalized = sessionsService.normalizeMessage('cursor', line, capturedSessionId || sessionId || null);
|
||||
for (const msg of normalized) ws.send(msg);
|
||||
}
|
||||
};
|
||||
@@ -288,7 +288,7 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
});
|
||||
|
||||
// Handle process errors
|
||||
cursorProcess.on('error', (error) => {
|
||||
cursorProcess.on('error', async (error) => {
|
||||
console.error('Cursor CLI process error:', error);
|
||||
|
||||
// Clean up process reference on error
|
||||
@@ -296,7 +296,7 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
activeCursorProcesses.delete(finalSessionId);
|
||||
|
||||
// Check if Cursor CLI is installed for a clearer error message
|
||||
const installed = getStatusChecker('cursor')?.checkInstalled() ?? true;
|
||||
const installed = await providerAuthService.isProviderInstalled('cursor');
|
||||
const errorContent = !installed
|
||||
? 'Cursor CLI is not installed. Please install it from https://cursor.com'
|
||||
: error.message;
|
||||
|
||||
@@ -1,593 +0,0 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import crypto from 'crypto';
|
||||
import { findAppRoot, getModuleDir } from '../utils/runtime-paths.js';
|
||||
import {
|
||||
APP_CONFIG_TABLE_SQL,
|
||||
USER_NOTIFICATION_PREFERENCES_TABLE_SQL,
|
||||
VAPID_KEYS_TABLE_SQL,
|
||||
PUSH_SUBSCRIPTIONS_TABLE_SQL,
|
||||
SESSION_NAMES_TABLE_SQL,
|
||||
SESSION_NAMES_LOOKUP_INDEX_SQL,
|
||||
DATABASE_SCHEMA_SQL
|
||||
} from './schema.js';
|
||||
|
||||
const __dirname = getModuleDir(import.meta.url);
|
||||
// The compiled backend lives under dist-server/server/database, but the install root we log
|
||||
// should still point at the project/app root. Resolving it here avoids build-layout drift.
|
||||
const APP_ROOT = findAppRoot(__dirname);
|
||||
|
||||
// ANSI color codes for terminal output
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
cyan: '\x1b[36m',
|
||||
dim: '\x1b[2m',
|
||||
};
|
||||
|
||||
const c = {
|
||||
info: (text) => `${colors.cyan}${text}${colors.reset}`,
|
||||
bright: (text) => `${colors.bright}${text}${colors.reset}`,
|
||||
dim: (text) => `${colors.dim}${text}${colors.reset}`,
|
||||
};
|
||||
|
||||
// Use DATABASE_PATH environment variable if set, otherwise use default location
|
||||
const DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, 'auth.db');
|
||||
|
||||
// Ensure database directory exists if custom path is provided
|
||||
if (process.env.DATABASE_PATH) {
|
||||
const dbDir = path.dirname(DB_PATH);
|
||||
try {
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
console.log(`Created database directory: ${dbDir}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to create database directory ${dbDir}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// As part of 1.19.2 we are introducing a new location for auth.db. The below handles exisitng moving legacy database from install directory to new location
|
||||
const LEGACY_DB_PATH = path.join(__dirname, 'auth.db');
|
||||
if (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGACY_DB_PATH)) {
|
||||
try {
|
||||
fs.copyFileSync(LEGACY_DB_PATH, DB_PATH);
|
||||
console.log(`[MIGRATION] Copied database from ${LEGACY_DB_PATH} to ${DB_PATH}`);
|
||||
for (const suffix of ['-wal', '-shm']) {
|
||||
if (fs.existsSync(LEGACY_DB_PATH + suffix)) {
|
||||
fs.copyFileSync(LEGACY_DB_PATH + suffix, DB_PATH + suffix);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[MIGRATION] Could not copy legacy database: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create database connection
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
// app_config must exist before any other module imports (auth.js reads the JWT secret at load time).
|
||||
// runMigrations() also creates this table, but it runs too late for existing installations
|
||||
// where auth.js is imported before initializeDatabase() is called.
|
||||
db.exec(APP_CONFIG_TABLE_SQL);
|
||||
|
||||
// Show app installation path prominently
|
||||
const appInstallPath = APP_ROOT;
|
||||
console.log('');
|
||||
console.log(c.dim('═'.repeat(60)));
|
||||
console.log(`${c.info('[INFO]')} App Installation: ${c.bright(appInstallPath)}`);
|
||||
console.log(`${c.info('[INFO]')} Database: ${c.dim(path.relative(appInstallPath, DB_PATH))}`);
|
||||
if (process.env.DATABASE_PATH) {
|
||||
console.log(` ${c.dim('(Using custom DATABASE_PATH from environment)')}`);
|
||||
}
|
||||
console.log(c.dim('═'.repeat(60)));
|
||||
console.log('');
|
||||
|
||||
const runMigrations = () => {
|
||||
try {
|
||||
const tableInfo = db.prepare("PRAGMA table_info(users)").all();
|
||||
const columnNames = tableInfo.map(col => col.name);
|
||||
|
||||
if (!columnNames.includes('git_name')) {
|
||||
console.log('Running migration: Adding git_name column');
|
||||
db.exec('ALTER TABLE users ADD COLUMN git_name TEXT');
|
||||
}
|
||||
|
||||
if (!columnNames.includes('git_email')) {
|
||||
console.log('Running migration: Adding git_email column');
|
||||
db.exec('ALTER TABLE users ADD COLUMN git_email TEXT');
|
||||
}
|
||||
|
||||
if (!columnNames.includes('has_completed_onboarding')) {
|
||||
console.log('Running migration: Adding has_completed_onboarding column');
|
||||
db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
|
||||
}
|
||||
|
||||
db.exec(USER_NOTIFICATION_PREFERENCES_TABLE_SQL);
|
||||
db.exec(VAPID_KEYS_TABLE_SQL);
|
||||
db.exec(PUSH_SUBSCRIPTIONS_TABLE_SQL);
|
||||
db.exec(APP_CONFIG_TABLE_SQL);
|
||||
db.exec(SESSION_NAMES_TABLE_SQL);
|
||||
db.exec(SESSION_NAMES_LOOKUP_INDEX_SQL);
|
||||
|
||||
console.log('Database migrations completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Error running migrations:', error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize database with schema
|
||||
const initializeDatabase = async () => {
|
||||
try {
|
||||
db.exec(DATABASE_SCHEMA_SQL);
|
||||
console.log('Database initialized successfully');
|
||||
runMigrations();
|
||||
} catch (error) {
|
||||
console.error('Error initializing database:', error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// User database operations
|
||||
const userDb = {
|
||||
// Check if any users exist
|
||||
hasUsers: () => {
|
||||
try {
|
||||
const row = db.prepare('SELECT COUNT(*) as count FROM users').get();
|
||||
return row.count > 0;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Create a new user
|
||||
createUser: (username, passwordHash) => {
|
||||
try {
|
||||
const stmt = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
|
||||
const result = stmt.run(username, passwordHash);
|
||||
return { id: result.lastInsertRowid, username };
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Get user by username
|
||||
getUserByUsername: (username) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username);
|
||||
return row;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Update last login time (non-fatal — logged but not thrown)
|
||||
updateLastLogin: (userId) => {
|
||||
try {
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
|
||||
} catch (err) {
|
||||
console.warn('Failed to update last login:', err.message);
|
||||
}
|
||||
},
|
||||
|
||||
// Get user by ID
|
||||
getUserById: (userId) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1').get(userId);
|
||||
return row;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
getFirstUser: () => {
|
||||
try {
|
||||
const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE is_active = 1 LIMIT 1').get();
|
||||
return row;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
updateGitConfig: (userId, gitName, gitEmail) => {
|
||||
try {
|
||||
const stmt = db.prepare('UPDATE users SET git_name = ?, git_email = ? WHERE id = ?');
|
||||
stmt.run(gitName, gitEmail, userId);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
getGitConfig: (userId) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT git_name, git_email FROM users WHERE id = ?').get(userId);
|
||||
return row;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
completeOnboarding: (userId) => {
|
||||
try {
|
||||
const stmt = db.prepare('UPDATE users SET has_completed_onboarding = 1 WHERE id = ?');
|
||||
stmt.run(userId);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
hasCompletedOnboarding: (userId) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT has_completed_onboarding FROM users WHERE id = ?').get(userId);
|
||||
return row?.has_completed_onboarding === 1;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// API Keys database operations
|
||||
const apiKeysDb = {
|
||||
// Generate a new API key
|
||||
generateApiKey: () => {
|
||||
return 'ck_' + crypto.randomBytes(32).toString('hex');
|
||||
},
|
||||
|
||||
// Create a new API key
|
||||
createApiKey: (userId, keyName) => {
|
||||
try {
|
||||
const apiKey = apiKeysDb.generateApiKey();
|
||||
const stmt = db.prepare('INSERT INTO api_keys (user_id, key_name, api_key) VALUES (?, ?, ?)');
|
||||
const result = stmt.run(userId, keyName, apiKey);
|
||||
return { id: result.lastInsertRowid, keyName, apiKey };
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Get all API keys for a user
|
||||
getApiKeys: (userId) => {
|
||||
try {
|
||||
const rows = db.prepare('SELECT id, key_name, api_key, created_at, last_used, is_active FROM api_keys WHERE user_id = ? ORDER BY created_at DESC').all(userId);
|
||||
return rows;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Validate API key and get user
|
||||
validateApiKey: (apiKey) => {
|
||||
try {
|
||||
const row = db.prepare(`
|
||||
SELECT u.id, u.username, ak.id as api_key_id
|
||||
FROM api_keys ak
|
||||
JOIN users u ON ak.user_id = u.id
|
||||
WHERE ak.api_key = ? AND ak.is_active = 1 AND u.is_active = 1
|
||||
`).get(apiKey);
|
||||
|
||||
if (row) {
|
||||
// Update last_used timestamp
|
||||
db.prepare('UPDATE api_keys SET last_used = CURRENT_TIMESTAMP WHERE id = ?').run(row.api_key_id);
|
||||
}
|
||||
|
||||
return row;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Delete an API key
|
||||
deleteApiKey: (userId, apiKeyId) => {
|
||||
try {
|
||||
const stmt = db.prepare('DELETE FROM api_keys WHERE id = ? AND user_id = ?');
|
||||
const result = stmt.run(apiKeyId, userId);
|
||||
return result.changes > 0;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Toggle API key active status
|
||||
toggleApiKey: (userId, apiKeyId, isActive) => {
|
||||
try {
|
||||
const stmt = db.prepare('UPDATE api_keys SET is_active = ? WHERE id = ? AND user_id = ?');
|
||||
const result = stmt.run(isActive ? 1 : 0, apiKeyId, userId);
|
||||
return result.changes > 0;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// User credentials database operations (for GitHub tokens, GitLab tokens, etc.)
|
||||
const credentialsDb = {
|
||||
// Create a new credential
|
||||
createCredential: (userId, credentialName, credentialType, credentialValue, description = null) => {
|
||||
try {
|
||||
const stmt = db.prepare('INSERT INTO user_credentials (user_id, credential_name, credential_type, credential_value, description) VALUES (?, ?, ?, ?, ?)');
|
||||
const result = stmt.run(userId, credentialName, credentialType, credentialValue, description);
|
||||
return { id: result.lastInsertRowid, credentialName, credentialType };
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Get all credentials for a user, optionally filtered by type
|
||||
getCredentials: (userId, credentialType = null) => {
|
||||
try {
|
||||
let query = 'SELECT id, credential_name, credential_type, description, created_at, is_active FROM user_credentials WHERE user_id = ?';
|
||||
const params = [userId];
|
||||
|
||||
if (credentialType) {
|
||||
query += ' AND credential_type = ?';
|
||||
params.push(credentialType);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC';
|
||||
|
||||
const rows = db.prepare(query).all(...params);
|
||||
return rows;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Get active credential value for a user by type (returns most recent active)
|
||||
getActiveCredential: (userId, credentialType) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT credential_value FROM user_credentials WHERE user_id = ? AND credential_type = ? AND is_active = 1 ORDER BY created_at DESC LIMIT 1').get(userId, credentialType);
|
||||
return row?.credential_value || null;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Delete a credential
|
||||
deleteCredential: (userId, credentialId) => {
|
||||
try {
|
||||
const stmt = db.prepare('DELETE FROM user_credentials WHERE id = ? AND user_id = ?');
|
||||
const result = stmt.run(credentialId, userId);
|
||||
return result.changes > 0;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Toggle credential active status
|
||||
toggleCredential: (userId, credentialId, isActive) => {
|
||||
try {
|
||||
const stmt = db.prepare('UPDATE user_credentials SET is_active = ? WHERE id = ? AND user_id = ?');
|
||||
const result = stmt.run(isActive ? 1 : 0, credentialId, userId);
|
||||
return result.changes > 0;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const DEFAULT_NOTIFICATION_PREFERENCES = {
|
||||
channels: {
|
||||
inApp: false,
|
||||
webPush: false
|
||||
},
|
||||
events: {
|
||||
actionRequired: true,
|
||||
stop: true,
|
||||
error: true
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeNotificationPreferences = (value) => {
|
||||
const source = value && typeof value === 'object' ? value : {};
|
||||
|
||||
return {
|
||||
channels: {
|
||||
inApp: source.channels?.inApp === true,
|
||||
webPush: source.channels?.webPush === true
|
||||
},
|
||||
events: {
|
||||
actionRequired: source.events?.actionRequired !== false,
|
||||
stop: source.events?.stop !== false,
|
||||
error: source.events?.error !== false
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const notificationPreferencesDb = {
|
||||
getPreferences: (userId) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT preferences_json FROM user_notification_preferences WHERE user_id = ?').get(userId);
|
||||
if (!row) {
|
||||
const defaults = normalizeNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES);
|
||||
db.prepare(
|
||||
'INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)'
|
||||
).run(userId, JSON.stringify(defaults));
|
||||
return defaults;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(row.preferences_json);
|
||||
} catch {
|
||||
parsed = DEFAULT_NOTIFICATION_PREFERENCES;
|
||||
}
|
||||
return normalizeNotificationPreferences(parsed);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
updatePreferences: (userId, preferences) => {
|
||||
try {
|
||||
const normalized = normalizeNotificationPreferences(preferences);
|
||||
db.prepare(
|
||||
`INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
preferences_json = excluded.preferences_json,
|
||||
updated_at = CURRENT_TIMESTAMP`
|
||||
).run(userId, JSON.stringify(normalized));
|
||||
return normalized;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pushSubscriptionsDb = {
|
||||
saveSubscription: (userId, endpoint, keysP256dh, keysAuth) => {
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
keys_p256dh = excluded.keys_p256dh,
|
||||
keys_auth = excluded.keys_auth`
|
||||
).run(userId, endpoint, keysP256dh, keysAuth);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
getSubscriptions: (userId) => {
|
||||
try {
|
||||
return db.prepare('SELECT endpoint, keys_p256dh, keys_auth FROM push_subscriptions WHERE user_id = ?').all(userId);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
removeSubscription: (endpoint) => {
|
||||
try {
|
||||
db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
removeAllForUser: (userId) => {
|
||||
try {
|
||||
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Session custom names database operations
|
||||
const sessionNamesDb = {
|
||||
// Set (insert or update) a custom session name
|
||||
setName: (sessionId, provider, customName) => {
|
||||
db.prepare(`
|
||||
INSERT INTO session_names (session_id, provider, custom_name)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(session_id, provider)
|
||||
DO UPDATE SET custom_name = excluded.custom_name, updated_at = CURRENT_TIMESTAMP
|
||||
`).run(sessionId, provider, customName);
|
||||
},
|
||||
|
||||
// Get a single custom session name
|
||||
getName: (sessionId, provider) => {
|
||||
const row = db.prepare(
|
||||
'SELECT custom_name FROM session_names WHERE session_id = ? AND provider = ?'
|
||||
).get(sessionId, provider);
|
||||
return row?.custom_name || null;
|
||||
},
|
||||
|
||||
// Batch lookup — returns Map<sessionId, customName>
|
||||
getNames: (sessionIds, provider) => {
|
||||
if (!sessionIds.length) return new Map();
|
||||
const placeholders = sessionIds.map(() => '?').join(',');
|
||||
const rows = db.prepare(
|
||||
`SELECT session_id, custom_name FROM session_names
|
||||
WHERE session_id IN (${placeholders}) AND provider = ?`
|
||||
).all(...sessionIds, provider);
|
||||
return new Map(rows.map(r => [r.session_id, r.custom_name]));
|
||||
},
|
||||
|
||||
// Delete a custom session name
|
||||
deleteName: (sessionId, provider) => {
|
||||
return db.prepare(
|
||||
'DELETE FROM session_names WHERE session_id = ? AND provider = ?'
|
||||
).run(sessionId, provider).changes > 0;
|
||||
},
|
||||
};
|
||||
|
||||
// Apply custom session names from the database (overrides CLI-generated summaries)
|
||||
function applyCustomSessionNames(sessions, provider) {
|
||||
if (!sessions?.length) return;
|
||||
try {
|
||||
const ids = sessions.map(s => s.id);
|
||||
const customNames = sessionNamesDb.getNames(ids, provider);
|
||||
for (const session of sessions) {
|
||||
const custom = customNames.get(session.id);
|
||||
if (custom) session.summary = custom;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[DB] Failed to apply custom session names for ${provider}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// App config database operations
|
||||
const appConfigDb = {
|
||||
get: (key) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key);
|
||||
return row?.value || null;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
set: (key, value) => {
|
||||
db.prepare(
|
||||
'INSERT INTO app_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
).run(key, value);
|
||||
},
|
||||
|
||||
getOrCreateJwtSecret: () => {
|
||||
let secret = appConfigDb.get('jwt_secret');
|
||||
if (!secret) {
|
||||
secret = crypto.randomBytes(64).toString('hex');
|
||||
appConfigDb.set('jwt_secret', secret);
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
};
|
||||
|
||||
// Backward compatibility - keep old names pointing to new system
|
||||
const githubTokensDb = {
|
||||
createGithubToken: (userId, tokenName, githubToken, description = null) => {
|
||||
return credentialsDb.createCredential(userId, tokenName, 'github_token', githubToken, description);
|
||||
},
|
||||
getGithubTokens: (userId) => {
|
||||
return credentialsDb.getCredentials(userId, 'github_token');
|
||||
},
|
||||
getActiveGithubToken: (userId) => {
|
||||
return credentialsDb.getActiveCredential(userId, 'github_token');
|
||||
},
|
||||
deleteGithubToken: (userId, tokenId) => {
|
||||
return credentialsDb.deleteCredential(userId, tokenId);
|
||||
},
|
||||
toggleGithubToken: (userId, tokenId, isActive) => {
|
||||
return credentialsDb.toggleCredential(userId, tokenId, isActive);
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
db,
|
||||
initializeDatabase,
|
||||
userDb,
|
||||
apiKeysDb,
|
||||
credentialsDb,
|
||||
notificationPreferencesDb,
|
||||
pushSubscriptionsDb,
|
||||
sessionNamesDb,
|
||||
applyCustomSessionNames,
|
||||
appConfigDb,
|
||||
githubTokensDb // Backward compatibility
|
||||
};
|
||||
@@ -1,102 +0,0 @@
|
||||
export const APP_CONFIG_TABLE_SQL = `CREATE TABLE IF NOT EXISTS app_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);`;
|
||||
|
||||
export const USER_NOTIFICATION_PREFERENCES_TABLE_SQL = `CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
preferences_json TEXT NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);`;
|
||||
|
||||
export const VAPID_KEYS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS vapid_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);`;
|
||||
|
||||
export const PUSH_SUBSCRIPTIONS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
keys_p256dh TEXT NOT NULL,
|
||||
keys_auth TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);`;
|
||||
|
||||
export const SESSION_NAMES_TABLE_SQL = `CREATE TABLE IF NOT EXISTS session_names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL DEFAULT 'claude',
|
||||
custom_name TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(session_id, provider)
|
||||
);`;
|
||||
|
||||
export const SESSION_NAMES_LOOKUP_INDEX_SQL = `CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);`;
|
||||
|
||||
export const DATABASE_SCHEMA_SQL = `PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login DATETIME,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
git_name TEXT,
|
||||
git_email TEXT,
|
||||
has_completed_onboarding BOOLEAN DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
key_name TEXT NOT NULL,
|
||||
api_key TEXT UNIQUE NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used DATETIME,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(api_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
credential_name TEXT NOT NULL,
|
||||
credential_type TEXT NOT NULL,
|
||||
credential_value TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
|
||||
|
||||
${USER_NOTIFICATION_PREFERENCES_TABLE_SQL}
|
||||
|
||||
${VAPID_KEYS_TABLE_SQL}
|
||||
|
||||
${PUSH_SUBSCRIPTIONS_TABLE_SQL}
|
||||
|
||||
${SESSION_NAMES_TABLE_SQL}
|
||||
|
||||
${SESSION_NAMES_LOOKUP_INDEX_SQL}
|
||||
|
||||
${APP_CONFIG_TABLE_SQL}
|
||||
`;
|
||||
@@ -9,8 +9,8 @@ import os from 'os';
|
||||
import sessionManager from './sessionManager.js';
|
||||
import GeminiResponseHandler from './gemini-response-handler.js';
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||
import { createNormalizedMessage } from './providers/types.js';
|
||||
import { getStatusChecker } from './providers/registry.js';
|
||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||
import { createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
let activeGeminiProcesses = new Map(); // Track active processes by session ID
|
||||
|
||||
@@ -383,7 +383,7 @@ async function spawnGemini(command, options = {}, ws) {
|
||||
} else {
|
||||
// code 127 = shell "command not found" — check installation
|
||||
if (code === 127) {
|
||||
const installed = getStatusChecker('gemini')?.checkInstalled() ?? true;
|
||||
const installed = await providerAuthService.isProviderInstalled('gemini');
|
||||
if (!installed) {
|
||||
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
|
||||
ws.send(createNormalizedMessage({ kind: 'error', content: 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli', sessionId: socketSessionId, provider: 'gemini' }));
|
||||
@@ -399,13 +399,13 @@ async function spawnGemini(command, options = {}, ws) {
|
||||
});
|
||||
|
||||
// Handle process errors
|
||||
geminiProcess.on('error', (error) => {
|
||||
geminiProcess.on('error', async (error) => {
|
||||
// Clean up process reference on error
|
||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||
activeGeminiProcesses.delete(finalSessionId);
|
||||
|
||||
// Check if Gemini CLI is installed for a clearer error message
|
||||
const installed = getStatusChecker('gemini')?.checkInstalled() ?? true;
|
||||
const installed = await providerAuthService.isProviderInstalled('gemini');
|
||||
const errorContent = !installed
|
||||
? 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli'
|
||||
: error.message;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Gemini Response Handler - JSON Stream processing
|
||||
import { geminiAdapter } from './providers/gemini/adapter.js';
|
||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||
|
||||
class GeminiResponseHandler {
|
||||
constructor(ws, options = {}) {
|
||||
@@ -56,7 +56,7 @@ class GeminiResponseHandler {
|
||||
}
|
||||
|
||||
// Normalize via adapter and send all resulting messages
|
||||
const normalized = geminiAdapter.normalizeMessage(event, sid);
|
||||
const normalized = sessionsService.normalizeMessage('gemini', event, sid);
|
||||
for (const msg of normalized) {
|
||||
this.ws.send(msg);
|
||||
}
|
||||
|
||||
1236
server/index.js
1236
server/index.js
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { userDb, appConfigDb } from '../database/db.js';
|
||||
import { userDb, appConfigDb } from '../modules/database/index.js';
|
||||
import { IS_PLATFORM } from '../constants/config.js';
|
||||
|
||||
// Use env var if set, otherwise auto-generate a unique secret per installation
|
||||
|
||||
143
server/modules/database/connection.ts
Normal file
143
server/modules/database/connection.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Database connection management.
|
||||
*
|
||||
* Owns the single SQLite connection used across all repositories.
|
||||
* Handles path resolution, directory creation, legacy database migration,
|
||||
* and eager app_config bootstrap so the auth middleware can read the
|
||||
* JWT secret before the full schema is applied.
|
||||
*
|
||||
* Consumers should never create their own Database instance — they use
|
||||
* `getConnection()` to obtain the shared singleton.
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { APP_CONFIG_TABLE_SCHEMA_SQL } from '@/modules/database/schema.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolves the database file path from environment or falls back
|
||||
* to the legacy location inside the server/database/ folder.
|
||||
*
|
||||
* Priority:
|
||||
* 1. DATABASE_PATH environment variable (set by cli.js or load-env-vars.js)
|
||||
* 2. Legacy path: server/database/auth.db
|
||||
*/
|
||||
function resolveDatabasePath(): string {
|
||||
// process.env.DATABASE_PATH is set by load-env-vars.js to either the .env value or a default(~/.cloudcli/auth.db) in the user's home directory.
|
||||
return process.env.DATABASE_PATH || resolveLegacyDatabasePath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the legacy database path (always inside server/database/).
|
||||
* Used for the one-time migration to the new external location.
|
||||
*/
|
||||
function resolveLegacyDatabasePath(): string {
|
||||
const serverDir = path.resolve(__dirname, '..', '..', '..');
|
||||
return path.join(serverDir, 'database', 'auth.db');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Directory & migration helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ensureDatabaseDirectory(dbPath: string): void {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
console.log('Created database directory:', dir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the database was moved to an external location (e.g. ~/.cloudcli/)
|
||||
* but the user still has a legacy auth.db inside the install directory,
|
||||
* copy it to the new location as a one-time migration.
|
||||
*/
|
||||
function migrateLegacyDatabase(targetPath: string): void {
|
||||
const legacyPath = resolveLegacyDatabasePath();
|
||||
|
||||
if (targetPath === legacyPath) return;
|
||||
if (fs.existsSync(targetPath)) return;
|
||||
if (!fs.existsSync(legacyPath)) return;
|
||||
|
||||
try {
|
||||
fs.copyFileSync(legacyPath, targetPath);
|
||||
console.log('Migrated legacy database', { from: legacyPath, to: targetPath });
|
||||
|
||||
|
||||
// copy the write-ahead log and shared memory files (auth.db-wal, auth.db-shm) if they exist, to preserve any uncommitted transactions
|
||||
for (const suffix of ['-wal', '-shm']) {
|
||||
const src = legacyPath + suffix;
|
||||
if (fs.existsSync(src)) {
|
||||
fs.copyFileSync(src, targetPath + suffix);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Could not migrate legacy database', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Singleton connection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let instance: Database.Database | null = null;
|
||||
|
||||
/**
|
||||
* Returns the shared database connection, creating it on first call.
|
||||
*
|
||||
* The first invocation:
|
||||
* 1. Resolves the target database path
|
||||
* 2. Ensures the parent directory exists
|
||||
* 3. Migrates from the legacy install-directory path if needed
|
||||
* 4. Opens the SQLite connection
|
||||
* 5. Eagerly creates the app_config table (auth reads JWT secret at import time)
|
||||
* 6. Logs the database location
|
||||
*/
|
||||
export function getConnection(): Database.Database {
|
||||
if (instance) return instance;
|
||||
|
||||
const dbPath = resolveDatabasePath();
|
||||
|
||||
ensureDatabaseDirectory(dbPath);
|
||||
migrateLegacyDatabase(dbPath);
|
||||
|
||||
instance = new Database(dbPath);
|
||||
|
||||
// app_config must exist immediately — the auth middleware reads
|
||||
// the JWT secret at module-load time, before initializeDatabase() runs.
|
||||
instance.exec(APP_CONFIG_TABLE_SCHEMA_SQL);
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the resolved database file path without opening a connection.
|
||||
* Useful for diagnostics and CLI status commands.
|
||||
*/
|
||||
export function getDatabasePath(): string {
|
||||
return resolveDatabasePath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the database connection and clears the singleton.
|
||||
* Primarily used for graceful shutdown or testing.
|
||||
*/
|
||||
export function closeConnection(): void {
|
||||
if (instance) {
|
||||
instance.close();
|
||||
instance = null;
|
||||
console.log('Database connection closed');
|
||||
}
|
||||
}
|
||||
12
server/modules/database/index.ts
Normal file
12
server/modules/database/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { initializeDatabase } from '@/modules/database/init-db.js';
|
||||
export { apiKeysDb } from '@/modules/database/repositories/api-keys.js';
|
||||
export { appConfigDb } from '@/modules/database/repositories/app-config.js';
|
||||
export { credentialsDb } from '@/modules/database/repositories/credentials.js';
|
||||
export { githubTokensDb } from '@/modules/database/repositories/github-tokens.js';
|
||||
export { notificationPreferencesDb } from '@/modules/database/repositories/notification-preferences.js';
|
||||
export { projectsDb } from '@/modules/database/repositories/projects.db.js';
|
||||
export { pushSubscriptionsDb } from '@/modules/database/repositories/push-subscriptions.js';
|
||||
export { scanStateDb } from '@/modules/database/repositories/scan-state.db.js';
|
||||
export { sessionsDb } from '@/modules/database/repositories/sessions.db.js';
|
||||
export { userDb } from '@/modules/database/repositories/users.js';
|
||||
export { vapidKeysDb } from '@/modules/database/repositories/vapid-keys.js';
|
||||
17
server/modules/database/init-db.ts
Normal file
17
server/modules/database/init-db.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { getConnection } from "@/modules/database/connection.js";
|
||||
import { runMigrations } from "@/modules/database/migrations.js";
|
||||
import { INIT_SCHEMA_SQL } from "@/modules/database/schema.js";
|
||||
|
||||
// Initialize database with schema
|
||||
export const initializeDatabase = async () => {
|
||||
try {
|
||||
const db = getConnection();
|
||||
db.exec(INIT_SCHEMA_SQL);
|
||||
console.log('Database schema applied');
|
||||
runMigrations(db);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.log('Database initialization failed', { error: message });
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
443
server/modules/database/migrations.ts
Normal file
443
server/modules/database/migrations.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
import { Database } from 'better-sqlite3';
|
||||
|
||||
import {
|
||||
APP_CONFIG_TABLE_SCHEMA_SQL,
|
||||
LAST_SCANNED_AT_SQL,
|
||||
PROJECTS_TABLE_SCHEMA_SQL,
|
||||
PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL,
|
||||
SESSIONS_TABLE_SCHEMA_SQL,
|
||||
USER_NOTIFICATION_PREFERENCES_TABLE_SCHEMA_SQL,
|
||||
VAPID_KEYS_TABLE_SCHEMA_SQL,
|
||||
} from '@/modules/database/schema.js';
|
||||
|
||||
const SQLITE_UUID_SQL = `
|
||||
lower(hex(randomblob(4))) || '-' ||
|
||||
lower(hex(randomblob(2))) || '-' ||
|
||||
lower(hex(randomblob(2))) || '-' ||
|
||||
lower(hex(randomblob(2))) || '-' ||
|
||||
lower(hex(randomblob(6)))
|
||||
`;
|
||||
|
||||
type TableInfoRow = {
|
||||
name: string;
|
||||
pk: number;
|
||||
};
|
||||
|
||||
const addColumnToTableIfNotExists = (
|
||||
db: Database,
|
||||
tableName: string,
|
||||
columnNames: string[],
|
||||
columnName: string,
|
||||
columnType: string
|
||||
) => {
|
||||
if (!columnNames.includes(columnName)) {
|
||||
console.log(`Running migration: Adding ${columnName} column to ${tableName} table`);
|
||||
db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnType}`);
|
||||
}
|
||||
};
|
||||
|
||||
const tableExists = (db: Database, tableName: string): boolean =>
|
||||
Boolean(
|
||||
db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(tableName)
|
||||
);
|
||||
|
||||
const getTableInfo = (db: Database, tableName: string): TableInfoRow[] =>
|
||||
db.prepare(`PRAGMA table_info(${tableName})`).all() as TableInfoRow[];
|
||||
|
||||
const migrateLegacySessionNames = (db: Database): void => {
|
||||
const hasLegacySessionNamesTable = tableExists(db, 'session_names');
|
||||
const hasSessionsTable = tableExists(db, 'sessions');
|
||||
|
||||
if (!hasLegacySessionNamesTable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasSessionsTable) {
|
||||
console.log('Running migration: Merging session_names into sessions');
|
||||
db.exec(`
|
||||
INSERT INTO sessions (session_id, provider, custom_name, created_at, updated_at)
|
||||
SELECT
|
||||
session_id,
|
||||
COALESCE(provider, 'claude'),
|
||||
custom_name,
|
||||
COALESCE(created_at, CURRENT_TIMESTAMP),
|
||||
COALESCE(updated_at, CURRENT_TIMESTAMP)
|
||||
FROM session_names
|
||||
WHERE true
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
provider = excluded.provider,
|
||||
custom_name = COALESCE(excluded.custom_name, sessions.custom_name),
|
||||
created_at = COALESCE(sessions.created_at, excluded.created_at),
|
||||
updated_at = COALESCE(excluded.updated_at, sessions.updated_at)
|
||||
`);
|
||||
db.exec('DROP TABLE session_names');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Running migration: Renaming session_names table to sessions');
|
||||
db.exec('ALTER TABLE session_names RENAME TO sessions');
|
||||
};
|
||||
|
||||
const migrateLegacyWorkspaceTableIntoProjects = (db: Database): void => {
|
||||
db.exec(PROJECTS_TABLE_SCHEMA_SQL);
|
||||
|
||||
if (!tableExists(db, 'workspace_original_paths')) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Running migration: Migrating workspace_original_paths data into projects');
|
||||
db.exec(`
|
||||
INSERT INTO projects (project_id, project_path, custom_project_name, isStarred, isArchived)
|
||||
SELECT
|
||||
CASE
|
||||
WHEN workspace_id IS NULL OR trim(workspace_id) = ''
|
||||
THEN ${SQLITE_UUID_SQL}
|
||||
ELSE workspace_id
|
||||
END,
|
||||
workspace_path,
|
||||
custom_workspace_name,
|
||||
COALESCE(isStarred, 0),
|
||||
0
|
||||
FROM workspace_original_paths
|
||||
WHERE workspace_path IS NOT NULL AND trim(workspace_path) <> ''
|
||||
ON CONFLICT(project_path) DO UPDATE SET
|
||||
custom_project_name = COALESCE(projects.custom_project_name, excluded.custom_project_name),
|
||||
isStarred = COALESCE(projects.isStarred, excluded.isStarred)
|
||||
`);
|
||||
};
|
||||
|
||||
const rebuildProjectsTableWithPrimaryKeySchema = (db: Database): void => {
|
||||
const hasProjectsTable = tableExists(db, 'projects');
|
||||
if (!hasProjectsTable) {
|
||||
db.exec(PROJECTS_TABLE_SCHEMA_SQL);
|
||||
return;
|
||||
}
|
||||
|
||||
const projectsTableInfo = getTableInfo(db, 'projects');
|
||||
const columnNames = projectsTableInfo.map((column) => column.name);
|
||||
const hasProjectIdPrimaryKey = projectsTableInfo.some(
|
||||
(column) => column.name === 'project_id' && column.pk === 1,
|
||||
);
|
||||
|
||||
if (hasProjectIdPrimaryKey) {
|
||||
addColumnToTableIfNotExists(db, 'projects', columnNames, 'custom_project_name', 'TEXT DEFAULT NULL');
|
||||
addColumnToTableIfNotExists(db, 'projects', columnNames, 'isStarred', 'BOOLEAN DEFAULT 0');
|
||||
addColumnToTableIfNotExists(db, 'projects', columnNames, 'isArchived', 'BOOLEAN DEFAULT 0');
|
||||
db.exec(`
|
||||
UPDATE projects
|
||||
SET project_id = ${SQLITE_UUID_SQL}
|
||||
WHERE project_id IS NULL OR trim(project_id) = ''
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Running migration: Rebuilding projects table to enforce project_id primary key');
|
||||
|
||||
const projectPathExpression = columnNames.includes('project_path')
|
||||
? 'project_path'
|
||||
: columnNames.includes('workspace_path')
|
||||
? 'workspace_path'
|
||||
: 'NULL';
|
||||
|
||||
const customProjectNameExpression = columnNames.includes('custom_project_name')
|
||||
? 'custom_project_name'
|
||||
: columnNames.includes('custom_workspace_name')
|
||||
? 'custom_workspace_name'
|
||||
: 'NULL';
|
||||
|
||||
const isStarredExpression = columnNames.includes('isStarred') ? 'COALESCE(isStarred, 0)' : '0';
|
||||
|
||||
const isArchivedExpression = columnNames.includes('isArchived') ? 'COALESCE(isArchived, 0)' : '0';
|
||||
|
||||
const projectIdExpression = columnNames.includes('project_id')
|
||||
? `CASE
|
||||
WHEN project_id IS NULL OR trim(project_id) = ''
|
||||
THEN ${SQLITE_UUID_SQL}
|
||||
ELSE project_id
|
||||
END`
|
||||
: SQLITE_UUID_SQL;
|
||||
|
||||
db.exec('PRAGMA foreign_keys = OFF');
|
||||
try {
|
||||
db.exec('BEGIN TRANSACTION');
|
||||
db.exec('DROP TABLE IF EXISTS projects__new');
|
||||
db.exec(`
|
||||
CREATE TABLE projects__new (
|
||||
project_id TEXT PRIMARY KEY NOT NULL,
|
||||
project_path TEXT NOT NULL UNIQUE,
|
||||
custom_project_name TEXT DEFAULT NULL,
|
||||
isStarred BOOLEAN DEFAULT 0,
|
||||
isArchived BOOLEAN DEFAULT 0
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
WITH source_rows AS (
|
||||
SELECT
|
||||
${projectPathExpression} AS project_path,
|
||||
${customProjectNameExpression} AS custom_project_name,
|
||||
${isStarredExpression} AS isStarred,
|
||||
${isArchivedExpression} AS isArchived,
|
||||
${projectIdExpression} AS candidate_project_id,
|
||||
rowid AS source_rowid
|
||||
FROM projects
|
||||
WHERE ${projectPathExpression} IS NOT NULL AND trim(${projectPathExpression}) <> ''
|
||||
),
|
||||
deduped_paths AS (
|
||||
SELECT
|
||||
project_path,
|
||||
custom_project_name,
|
||||
isStarred,
|
||||
isArchived,
|
||||
candidate_project_id,
|
||||
source_rowid,
|
||||
ROW_NUMBER() OVER (PARTITION BY project_path ORDER BY source_rowid) AS project_path_rank
|
||||
FROM source_rows
|
||||
),
|
||||
prepared_rows AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN ROW_NUMBER() OVER (PARTITION BY candidate_project_id ORDER BY source_rowid) = 1
|
||||
THEN candidate_project_id
|
||||
ELSE ${SQLITE_UUID_SQL}
|
||||
END AS project_id,
|
||||
project_path,
|
||||
custom_project_name,
|
||||
isStarred,
|
||||
isArchived
|
||||
FROM deduped_paths
|
||||
WHERE project_path_rank = 1
|
||||
)
|
||||
INSERT INTO projects__new (
|
||||
project_id,
|
||||
project_path,
|
||||
custom_project_name,
|
||||
isStarred,
|
||||
isArchived
|
||||
)
|
||||
SELECT
|
||||
project_id,
|
||||
project_path,
|
||||
custom_project_name,
|
||||
isStarred,
|
||||
isArchived
|
||||
FROM prepared_rows
|
||||
`);
|
||||
db.exec('DROP TABLE projects');
|
||||
db.exec('ALTER TABLE projects__new RENAME TO projects');
|
||||
db.exec('COMMIT');
|
||||
} catch (migrationError) {
|
||||
db.exec('ROLLBACK');
|
||||
throw migrationError;
|
||||
} finally {
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
};
|
||||
|
||||
const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
|
||||
const hasSessions = tableExists(db, 'sessions');
|
||||
if (!hasSessions) {
|
||||
db.exec(SESSIONS_TABLE_SCHEMA_SQL);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionsTableInfo = getTableInfo(db, 'sessions');
|
||||
const columnNames = sessionsTableInfo.map((column) => column.name);
|
||||
const primaryKeyColumns = sessionsTableInfo
|
||||
.filter((column) => column.pk > 0)
|
||||
.sort((a, b) => a.pk - b.pk)
|
||||
.map((column) => column.name);
|
||||
|
||||
const shouldRebuild =
|
||||
!columnNames.includes('project_path') ||
|
||||
primaryKeyColumns.length !== 1 ||
|
||||
primaryKeyColumns[0] !== 'session_id' ||
|
||||
!columnNames.includes('provider');
|
||||
|
||||
if (!shouldRebuild) {
|
||||
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'jsonl_path', 'TEXT');
|
||||
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'created_at', 'DATETIME');
|
||||
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'updated_at', 'DATETIME');
|
||||
db.exec('UPDATE sessions SET created_at = COALESCE(created_at, CURRENT_TIMESTAMP)');
|
||||
db.exec('UPDATE sessions SET updated_at = COALESCE(updated_at, CURRENT_TIMESTAMP)');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Running migration: Rebuilding sessions table to project-based schema');
|
||||
|
||||
const projectPathExpression = columnNames.includes('project_path')
|
||||
? 'project_path'
|
||||
: columnNames.includes('workspace_path')
|
||||
? 'workspace_path'
|
||||
: 'NULL';
|
||||
|
||||
const providerExpression = columnNames.includes('provider')
|
||||
? "COALESCE(provider, 'claude')"
|
||||
: "'claude'";
|
||||
|
||||
const customNameExpression = columnNames.includes('custom_name')
|
||||
? 'custom_name'
|
||||
: 'NULL';
|
||||
|
||||
const jsonlPathExpression = columnNames.includes('jsonl_path')
|
||||
? 'jsonl_path'
|
||||
: 'NULL';
|
||||
|
||||
const createdAtExpression = columnNames.includes('created_at')
|
||||
? 'COALESCE(created_at, CURRENT_TIMESTAMP)'
|
||||
: 'CURRENT_TIMESTAMP';
|
||||
|
||||
const updatedAtExpression = columnNames.includes('updated_at')
|
||||
? 'COALESCE(updated_at, CURRENT_TIMESTAMP)'
|
||||
: 'CURRENT_TIMESTAMP';
|
||||
|
||||
db.exec('PRAGMA foreign_keys = OFF');
|
||||
try {
|
||||
db.exec('BEGIN TRANSACTION');
|
||||
db.exec('DROP TABLE IF EXISTS sessions__new');
|
||||
db.exec(`
|
||||
CREATE TABLE sessions__new (
|
||||
session_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL DEFAULT 'claude',
|
||||
custom_name TEXT,
|
||||
project_path TEXT,
|
||||
jsonl_path TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (session_id),
|
||||
FOREIGN KEY (project_path) REFERENCES projects(project_path)
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
WITH source_rows AS (
|
||||
SELECT
|
||||
session_id,
|
||||
${providerExpression} AS provider,
|
||||
${customNameExpression} AS custom_name,
|
||||
${projectPathExpression} AS project_path,
|
||||
${jsonlPathExpression} AS jsonl_path,
|
||||
${createdAtExpression} AS created_at,
|
||||
${updatedAtExpression} AS updated_at,
|
||||
rowid AS source_rowid
|
||||
FROM sessions
|
||||
WHERE session_id IS NOT NULL AND trim(session_id) <> ''
|
||||
),
|
||||
ranked_rows AS (
|
||||
SELECT
|
||||
session_id,
|
||||
provider,
|
||||
custom_name,
|
||||
project_path,
|
||||
jsonl_path,
|
||||
created_at,
|
||||
updated_at,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY session_id
|
||||
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, source_rowid DESC
|
||||
) AS session_rank
|
||||
FROM source_rows
|
||||
)
|
||||
INSERT INTO sessions__new (
|
||||
session_id,
|
||||
provider,
|
||||
custom_name,
|
||||
project_path,
|
||||
jsonl_path,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
session_id,
|
||||
provider,
|
||||
custom_name,
|
||||
project_path,
|
||||
jsonl_path,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM ranked_rows
|
||||
WHERE session_rank = 1
|
||||
`);
|
||||
db.exec('DROP TABLE sessions');
|
||||
db.exec('ALTER TABLE sessions__new RENAME TO sessions');
|
||||
db.exec('COMMIT');
|
||||
} catch (migrationError) {
|
||||
db.exec('ROLLBACK');
|
||||
throw migrationError;
|
||||
} finally {
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
};
|
||||
|
||||
const ensureProjectsForSessionPaths = (db: Database): void => {
|
||||
if (!tableExists(db, 'sessions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
INSERT INTO projects (project_id, project_path, custom_project_name, isStarred, isArchived)
|
||||
SELECT
|
||||
${SQLITE_UUID_SQL},
|
||||
project_path,
|
||||
NULL,
|
||||
0,
|
||||
0
|
||||
FROM sessions
|
||||
WHERE project_path IS NOT NULL AND trim(project_path) <> ''
|
||||
ON CONFLICT(project_path) DO NOTHING
|
||||
`);
|
||||
};
|
||||
|
||||
export const runMigrations = (db: Database) => {
|
||||
try {
|
||||
const usersTableInfo = db.prepare('PRAGMA table_info(users)').all() as { name: string }[];
|
||||
const userColumnNames = usersTableInfo.map((column) => column.name);
|
||||
|
||||
addColumnToTableIfNotExists(db, 'users', userColumnNames, 'git_name', 'TEXT');
|
||||
addColumnToTableIfNotExists(db, 'users', userColumnNames, 'git_email', 'TEXT');
|
||||
addColumnToTableIfNotExists(
|
||||
db,
|
||||
'users',
|
||||
userColumnNames,
|
||||
'has_completed_onboarding',
|
||||
'BOOLEAN DEFAULT 0'
|
||||
);
|
||||
|
||||
db.exec(APP_CONFIG_TABLE_SCHEMA_SQL);
|
||||
db.exec(USER_NOTIFICATION_PREFERENCES_TABLE_SCHEMA_SQL);
|
||||
db.exec(VAPID_KEYS_TABLE_SCHEMA_SQL);
|
||||
db.exec(PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id)');
|
||||
|
||||
db.exec(PROJECTS_TABLE_SCHEMA_SQL);
|
||||
rebuildProjectsTableWithPrimaryKeySchema(db);
|
||||
|
||||
migrateLegacyWorkspaceTableIntoProjects(db);
|
||||
rebuildSessionsTableWithProjectSchema(db);
|
||||
migrateLegacySessionNames(db);
|
||||
ensureProjectsForSessionPaths(db);
|
||||
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_starred ON projects(isStarred)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_archived ON projects(isArchived)');
|
||||
|
||||
db.exec('DROP INDEX IF EXISTS idx_session_names_lookup');
|
||||
db.exec('DROP INDEX IF EXISTS idx_sessions_workspace_path');
|
||||
db.exec('DROP INDEX IF EXISTS idx_workspace_original_paths_is_starred');
|
||||
db.exec('DROP INDEX IF EXISTS idx_workspace_original_paths_workspace_id');
|
||||
|
||||
if (tableExists(db, 'workspace_original_paths')) {
|
||||
console.log('Running migration: Dropping legacy workspace_original_paths table');
|
||||
db.exec('DROP TABLE workspace_original_paths');
|
||||
}
|
||||
|
||||
db.exec(LAST_SCANNED_AT_SQL);
|
||||
console.log('Database migrations completed successfully');
|
||||
} catch (error: any) {
|
||||
console.error('Error running migrations:', error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
119
server/modules/database/repositories/api-keys.ts
Normal file
119
server/modules/database/repositories/api-keys.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* API keys repository.
|
||||
*
|
||||
* Manages API keys used for external/programmatic access to the backend.
|
||||
* Keys are prefixed with `ck_` and tied to a user via foreign key.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { getConnection } from '@/modules/database/connection.js';
|
||||
|
||||
type ApiKeyRow = {
|
||||
id: number;
|
||||
key_name: string;
|
||||
api_key: string;
|
||||
created_at: string;
|
||||
last_used: string | null;
|
||||
is_active: number;
|
||||
};
|
||||
|
||||
type CreateApiKeyResult = {
|
||||
id: number | bigint;
|
||||
keyName: string;
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
type ValidatedApiKeyUser = {
|
||||
id: number;
|
||||
username: string;
|
||||
api_key_id: number;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Generates a cryptographically random API key with the `ck_` prefix. */
|
||||
function generateApiKey(): string {
|
||||
return 'ck_' + crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const apiKeysDb = {
|
||||
generateApiKey,
|
||||
|
||||
/** Creates a new API key for the given user and returns it for one-time display. */
|
||||
createApiKey(userId: number, keyName: string): CreateApiKeyResult {
|
||||
const db = getConnection();
|
||||
const apiKey = generateApiKey();
|
||||
const result = db
|
||||
.prepare(
|
||||
'INSERT INTO api_keys (user_id, key_name, api_key) VALUES (?, ?, ?)'
|
||||
)
|
||||
.run(userId, keyName, apiKey);
|
||||
return { id: result.lastInsertRowid, keyName, apiKey };
|
||||
},
|
||||
|
||||
/** Lists all API keys for a user, most recent first. */
|
||||
getApiKeys(userId: number): ApiKeyRow[] {
|
||||
const db = getConnection();
|
||||
return db
|
||||
.prepare(
|
||||
'SELECT id, key_name, api_key, created_at, last_used, is_active FROM api_keys WHERE user_id = ? ORDER BY created_at DESC'
|
||||
)
|
||||
.all(userId) as ApiKeyRow[];
|
||||
},
|
||||
|
||||
/**
|
||||
* Validates an API key and resolves the owning user.
|
||||
* If the key is valid, its `last_used` timestamp is updated as a side effect.
|
||||
* Returns undefined when the key is invalid or the user is inactive.
|
||||
*/
|
||||
validateApiKey(apiKey: string): ValidatedApiKeyUser | undefined {
|
||||
const db = getConnection();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT u.id, u.username, ak.id as api_key_id
|
||||
FROM api_keys ak
|
||||
JOIN users u ON ak.user_id = u.id
|
||||
WHERE ak.api_key = ? AND ak.is_active = 1 AND u.is_active = 1`
|
||||
)
|
||||
.get(apiKey) as ValidatedApiKeyUser | undefined;
|
||||
|
||||
if (row) {
|
||||
db.prepare(
|
||||
'UPDATE api_keys SET last_used = CURRENT_TIMESTAMP WHERE id = ?'
|
||||
).run(row.api_key_id);
|
||||
}
|
||||
|
||||
return row;
|
||||
},
|
||||
|
||||
/** Permanently removes an API key. Returns true if a row was deleted. */
|
||||
deleteApiKey(userId: number, apiKeyId: number): boolean {
|
||||
const db = getConnection();
|
||||
const result = db
|
||||
.prepare('DELETE FROM api_keys WHERE id = ? AND user_id = ?')
|
||||
.run(apiKeyId, userId);
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
/** Enables or disables an API key without deleting it. */
|
||||
toggleApiKey(
|
||||
userId: number,
|
||||
apiKeyId: number,
|
||||
isActive: boolean
|
||||
): boolean {
|
||||
const db = getConnection();
|
||||
const result = db
|
||||
.prepare(
|
||||
'UPDATE api_keys SET is_active = ? WHERE id = ? AND user_id = ?'
|
||||
)
|
||||
.run(isActive ? 1 : 0, apiKeyId, userId);
|
||||
return result.changes > 0;
|
||||
},
|
||||
};
|
||||
53
server/modules/database/repositories/app-config.ts
Normal file
53
server/modules/database/repositories/app-config.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* App config repository.
|
||||
*
|
||||
* Key-value store for application-level configuration that persists
|
||||
* across restarts (JWT secret, feature flags, etc.). Values are always
|
||||
* stored as strings; callers handle parsing.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { getConnection } from '@/modules/database/connection.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const appConfigDb = {
|
||||
/** Returns the stored value for a config key, or null if missing. */
|
||||
get(key: string): string | null {
|
||||
try {
|
||||
const db = getConnection();
|
||||
const row = db
|
||||
.prepare('SELECT value FROM app_config WHERE key = ?')
|
||||
.get(key) as { value: string } | undefined;
|
||||
return row?.value ?? null;
|
||||
} catch {
|
||||
// Swallow errors so early-startup reads (e.g. JWT secret) do not crash.
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Inserts or updates a config key (upsert). */
|
||||
set(key: string, value: string): void {
|
||||
const db = getConnection();
|
||||
db.prepare(
|
||||
'INSERT INTO app_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
).run(key, value);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the JWT signing secret, generating and persisting one
|
||||
* if it does not already exist. This ensures the secret survives
|
||||
* server restarts while being created automatically on first boot.
|
||||
*/
|
||||
getOrCreateJwtSecret(): string {
|
||||
let secret = appConfigDb.get('jwt_secret');
|
||||
if (!secret) {
|
||||
secret = crypto.randomBytes(64).toString('hex');
|
||||
appConfigDb.set('jwt_secret', secret);
|
||||
}
|
||||
return secret;
|
||||
},
|
||||
};
|
||||
106
server/modules/database/repositories/credentials.ts
Normal file
106
server/modules/database/repositories/credentials.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* User credentials repository.
|
||||
*
|
||||
* Manages external service tokens (GitHub, GitLab, Bitbucket, etc.)
|
||||
* stored per-user. Each credential has a type discriminator so multiple
|
||||
* credential kinds can coexist in the same table.
|
||||
*/
|
||||
|
||||
import { getConnection } from '@/modules/database/connection.js';
|
||||
import type {
|
||||
CreateCredentialResult,
|
||||
CredentialPublicRow,
|
||||
} from '@/shared/types.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const credentialsDb = {
|
||||
/** Stores a new credential and returns a safe (no raw value) result. */
|
||||
createCredential(
|
||||
userId: number,
|
||||
credentialName: string,
|
||||
credentialType: string,
|
||||
credentialValue: string,
|
||||
description: string | null = null
|
||||
): CreateCredentialResult {
|
||||
const db = getConnection();
|
||||
const result = db
|
||||
.prepare(
|
||||
'INSERT INTO user_credentials (user_id, credential_name, credential_type, credential_value, description) VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run(userId, credentialName, credentialType, credentialValue, description);
|
||||
return {
|
||||
id: result.lastInsertRowid,
|
||||
credentialName,
|
||||
credentialType,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Lists credentials for a user (excluding raw values).
|
||||
* Optionally filters by credential type (e.g. 'github_token').
|
||||
*/
|
||||
getCredentials(
|
||||
userId: number,
|
||||
credentialType: string | null = null
|
||||
): CredentialPublicRow[] {
|
||||
const db = getConnection();
|
||||
|
||||
if (credentialType) {
|
||||
return db
|
||||
.prepare(
|
||||
'SELECT id, credential_name, credential_type, description, created_at, is_active FROM user_credentials WHERE user_id = ? AND credential_type = ? ORDER BY created_at DESC'
|
||||
)
|
||||
.all(userId, credentialType) as CredentialPublicRow[];
|
||||
}
|
||||
|
||||
return db
|
||||
.prepare(
|
||||
'SELECT id, credential_name, credential_type, description, created_at, is_active FROM user_credentials WHERE user_id = ? ORDER BY created_at DESC'
|
||||
)
|
||||
.all(userId) as CredentialPublicRow[];
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the raw credential value for the most recent active
|
||||
* credential of the given type, or null if none exists.
|
||||
*/
|
||||
getActiveCredential(
|
||||
userId: number,
|
||||
credentialType: string
|
||||
): string | null {
|
||||
const db = getConnection();
|
||||
const row = db
|
||||
.prepare(
|
||||
'SELECT credential_value FROM user_credentials WHERE user_id = ? AND credential_type = ? AND is_active = 1 ORDER BY created_at DESC LIMIT 1'
|
||||
)
|
||||
.get(userId, credentialType) as { credential_value: string } | undefined;
|
||||
return row?.credential_value ?? null;
|
||||
},
|
||||
|
||||
/** Permanently removes a credential. Returns true if a row was deleted. */
|
||||
deleteCredential(userId: number, credentialId: number): boolean {
|
||||
const db = getConnection();
|
||||
const result = db
|
||||
.prepare('DELETE FROM user_credentials WHERE id = ? AND user_id = ?')
|
||||
.run(credentialId, userId);
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
/** Enables or disables a credential without deleting it. */
|
||||
toggleCredential(
|
||||
userId: number,
|
||||
credentialId: number,
|
||||
isActive: boolean
|
||||
): boolean {
|
||||
const db = getConnection();
|
||||
const result = db
|
||||
.prepare(
|
||||
'UPDATE user_credentials SET is_active = ? WHERE id = ? AND user_id = ?'
|
||||
)
|
||||
.run(isActive ? 1 : 0, credentialId, userId);
|
||||
return result.changes > 0;
|
||||
},
|
||||
};
|
||||
100
server/modules/database/repositories/github-tokens.ts
Normal file
100
server/modules/database/repositories/github-tokens.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* GitHub tokens repository.
|
||||
*
|
||||
* Backward-compatible helper layer over generic credentials storage.
|
||||
* Tokens are stored in `user_credentials` with `credential_type = 'github_token'`.
|
||||
*/
|
||||
|
||||
import { getConnection } from '@/modules/database/connection.js';
|
||||
import { credentialsDb } from '@/modules/database/repositories/credentials.js';
|
||||
import type {
|
||||
CredentialPublicRow,
|
||||
CreateCredentialResult,
|
||||
} from '@/shared/types.js';
|
||||
|
||||
const GITHUB_TOKEN_TYPE = 'github_token';
|
||||
|
||||
type CredentialRow = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
credential_name: string;
|
||||
credential_type: string;
|
||||
credential_value: string;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
is_active: number;
|
||||
};
|
||||
|
||||
type GithubTokenLookup = CredentialRow & {
|
||||
github_token: string;
|
||||
};
|
||||
|
||||
export const githubTokensDb = {
|
||||
/** Creates a GitHub token credential entry. */
|
||||
createGithubToken(
|
||||
userId: number,
|
||||
tokenName: string,
|
||||
githubToken: string,
|
||||
description: string | null = null
|
||||
): CreateCredentialResult {
|
||||
return credentialsDb.createCredential(
|
||||
userId,
|
||||
tokenName,
|
||||
GITHUB_TOKEN_TYPE,
|
||||
githubToken,
|
||||
description
|
||||
);
|
||||
},
|
||||
|
||||
/** Returns all GitHub tokens (safe shape: no credential value). */
|
||||
getGithubTokens(userId: number): CredentialPublicRow[] {
|
||||
return credentialsDb.getCredentials(userId, GITHUB_TOKEN_TYPE);
|
||||
},
|
||||
|
||||
/** Returns the most recent active GitHub token value for a user. */
|
||||
getActiveGithubToken(userId: number): string | null {
|
||||
return credentialsDb.getActiveCredential(userId, GITHUB_TOKEN_TYPE);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns a specific active GitHub token row by id/user, including
|
||||
* a `github_token` compatibility field.
|
||||
*/
|
||||
getGithubTokenById(userId: number, tokenId: number): GithubTokenLookup | null {
|
||||
const db = getConnection();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT *
|
||||
FROM user_credentials
|
||||
WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1`
|
||||
)
|
||||
.get(tokenId, userId, GITHUB_TOKEN_TYPE) as CredentialRow | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
...row,
|
||||
github_token: row.credential_value,
|
||||
};
|
||||
},
|
||||
|
||||
/** Updates active state for a GitHub token. */
|
||||
updateGithubToken(
|
||||
userId: number,
|
||||
tokenId: number,
|
||||
isActive: boolean
|
||||
): boolean {
|
||||
return credentialsDb.toggleCredential(userId, tokenId, isActive);
|
||||
},
|
||||
|
||||
/** Deletes a GitHub token. */
|
||||
deleteGithubToken(userId: number, tokenId: number): boolean {
|
||||
return credentialsDb.deleteCredential(userId, tokenId);
|
||||
},
|
||||
|
||||
// Legacy alias used by existing routes
|
||||
toggleGithubToken(userId: number, tokenId: number, isActive: boolean): boolean {
|
||||
return githubTokensDb.updateGithubToken(userId, tokenId, isActive);
|
||||
},
|
||||
};
|
||||
|
||||
103
server/modules/database/repositories/notification-preferences.ts
Normal file
103
server/modules/database/repositories/notification-preferences.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Notification preferences repository.
|
||||
*
|
||||
* Stores per-user notification channel/event preferences as JSON.
|
||||
*/
|
||||
|
||||
import { getConnection } from '@/modules/database/connection.js';
|
||||
|
||||
type NotificationPreferences = {
|
||||
channels: {
|
||||
inApp: boolean;
|
||||
webPush: boolean;
|
||||
};
|
||||
events: {
|
||||
actionRequired: boolean;
|
||||
stop: boolean;
|
||||
error: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
|
||||
channels: {
|
||||
inApp: false,
|
||||
webPush: false,
|
||||
},
|
||||
events: {
|
||||
actionRequired: true,
|
||||
stop: true,
|
||||
error: true,
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeNotificationPreferences(value: unknown): NotificationPreferences {
|
||||
const source = value && typeof value === 'object' ? (value as Record<string, any>) : {};
|
||||
|
||||
return {
|
||||
channels: {
|
||||
inApp: source.channels?.inApp === true,
|
||||
webPush: source.channels?.webPush === true,
|
||||
},
|
||||
events: {
|
||||
actionRequired: source.events?.actionRequired !== false,
|
||||
stop: source.events?.stop !== false,
|
||||
error: source.events?.error !== false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const notificationPreferencesDb = {
|
||||
/** Returns the normalized preferences for a user, creating defaults on first read. */
|
||||
getNotificationPreferences(userId: number): NotificationPreferences {
|
||||
const db = getConnection();
|
||||
const row = db
|
||||
.prepare(
|
||||
'SELECT preferences_json FROM user_notification_preferences WHERE user_id = ?'
|
||||
)
|
||||
.get(userId) as { preferences_json: string } | undefined;
|
||||
|
||||
if (!row) {
|
||||
const defaults = normalizeNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES);
|
||||
db.prepare(
|
||||
'INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)'
|
||||
).run(userId, JSON.stringify(defaults));
|
||||
return defaults;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(row.preferences_json);
|
||||
} catch {
|
||||
parsed = DEFAULT_NOTIFICATION_PREFERENCES;
|
||||
}
|
||||
return normalizeNotificationPreferences(parsed);
|
||||
},
|
||||
|
||||
/** Upserts normalized preferences for a user and returns the stored value. */
|
||||
updateNotificationPreferences(
|
||||
userId: number,
|
||||
preferences: unknown
|
||||
): NotificationPreferences {
|
||||
const normalized = normalizeNotificationPreferences(preferences);
|
||||
const db = getConnection();
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
preferences_json = excluded.preferences_json,
|
||||
updated_at = CURRENT_TIMESTAMP`
|
||||
).run(userId, JSON.stringify(normalized));
|
||||
|
||||
return normalized;
|
||||
},
|
||||
|
||||
// Legacy aliases used by existing services/routes
|
||||
getPreferences(userId: number): NotificationPreferences {
|
||||
return notificationPreferencesDb.getNotificationPreferences(userId);
|
||||
},
|
||||
updatePreferences(userId: number, preferences: unknown): NotificationPreferences {
|
||||
return notificationPreferencesDb.updateNotificationPreferences(userId, preferences);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { closeConnection } from '@/modules/database/connection.js';
|
||||
import { initializeDatabase } from '@/modules/database/init-db.js';
|
||||
import { projectsDb } from '@/modules/database/repositories/projects.db.js';
|
||||
|
||||
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
|
||||
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||
const tempDirectory = await mkdtemp(path.join(tmpdir(), 'projects-db-'));
|
||||
const databasePath = path.join(tempDirectory, 'auth.db');
|
||||
|
||||
closeConnection();
|
||||
process.env.DATABASE_PATH = databasePath;
|
||||
await initializeDatabase();
|
||||
|
||||
try {
|
||||
await runTest();
|
||||
} finally {
|
||||
closeConnection();
|
||||
if (previousDatabasePath === undefined) {
|
||||
delete process.env.DATABASE_PATH;
|
||||
} else {
|
||||
process.env.DATABASE_PATH = previousDatabasePath;
|
||||
}
|
||||
await rm(tempDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('projectsDb.createProjectPath returns created for fresh paths', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
const created = projectsDb.createProjectPath('/workspace/new-project');
|
||||
|
||||
assert.equal(created.outcome, 'created');
|
||||
assert.ok(created.project);
|
||||
assert.equal(created.project?.project_path, '/workspace/new-project');
|
||||
assert.equal(created.project?.isArchived, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('projectsDb.createProjectPath returns reactivated_archived for archived duplicates', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
const initial = projectsDb.createProjectPath('/workspace/archived-project', 'Archived Project');
|
||||
assert.equal(initial.outcome, 'created');
|
||||
assert.ok(initial.project);
|
||||
|
||||
projectsDb.updateProjectIsArchived('/workspace/archived-project', true);
|
||||
|
||||
const reused = projectsDb.createProjectPath('/workspace/archived-project', 'Renamed Project');
|
||||
assert.equal(reused.outcome, 'reactivated_archived');
|
||||
assert.ok(reused.project);
|
||||
assert.equal(reused.project?.project_id, initial.project?.project_id);
|
||||
assert.equal(reused.project?.isArchived, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('projectsDb.createProjectPath returns active_conflict for active duplicates', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
const initial = projectsDb.createProjectPath('/workspace/active-project');
|
||||
assert.equal(initial.outcome, 'created');
|
||||
assert.ok(initial.project);
|
||||
|
||||
const conflict = projectsDb.createProjectPath('/workspace/active-project');
|
||||
assert.equal(conflict.outcome, 'active_conflict');
|
||||
assert.ok(conflict.project);
|
||||
assert.equal(conflict.project?.project_id, initial.project?.project_id);
|
||||
assert.equal(conflict.project?.isArchived, 0);
|
||||
});
|
||||
});
|
||||
183
server/modules/database/repositories/projects.db.ts
Normal file
183
server/modules/database/repositories/projects.db.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
|
||||
import { getConnection } from '@/modules/database/connection.js';
|
||||
import type { CreateProjectPathResult, ProjectRepositoryRow } from '@/shared/types.js';
|
||||
import { normalizeProjectPath } from '@/shared/utils.js';
|
||||
|
||||
function normalizeProjectDisplayName(projectPath: string, customProjectName: string | null): string {
|
||||
const trimmedCustomName = typeof customProjectName === 'string' ? customProjectName.trim() : '';
|
||||
if (trimmedCustomName.length > 0) {
|
||||
return trimmedCustomName;
|
||||
}
|
||||
|
||||
const directoryName = path.basename(projectPath);
|
||||
return directoryName || projectPath;
|
||||
}
|
||||
|
||||
export const projectsDb = {
|
||||
createProjectPath(projectPath: string, customProjectName: string | null = null): CreateProjectPathResult {
|
||||
const db = getConnection();
|
||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||
const normalizedProjectName = normalizeProjectDisplayName(normalizedProjectPath, customProjectName);
|
||||
const attemptedId = randomUUID();
|
||||
const row = db.prepare(`
|
||||
INSERT INTO projects (project_id, project_path, custom_project_name, isArchived)
|
||||
VALUES (?, ?, ?, 0)
|
||||
ON CONFLICT(project_path) DO UPDATE SET
|
||||
isArchived = 0
|
||||
WHERE projects.isArchived = 1
|
||||
RETURNING project_id, project_path, custom_project_name, isStarred, isArchived
|
||||
`).get(attemptedId, normalizedProjectPath, normalizedProjectName) as ProjectRepositoryRow | undefined;
|
||||
|
||||
if (row) {
|
||||
return {
|
||||
outcome: row.project_id === attemptedId ? 'created' : 'reactivated_archived',
|
||||
project: row,
|
||||
};
|
||||
}
|
||||
|
||||
const existingProject = projectsDb.getProjectPath(normalizedProjectPath);
|
||||
return {
|
||||
outcome: 'active_conflict',
|
||||
project: existingProject,
|
||||
};
|
||||
},
|
||||
|
||||
getProjectPath(projectPath: string): ProjectRepositoryRow | null {
|
||||
const db = getConnection();
|
||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||
const row = db.prepare(`
|
||||
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
|
||||
FROM projects
|
||||
WHERE project_path = ?
|
||||
`).get(normalizedProjectPath) as ProjectRepositoryRow | undefined;
|
||||
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
getProjectById(projectId: string): ProjectRepositoryRow | null {
|
||||
const db = getConnection();
|
||||
const row = db.prepare(`
|
||||
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
|
||||
FROM projects
|
||||
WHERE project_id = ?
|
||||
`).get(projectId) as ProjectRepositoryRow | undefined;
|
||||
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve the absolute project directory from a database project_id.
|
||||
*
|
||||
* This is the canonical lookup used after the projectName → projectId migration:
|
||||
* API routes receive the DB-assigned `projectId` and must resolve the real folder
|
||||
* path through this helper before touching the filesystem. Returns `null` when the
|
||||
* project row does not exist so callers can respond with a 404.
|
||||
*/
|
||||
getProjectPathById(projectId: string): string | null {
|
||||
const db = getConnection();
|
||||
const row = db.prepare(`
|
||||
SELECT project_path
|
||||
FROM projects
|
||||
WHERE project_id = ?
|
||||
`).get(projectId) as Pick<ProjectRepositoryRow, 'project_path'> | undefined;
|
||||
|
||||
return row?.project_path ?? null;
|
||||
},
|
||||
|
||||
getProjectPaths(): ProjectRepositoryRow[] {
|
||||
const db = getConnection();
|
||||
return db.prepare(`
|
||||
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
|
||||
FROM projects
|
||||
WHERE isArchived = 0
|
||||
`).all() as ProjectRepositoryRow[];
|
||||
},
|
||||
|
||||
getCustomProjectName(projectPath: string): string | null {
|
||||
const db = getConnection();
|
||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||
const row = db.prepare(`
|
||||
SELECT custom_project_name
|
||||
FROM projects
|
||||
WHERE project_path = ?
|
||||
`).get(normalizedProjectPath) as Pick<ProjectRepositoryRow, 'custom_project_name'> | undefined;
|
||||
|
||||
return row?.custom_project_name ?? null;
|
||||
},
|
||||
|
||||
updateCustomProjectName(projectPath: string, customProjectName: string | null): void {
|
||||
const db = getConnection();
|
||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||
db.prepare(`
|
||||
INSERT INTO projects (project_id, project_path, custom_project_name)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(project_path) DO UPDATE SET custom_project_name = excluded.custom_project_name
|
||||
`).run(randomUUID(), normalizedProjectPath, customProjectName);
|
||||
},
|
||||
|
||||
updateCustomProjectNameById(projectId: string, customProjectName: string | null): void {
|
||||
const db = getConnection();
|
||||
db.prepare(`
|
||||
UPDATE projects
|
||||
SET custom_project_name = ?
|
||||
WHERE project_id = ?
|
||||
`).run(customProjectName, projectId);
|
||||
},
|
||||
|
||||
updateProjectIsStarred(projectPath: string, isStarred: boolean): void {
|
||||
const db = getConnection();
|
||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||
db.prepare(`
|
||||
UPDATE projects
|
||||
SET isStarred = ?
|
||||
WHERE project_path = ?
|
||||
`).run(isStarred ? 1 : 0, normalizedProjectPath);
|
||||
},
|
||||
|
||||
updateProjectIsStarredById(projectId: string, isStarred: boolean): void {
|
||||
const db = getConnection();
|
||||
db.prepare(`
|
||||
UPDATE projects
|
||||
SET isStarred = ?
|
||||
WHERE project_id = ?
|
||||
`).run(isStarred ? 1 : 0, projectId);
|
||||
},
|
||||
|
||||
updateProjectIsArchived(projectPath: string, isArchived: boolean): void {
|
||||
const db = getConnection();
|
||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||
db.prepare(`
|
||||
UPDATE projects
|
||||
SET isArchived = ?
|
||||
WHERE project_path = ?
|
||||
`).run(isArchived ? 1 : 0, normalizedProjectPath);
|
||||
},
|
||||
|
||||
updateProjectIsArchivedById(projectId: string, isArchived: boolean): void {
|
||||
const db = getConnection();
|
||||
db.prepare(`
|
||||
UPDATE projects
|
||||
SET isArchived = ?
|
||||
WHERE project_id = ?
|
||||
`).run(isArchived ? 1 : 0, projectId);
|
||||
},
|
||||
|
||||
deleteProjectPath(projectPath: string): void {
|
||||
const db = getConnection();
|
||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||
db.prepare(`
|
||||
DELETE FROM projects
|
||||
WHERE project_path = ?
|
||||
`).run(normalizedProjectPath);
|
||||
},
|
||||
|
||||
deleteProjectById(projectId: string): void {
|
||||
const db = getConnection();
|
||||
db.prepare(`
|
||||
DELETE FROM projects
|
||||
WHERE project_id = ?
|
||||
`).run(projectId);
|
||||
},
|
||||
};
|
||||
80
server/modules/database/repositories/push-subscriptions.ts
Normal file
80
server/modules/database/repositories/push-subscriptions.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Push subscriptions repository.
|
||||
*
|
||||
* Persists browser push subscription endpoints and keys per user.
|
||||
*/
|
||||
|
||||
import { getConnection } from '@/modules/database/connection.js';
|
||||
|
||||
type PushSubscriptionLookupRow = {
|
||||
endpoint: string;
|
||||
keys_p256dh: string;
|
||||
keys_auth: string;
|
||||
};
|
||||
|
||||
export const pushSubscriptionsDb = {
|
||||
/** Upserts a push subscription endpoint for a user. */
|
||||
createPushSubscription(
|
||||
userId: number,
|
||||
endpoint: string,
|
||||
keysP256dh: string,
|
||||
keysAuth: string
|
||||
): void {
|
||||
const db = getConnection();
|
||||
db.prepare(
|
||||
`INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
keys_p256dh = excluded.keys_p256dh,
|
||||
keys_auth = excluded.keys_auth`
|
||||
).run(userId, endpoint, keysP256dh, keysAuth);
|
||||
},
|
||||
|
||||
/** Returns all subscriptions for a user. */
|
||||
getPushSubscriptions(userId: number): PushSubscriptionLookupRow[] {
|
||||
const db = getConnection();
|
||||
return db
|
||||
.prepare(
|
||||
'SELECT endpoint, keys_p256dh, keys_auth FROM push_subscriptions WHERE user_id = ?'
|
||||
)
|
||||
.all(userId) as PushSubscriptionLookupRow[];
|
||||
},
|
||||
|
||||
/** Deletes one subscription by endpoint. */
|
||||
deletePushSubscription(endpoint: string): void {
|
||||
const db = getConnection();
|
||||
db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint);
|
||||
},
|
||||
|
||||
/** Deletes all subscriptions for a user. */
|
||||
deletePushSubscriptionsForUser(userId: number): void {
|
||||
const db = getConnection();
|
||||
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId);
|
||||
},
|
||||
|
||||
// Legacy aliases used by existing services/routes
|
||||
saveSubscription(
|
||||
userId: number,
|
||||
endpoint: string,
|
||||
keysP256dh: string,
|
||||
keysAuth: string
|
||||
): void {
|
||||
pushSubscriptionsDb.createPushSubscription(
|
||||
userId,
|
||||
endpoint,
|
||||
keysP256dh,
|
||||
keysAuth
|
||||
);
|
||||
},
|
||||
getSubscriptions(userId: number): PushSubscriptionLookupRow[] {
|
||||
return pushSubscriptionsDb.getPushSubscriptions(userId);
|
||||
},
|
||||
removeSubscription(endpoint: string): void {
|
||||
pushSubscriptionsDb.deletePushSubscription(endpoint);
|
||||
},
|
||||
removeAllForUser(userId: number): void {
|
||||
pushSubscriptionsDb.deletePushSubscriptionsForUser(userId);
|
||||
},
|
||||
};
|
||||
|
||||
42
server/modules/database/repositories/scan-state.db.ts
Normal file
42
server/modules/database/repositories/scan-state.db.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { getConnection } from '@/modules/database/connection.js';
|
||||
|
||||
type ScanStateRow = {
|
||||
last_scanned_at: string;
|
||||
};
|
||||
|
||||
export const scanStateDb = {
|
||||
getLastScannedAt() {
|
||||
const db = getConnection();
|
||||
|
||||
const row = db
|
||||
.prepare(`SELECT last_scanned_at FROM scan_state WHERE id = 1`)
|
||||
.get() as ScanStateRow;
|
||||
|
||||
if (!row) {
|
||||
return null; // Before any scan, the row is undefined.
|
||||
}
|
||||
|
||||
let lastScannedDate: Date | null = null;
|
||||
const lastScannedStr = row.last_scanned_at;
|
||||
|
||||
if (lastScannedStr) {
|
||||
// SQLite CURRENT_TIMESTAMP returns UTC in "YYYY-MM-DD HH:MM:SS" format.
|
||||
// Replace space with 'T' and append 'Z' to parse reliably in JS across all platforms.
|
||||
lastScannedDate = new Date(lastScannedStr.replace(' ', 'T') + 'Z');
|
||||
}
|
||||
|
||||
return lastScannedDate;
|
||||
},
|
||||
|
||||
updateLastScannedAt(scannedAt: Date = new Date()) {
|
||||
const db = getConnection();
|
||||
const sqliteTimestamp = scannedAt.toISOString().slice(0, 19).replace('T', ' ');
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO scan_state (id, last_scanned_at)
|
||||
VALUES (1, ?)
|
||||
ON CONFLICT (id)
|
||||
DO UPDATE SET last_scanned_at = excluded.last_scanned_at
|
||||
`).run(sqliteTimestamp);
|
||||
}
|
||||
};
|
||||
174
server/modules/database/repositories/sessions.db.ts
Normal file
174
server/modules/database/repositories/sessions.db.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { getConnection } from '@/modules/database/connection.js';
|
||||
import { projectsDb } from '@/modules/database/repositories/projects.db.js';
|
||||
import { normalizeProjectPath } from '@/shared/utils.js';
|
||||
|
||||
type SessionRow = {
|
||||
session_id: string;
|
||||
provider: string;
|
||||
project_path: string | null;
|
||||
jsonl_path: string | null;
|
||||
custom_name: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type SessionMetadataLookupRow = Pick<
|
||||
SessionRow,
|
||||
'session_id' | 'provider' | 'project_path' | 'jsonl_path' | 'custom_name' | 'created_at' | 'updated_at'
|
||||
>;
|
||||
|
||||
function normalizeTimestamp(value?: string): string | null {
|
||||
if (!value) return null;
|
||||
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
function normalizeProjectPathForProvider(provider: string, projectPath: string): string {
|
||||
void provider;
|
||||
return normalizeProjectPath(projectPath);
|
||||
}
|
||||
|
||||
export const sessionsDb = {
|
||||
createSession(
|
||||
sessionId: string,
|
||||
provider: string,
|
||||
projectPath: string,
|
||||
customName?: string,
|
||||
createdAt?: string,
|
||||
updatedAt?: string,
|
||||
jsonlPath?: string | null
|
||||
): string {
|
||||
const db = getConnection();
|
||||
const createdAtValue = normalizeTimestamp(createdAt);
|
||||
const updatedAtValue = normalizeTimestamp(updatedAt);
|
||||
const normalizedProjectPath = normalizeProjectPathForProvider(provider, projectPath);
|
||||
|
||||
// First, ensure the project path is recorded in the projects table,
|
||||
// since it's a foreign key in the sessions table.
|
||||
projectsDb.createProjectPath(normalizedProjectPath);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (session_id, provider, custom_name, project_path, jsonl_path, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP))
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
provider = excluded.provider,
|
||||
updated_at = excluded.updated_at,
|
||||
project_path = excluded.project_path,
|
||||
jsonl_path = excluded.jsonl_path,
|
||||
custom_name = COALESCE(excluded.custom_name, sessions.custom_name)`
|
||||
).run(
|
||||
sessionId,
|
||||
provider,
|
||||
customName ?? null,
|
||||
normalizedProjectPath,
|
||||
jsonlPath ?? null,
|
||||
createdAtValue,
|
||||
updatedAtValue
|
||||
);
|
||||
|
||||
return sessionId;
|
||||
},
|
||||
|
||||
updateSessionCustomName(sessionId: string, customName: string): void {
|
||||
const db = getConnection();
|
||||
db.prepare(
|
||||
`UPDATE sessions
|
||||
SET custom_name = ?
|
||||
WHERE session_id = ?`
|
||||
).run(customName, sessionId);
|
||||
},
|
||||
|
||||
getSessionById(sessionId: string): SessionMetadataLookupRow | null {
|
||||
const db = getConnection();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at
|
||||
FROM sessions
|
||||
WHERE session_id = ?
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(sessionId) as SessionMetadataLookupRow | undefined;
|
||||
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
getAllSessions(): SessionRow[] {
|
||||
const db = getConnection();
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at
|
||||
FROM sessions`
|
||||
)
|
||||
.all() as SessionRow[];
|
||||
},
|
||||
|
||||
getSessionsByProjectPath(projectPath: string): SessionRow[] {
|
||||
const db = getConnection();
|
||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at
|
||||
FROM sessions
|
||||
WHERE project_path = ?`
|
||||
)
|
||||
.all(normalizedProjectPath) as SessionRow[];
|
||||
},
|
||||
|
||||
getSessionsByProjectPathPage(projectPath: string, limit: number, offset: number): SessionRow[] {
|
||||
const db = getConnection();
|
||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at
|
||||
FROM sessions
|
||||
WHERE project_path = ?
|
||||
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(normalizedProjectPath, limit, offset) as SessionRow[];
|
||||
},
|
||||
|
||||
countSessionsByProjectPath(projectPath: string): number {
|
||||
const db = getConnection();
|
||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM sessions
|
||||
WHERE project_path = ?`
|
||||
)
|
||||
.get(normalizedProjectPath) as { count: number } | undefined;
|
||||
|
||||
return Number(row?.count ?? 0);
|
||||
},
|
||||
|
||||
deleteSessionsByProjectPath(projectPath: string): void {
|
||||
const db = getConnection();
|
||||
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
||||
db.prepare(`DELETE FROM sessions WHERE project_path = ?`).run(normalizedProjectPath);
|
||||
},
|
||||
|
||||
getSessionName(sessionId: string, provider: string): string | null {
|
||||
const db = getConnection();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT custom_name
|
||||
FROM sessions
|
||||
WHERE session_id = ? AND provider = ?`
|
||||
)
|
||||
.get(sessionId, provider) as { custom_name: string | null } | undefined;
|
||||
|
||||
return row?.custom_name ?? null;
|
||||
},
|
||||
|
||||
deleteSessionById(sessionId: string): boolean {
|
||||
const db = getConnection();
|
||||
return db.prepare('DELETE FROM sessions WHERE session_id = ?').run(sessionId).changes > 0;
|
||||
},
|
||||
};
|
||||
140
server/modules/database/repositories/users.ts
Normal file
140
server/modules/database/repositories/users.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* User repository.
|
||||
*
|
||||
* Provides typed CRUD operations for the `users` table.
|
||||
* This is a single-user system, but the schema supports multiple
|
||||
* users for forward compatibility.
|
||||
*/
|
||||
|
||||
import { getConnection } from '@/modules/database/connection.js';
|
||||
|
||||
type UserRow = {
|
||||
id: number;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
created_at: string;
|
||||
last_login: string | null;
|
||||
is_active: number;
|
||||
git_name: string | null;
|
||||
git_email: string | null;
|
||||
has_completed_onboarding: number;
|
||||
};
|
||||
|
||||
type UserPublicRow = Pick<UserRow, 'id' | 'username' | 'created_at' | 'last_login'>;
|
||||
|
||||
type UserGitConfig = {
|
||||
git_name: string | null;
|
||||
git_email: string | null;
|
||||
};
|
||||
|
||||
type CreateUserResult = {
|
||||
id: number | bigint;
|
||||
username: string;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const userDb = {
|
||||
/** Returns true if at least one user exists in the database. */
|
||||
hasUsers(): boolean {
|
||||
const db = getConnection();
|
||||
const row = db.prepare('SELECT COUNT(*) as count FROM users').get() as {
|
||||
count: number;
|
||||
};
|
||||
return row.count > 0;
|
||||
},
|
||||
|
||||
/** Inserts a new user and returns the created ID + username. */
|
||||
createUser(username: string, passwordHash: string): CreateUserResult {
|
||||
const db = getConnection();
|
||||
const result = db
|
||||
.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)')
|
||||
.run(username, passwordHash);
|
||||
return { id: result.lastInsertRowid, username };
|
||||
},
|
||||
|
||||
/**
|
||||
* Looks up an active user by username.
|
||||
* Returns the full row (including password hash) for auth verification.
|
||||
*/
|
||||
getUserByUsername(username: string): UserRow | undefined {
|
||||
const db = getConnection();
|
||||
return db
|
||||
.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1')
|
||||
.get(username) as UserRow | undefined;
|
||||
},
|
||||
|
||||
/** Updates the last_login timestamp. Non-fatal — logs but does not throw. */
|
||||
updateLastLogin(userId: number): void {
|
||||
try {
|
||||
const db = getConnection();
|
||||
db.prepare(
|
||||
'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?'
|
||||
).run(userId);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error('Failed to update last login', { error: message });
|
||||
}
|
||||
},
|
||||
|
||||
/** Returns public user fields by ID (no password hash). */
|
||||
getUserById(userId: number): UserPublicRow | undefined {
|
||||
const db = getConnection();
|
||||
return db
|
||||
.prepare(
|
||||
'SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1'
|
||||
)
|
||||
.get(userId) as UserPublicRow | undefined;
|
||||
},
|
||||
|
||||
/** Returns the first active user. Used for single-user mode lookups. */
|
||||
getFirstUser(): UserPublicRow | undefined {
|
||||
const db = getConnection();
|
||||
return db
|
||||
.prepare(
|
||||
'SELECT id, username, created_at, last_login FROM users WHERE is_active = 1 LIMIT 1'
|
||||
)
|
||||
.get() as UserPublicRow | undefined;
|
||||
},
|
||||
|
||||
/** Stores the user's preferred git name and email. */
|
||||
updateGitConfig(
|
||||
userId: number,
|
||||
gitName: string,
|
||||
gitEmail: string
|
||||
): void {
|
||||
const db = getConnection();
|
||||
db.prepare('UPDATE users SET git_name = ?, git_email = ? WHERE id = ?').run(
|
||||
gitName,
|
||||
gitEmail,
|
||||
userId
|
||||
);
|
||||
},
|
||||
|
||||
/** Retrieves the user's git identity (name + email). */
|
||||
getGitConfig(userId: number): UserGitConfig | undefined {
|
||||
const db = getConnection();
|
||||
return db
|
||||
.prepare('SELECT git_name, git_email FROM users WHERE id = ?')
|
||||
.get(userId) as UserGitConfig | undefined;
|
||||
},
|
||||
|
||||
/** Marks onboarding as complete for the given user. */
|
||||
completeOnboarding(userId: number): void {
|
||||
const db = getConnection();
|
||||
db.prepare(
|
||||
'UPDATE users SET has_completed_onboarding = 1 WHERE id = ?'
|
||||
).run(userId);
|
||||
},
|
||||
|
||||
/** Returns true if the user has finished the onboarding flow. */
|
||||
hasCompletedOnboarding(userId: number): boolean {
|
||||
const db = getConnection();
|
||||
const row = db
|
||||
.prepare('SELECT has_completed_onboarding FROM users WHERE id = ?')
|
||||
.get(userId) as { has_completed_onboarding: number } | undefined;
|
||||
return row?.has_completed_onboarding === 1;
|
||||
},
|
||||
};
|
||||
57
server/modules/database/repositories/vapid-keys.ts
Normal file
57
server/modules/database/repositories/vapid-keys.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* VAPID keys repository.
|
||||
*
|
||||
* Stores and retrieves the Web Push VAPID key pair.
|
||||
*/
|
||||
|
||||
import { getConnection } from '@/modules/database/connection.js';
|
||||
|
||||
type VapidKeyRow = {
|
||||
public_key: string;
|
||||
private_key: string;
|
||||
};
|
||||
|
||||
type VapidKeyPair = {
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
};
|
||||
|
||||
export const vapidKeysDb = {
|
||||
/** Returns the latest stored VAPID key pair, or null when unset. */
|
||||
getVapidKeys(): VapidKeyPair | null {
|
||||
const db = getConnection();
|
||||
const row = db
|
||||
.prepare(
|
||||
'SELECT public_key, private_key FROM vapid_keys ORDER BY id DESC LIMIT 1'
|
||||
)
|
||||
.get() as Pick<VapidKeyRow, 'public_key' | 'private_key'> | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
return {
|
||||
publicKey: row.public_key,
|
||||
privateKey: row.private_key,
|
||||
};
|
||||
},
|
||||
|
||||
/** Persists a new VAPID key pair. */
|
||||
createVapidKeys(publicKey: string, privateKey: string): void {
|
||||
const db = getConnection();
|
||||
db.prepare(
|
||||
'INSERT INTO vapid_keys (public_key, private_key) VALUES (?, ?)'
|
||||
).run(publicKey, privateKey);
|
||||
},
|
||||
|
||||
/** Replaces all existing keys with a fresh pair. */
|
||||
updateVapidKeys(publicKey: string, privateKey: string): void {
|
||||
const db = getConnection();
|
||||
db.prepare('DELETE FROM vapid_keys').run();
|
||||
vapidKeysDb.createVapidKeys(publicKey, privateKey);
|
||||
},
|
||||
|
||||
/** Deletes all VAPID key rows. */
|
||||
deleteVapidKeys(): void {
|
||||
const db = getConnection();
|
||||
db.prepare('DELETE FROM vapid_keys').run();
|
||||
},
|
||||
};
|
||||
|
||||
152
server/modules/database/schema.ts
Normal file
152
server/modules/database/schema.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
const USER_TABLE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login DATETIME,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
git_name TEXT,
|
||||
git_email TEXT,
|
||||
has_completed_onboarding BOOLEAN DEFAULT 0
|
||||
);
|
||||
`;
|
||||
|
||||
export const API_KEYS_TABLE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
key_name TEXT NOT NULL,
|
||||
api_key TEXT UNIQUE NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used DATETIME,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`;
|
||||
|
||||
export const USER_CREDENTIALS_TABLE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS user_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
credential_name TEXT NOT NULL,
|
||||
credential_type TEXT NOT NULL, -- 'github_token', 'gitlab_token', 'bitbucket_token', etc.
|
||||
credential_value TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`;
|
||||
|
||||
export const USER_NOTIFICATION_PREFERENCES_TABLE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
preferences_json TEXT NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`;
|
||||
|
||||
export const VAPID_KEYS_TABLE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS vapid_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`;
|
||||
|
||||
export const PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
keys_p256dh TEXT NOT NULL,
|
||||
keys_auth TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`;
|
||||
|
||||
export const PROJECTS_TABLE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
project_id TEXT PRIMARY KEY NOT NULL,
|
||||
project_path TEXT NOT NULL UNIQUE,
|
||||
custom_project_name TEXT DEFAULT NULL,
|
||||
isStarred BOOLEAN DEFAULT 0,
|
||||
isArchived BOOLEAN DEFAULT 0
|
||||
);
|
||||
`;
|
||||
|
||||
export const SESSIONS_TABLE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL DEFAULT 'claude',
|
||||
custom_name TEXT,
|
||||
project_path TEXT,
|
||||
jsonl_path TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (session_id),
|
||||
FOREIGN KEY (project_path) REFERENCES projects(project_path)
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE
|
||||
);
|
||||
`;
|
||||
|
||||
export const LAST_SCANNED_AT_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS scan_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
last_scanned_at TIMESTAMP NULL
|
||||
);
|
||||
`;
|
||||
|
||||
export const APP_CONFIG_TABLE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS app_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`;
|
||||
|
||||
export const INIT_SCHEMA_SQL = `
|
||||
-- Initialize authentication database
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
${USER_TABLE_SCHEMA_SQL}
|
||||
-- Indexes for performance for user lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
|
||||
|
||||
${API_KEYS_TABLE_SCHEMA_SQL}
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(api_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active);
|
||||
|
||||
${USER_CREDENTIALS_TABLE_SCHEMA_SQL}
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
|
||||
|
||||
${USER_NOTIFICATION_PREFERENCES_TABLE_SCHEMA_SQL}
|
||||
CREATE INDEX IF NOT EXISTS idx_user_notification_preferences_user_id ON user_notification_preferences(user_id);
|
||||
|
||||
${VAPID_KEYS_TABLE_SCHEMA_SQL}
|
||||
|
||||
${PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL}
|
||||
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id);
|
||||
|
||||
${PROJECTS_TABLE_SCHEMA_SQL}
|
||||
-- NOTE: These indexes are created in migrations after legacy table-shape repairs.
|
||||
-- Creating them here can fail on upgraded installs where projects lacks those columns.
|
||||
|
||||
${SESSIONS_TABLE_SCHEMA_SQL}
|
||||
CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id);
|
||||
-- NOTE: This index is created in migrations after sessions is rebuilt to include project_path.
|
||||
-- Creating it here can fail on upgraded installs where the legacy sessions table has no project_path.
|
||||
|
||||
${LAST_SCANNED_AT_SQL}
|
||||
|
||||
${APP_CONFIG_TABLE_SCHEMA_SQL}
|
||||
`;
|
||||
6
server/modules/projects/index.ts
Normal file
6
server/modules/projects/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
generateDisplayName,
|
||||
getProjectsWithSessions,
|
||||
} from './services/projects-with-sessions-fetch.service.js';
|
||||
export { updateProjectDisplayName } from './services/project-management.service.js';
|
||||
export { deleteOrArchiveProject, deleteSessionJsonlFilesForProjectPath } from './services/project-delete.service.js';
|
||||
247
server/modules/projects/projects.routes.ts
Normal file
247
server/modules/projects/projects.routes.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import express from 'express';
|
||||
|
||||
import { createProject, updateProjectDisplayName } from '@/modules/projects/services/project-management.service.js';
|
||||
import { startCloneProject } from '@/modules/projects/services/project-clone.service.js';
|
||||
import { getProjectTaskMaster } from '@/modules/projects/services/projects-has-taskmaster.service.js';
|
||||
import { AppError, asyncHandler } from '@/shared/utils.js';
|
||||
import { getProjectSessionsPage, getProjectsWithSessions } from '@/modules/projects/services/projects-with-sessions-fetch.service.js';
|
||||
import { deleteOrArchiveProject } from '@/modules/projects/services/project-delete.service.js';
|
||||
import { applyLegacyStarredProjectIds, toggleProjectStar } from '@/modules/projects/services/project-star.service.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
type AuthenticatedUser = {
|
||||
id?: number | string;
|
||||
};
|
||||
|
||||
function readQueryStringValue(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && typeof value[0] === 'string') {
|
||||
return value[0];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function readOptionalNumericQueryValue(value: unknown): number | null {
|
||||
const rawValue = readQueryStringValue(value).trim();
|
||||
if (!rawValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedValue = Number.parseInt(rawValue, 10);
|
||||
return Number.isNaN(parsedValue) ? null : parsedValue;
|
||||
}
|
||||
|
||||
function parseNonNegativeIntQuery(value: unknown, name: string, fallback: number): number {
|
||||
const rawValue = readQueryStringValue(value).trim();
|
||||
if (!rawValue) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsedValue = Number.parseInt(rawValue, 10);
|
||||
if (Number.isNaN(parsedValue) || parsedValue < 0) {
|
||||
throw new AppError(`${name} must be a non-negative integer`, {
|
||||
code: 'INVALID_QUERY_PARAMETER',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return parsedValue;
|
||||
}
|
||||
|
||||
function resolveRouteErrorMessage(error: unknown): string {
|
||||
if (error instanceof AppError) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return 'Failed to clone repository';
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (_req, res) => {
|
||||
const projects = await getProjectsWithSessions();
|
||||
res.json(projects);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:projectId/sessions',
|
||||
asyncHandler(async (req, res) => {
|
||||
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
|
||||
const limit = parseNonNegativeIntQuery(req.query.limit, 'limit', 20);
|
||||
const offset = parseNonNegativeIntQuery(req.query.offset, 'offset', 0);
|
||||
const sessionsPage = await getProjectSessionsPage(projectId, { limit, offset });
|
||||
res.json(sessionsPage);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/create-project',
|
||||
asyncHandler(async (req, res) => {
|
||||
const requestBody = req.body as Record<string, unknown>;
|
||||
const projectPath = typeof requestBody.path === 'string' ? requestBody.path : '';
|
||||
const customName = typeof requestBody.customName === 'string' ? requestBody.customName : null;
|
||||
|
||||
if (requestBody.workspaceType !== undefined) {
|
||||
throw new AppError('workspaceType is no longer supported. Use the single create-project flow.', {
|
||||
code: 'LEGACY_WORKSPACE_TYPE_UNSUPPORTED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (requestBody.githubUrl || requestBody.githubTokenId || requestBody.newGithubToken) {
|
||||
throw new AppError('Repository cloning is not supported on create-project', {
|
||||
code: 'CLONE_NOT_SUPPORTED_ON_CREATE_PROJECT',
|
||||
statusCode: 400,
|
||||
details: 'Use /api/projects/clone-progress for cloning workflows',
|
||||
});
|
||||
}
|
||||
|
||||
const projectCreationResult = await createProject({
|
||||
projectPath,
|
||||
customName,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
project: projectCreationResult.project,
|
||||
message:
|
||||
projectCreationResult.outcome === 'reactivated_archived'
|
||||
? 'Archived project path reused successfully'
|
||||
: 'Project created successfully',
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* One-time (or idempotent) migration: apply legacy `localStorage` starred projectIds to the DB, then clear client storage.
|
||||
*/
|
||||
router.post(
|
||||
'/migrate-legacy-stars',
|
||||
asyncHandler(async (req, res) => {
|
||||
const projectIds = Array.isArray((req.body as { projectIds?: unknown })?.projectIds)
|
||||
? ((req.body as { projectIds: unknown[] }).projectIds as unknown[]).map((x) => String(x))
|
||||
: [];
|
||||
const { updated } = applyLegacyStarredProjectIds(projectIds);
|
||||
res.json({ success: true, updated });
|
||||
}),
|
||||
);
|
||||
|
||||
router.get('/clone-progress', async (req, res) => {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
const sendEvent = (type: string, data: Record<string, unknown>) => {
|
||||
if (res.writableEnded) {
|
||||
return;
|
||||
}
|
||||
|
||||
res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
|
||||
};
|
||||
|
||||
let cloneOperation: Awaited<ReturnType<typeof startCloneProject>> | null = null;
|
||||
const closeListener = () => {
|
||||
cloneOperation?.cancel();
|
||||
};
|
||||
req.on('close', closeListener);
|
||||
|
||||
try {
|
||||
const queryParams = req.query as Record<string, unknown>;
|
||||
const workspacePath = readQueryStringValue(queryParams.path);
|
||||
const githubUrl = readQueryStringValue(queryParams.githubUrl);
|
||||
const githubTokenId = readOptionalNumericQueryValue(queryParams.githubTokenId);
|
||||
const newGithubToken = readQueryStringValue(queryParams.newGithubToken) || null;
|
||||
|
||||
const authenticatedUser = (req as typeof req & { user?: AuthenticatedUser }).user;
|
||||
const userId = authenticatedUser?.id;
|
||||
if (userId === undefined || userId === null) {
|
||||
throw new AppError('Authenticated user is required', {
|
||||
code: 'AUTHENTICATION_REQUIRED',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
cloneOperation = await startCloneProject(
|
||||
{
|
||||
workspacePath,
|
||||
githubUrl,
|
||||
githubTokenId,
|
||||
newGithubToken,
|
||||
userId,
|
||||
},
|
||||
{
|
||||
onProgress: (message) => {
|
||||
sendEvent('progress', { message });
|
||||
},
|
||||
onComplete: ({ project, message }) => {
|
||||
sendEvent('complete', { project, message });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await cloneOperation.waitForCompletion;
|
||||
} catch (error) {
|
||||
sendEvent('error', { message: resolveRouteErrorMessage(error) });
|
||||
} finally {
|
||||
req.off('close', closeListener);
|
||||
if (!res.writableEnded) {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/:projectId/taskmaster',
|
||||
asyncHandler(async (req, res) => {
|
||||
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
|
||||
const taskMasterDetails = await getProjectTaskMaster(projectId);
|
||||
res.json(taskMasterDetails);
|
||||
}),
|
||||
);
|
||||
|
||||
router.put('/:projectId/rename', (req, res) => {
|
||||
try {
|
||||
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
|
||||
const { displayName } = req.body as { displayName?: unknown };
|
||||
updateProjectDisplayName(projectId, displayName);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to rename project' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post(
|
||||
'/:projectId/toggle-star',
|
||||
asyncHandler(async (req, res) => {
|
||||
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
|
||||
const { isStarred } = toggleProjectStar(projectId);
|
||||
res.json({ success: true, isStarred });
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* - `force` not set / false: archive project in DB only (`isArchived` = 1; hidden from active list).
|
||||
* - `force=true`: remove DB row, delete session rows for that path, remove all `*.jsonl` under the Claude project dir.
|
||||
*/
|
||||
router.delete(
|
||||
'/:projectId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
|
||||
const force = req.query.force === 'true';
|
||||
await deleteOrArchiveProject(projectId, force);
|
||||
res.json({ success: true });
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
321
server/modules/projects/services/project-clone.service.ts
Normal file
321
server/modules/projects/services/project-clone.service.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { access, mkdir, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { githubTokensDb } from '@/modules/database/index.js';
|
||||
import { createProject } from '@/modules/projects/services/project-management.service.js';
|
||||
import type { WorkspacePathValidationResult } from '@/shared/types.js';
|
||||
import { AppError, validateWorkspacePath } from '@/shared/utils.js';
|
||||
|
||||
type CloneProjectInput = {
|
||||
workspacePath: string;
|
||||
githubUrl: string;
|
||||
githubTokenId?: number | null;
|
||||
newGithubToken?: string | null;
|
||||
userId: number | string;
|
||||
};
|
||||
|
||||
type CloneCompletePayload = {
|
||||
project: Record<string, unknown>;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type CloneProjectEventHandlers = {
|
||||
onProgress: (message: string) => void;
|
||||
onComplete: (payload: CloneCompletePayload) => void;
|
||||
};
|
||||
|
||||
type GitCloneProcess = {
|
||||
stdout: NodeJS.ReadableStream | null;
|
||||
stderr: NodeJS.ReadableStream | null;
|
||||
on(event: 'close', listener: (code: number | null) => void): void;
|
||||
on(event: 'error', listener: (error: NodeJS.ErrnoException) => void): void;
|
||||
kill(): void;
|
||||
};
|
||||
|
||||
type CloneProjectDependencies = {
|
||||
validatePath: (requestedPath: string) => Promise<WorkspacePathValidationResult>;
|
||||
ensureDirectory: (directoryPath: string) => Promise<void>;
|
||||
pathExists: (targetPath: string) => Promise<boolean>;
|
||||
removePath: (targetPath: string) => Promise<void>;
|
||||
getGithubTokenById: (
|
||||
tokenId: number,
|
||||
userId: number,
|
||||
) => Promise<{ github_token: string } | null>;
|
||||
spawnGitClone: (cloneUrl: string, clonePath: string) => GitCloneProcess;
|
||||
registerProject: (projectPath: string, customName: string) => Promise<{ project: Record<string, unknown> }>;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
};
|
||||
|
||||
export type CloneProjectOperation = {
|
||||
waitForCompletion: Promise<void>;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
async function defaultPathExists(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(targetPath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeGitError(message: string, token: string | null): string {
|
||||
if (!message || !token) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const escapedToken = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return message.replace(new RegExp(escapedToken, 'g'), '***');
|
||||
}
|
||||
|
||||
function resolveCloneFailureMessage(lastError: string, sanitizedError: string): string {
|
||||
if (lastError.includes('Authentication failed') || lastError.includes('could not read Username')) {
|
||||
return 'Authentication failed. Please check your credentials.';
|
||||
}
|
||||
|
||||
if (lastError.includes('Repository not found')) {
|
||||
return 'Repository not found. Please check the URL and ensure you have access.';
|
||||
}
|
||||
|
||||
if (lastError.includes('already exists')) {
|
||||
return 'Directory already exists';
|
||||
}
|
||||
|
||||
if (sanitizedError) {
|
||||
return sanitizedError;
|
||||
}
|
||||
|
||||
return 'Git clone failed';
|
||||
}
|
||||
|
||||
function resolveErrorMessage(error: unknown): string {
|
||||
if (error instanceof AppError) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return 'Unexpected error';
|
||||
}
|
||||
|
||||
const defaultDependencies: CloneProjectDependencies = {
|
||||
validatePath: validateWorkspacePath,
|
||||
ensureDirectory: async (directoryPath: string): Promise<void> => {
|
||||
await mkdir(directoryPath, { recursive: true });
|
||||
},
|
||||
pathExists: defaultPathExists,
|
||||
removePath: async (targetPath: string): Promise<void> => {
|
||||
await rm(targetPath, { recursive: true, force: true });
|
||||
},
|
||||
getGithubTokenById: async (
|
||||
tokenId: number,
|
||||
userId: number,
|
||||
): Promise<{ github_token: string } | null> => {
|
||||
const tokenRow = githubTokensDb.getGithubTokenById(userId, tokenId) as
|
||||
| { github_token: string }
|
||||
| null;
|
||||
return tokenRow;
|
||||
},
|
||||
spawnGitClone: (cloneUrl: string, clonePath: string): GitCloneProcess =>
|
||||
spawn('git', ['clone', '--progress', '--', cloneUrl, clonePath], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
},
|
||||
}) as unknown as GitCloneProcess,
|
||||
registerProject: async (
|
||||
projectPath: string,
|
||||
customName: string,
|
||||
): Promise<{ project: Record<string, unknown> }> =>
|
||||
createProject({
|
||||
projectPath,
|
||||
customName,
|
||||
}) as Promise<{ project: Record<string, unknown> }>,
|
||||
logError: (message: string, error: unknown): void => {
|
||||
console.error(message, error);
|
||||
},
|
||||
};
|
||||
|
||||
export async function startCloneProject(
|
||||
input: CloneProjectInput,
|
||||
handlers: CloneProjectEventHandlers,
|
||||
dependencies: CloneProjectDependencies = defaultDependencies,
|
||||
): Promise<CloneProjectOperation> {
|
||||
const normalizedWorkspacePath = input.workspacePath.trim();
|
||||
const normalizedGithubUrl = input.githubUrl.trim();
|
||||
|
||||
if (!normalizedWorkspacePath) {
|
||||
throw new AppError('workspacePath and githubUrl are required', {
|
||||
code: 'WORKSPACE_PATH_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (!normalizedGithubUrl) {
|
||||
throw new AppError('workspacePath and githubUrl are required', {
|
||||
code: 'GITHUB_URL_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedGithubUrl.startsWith('-')) {
|
||||
throw new AppError('Invalid githubUrl', {
|
||||
code: 'INVALID_GITHUB_URL',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const pathValidation = await dependencies.validatePath(normalizedWorkspacePath);
|
||||
if (!pathValidation.valid || !pathValidation.resolvedPath) {
|
||||
throw new AppError(pathValidation.error || 'Invalid workspace path', {
|
||||
code: 'INVALID_PROJECT_PATH',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const absolutePath = pathValidation.resolvedPath;
|
||||
await dependencies.ensureDirectory(absolutePath);
|
||||
|
||||
let githubToken: string | null = null;
|
||||
if (typeof input.githubTokenId === 'number') {
|
||||
const numericUserId =
|
||||
typeof input.userId === 'number' ? input.userId : Number.parseInt(String(input.userId), 10);
|
||||
if (Number.isNaN(numericUserId)) {
|
||||
throw new AppError('Authenticated user is required', {
|
||||
code: 'AUTHENTICATION_REQUIRED',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const token = await dependencies.getGithubTokenById(input.githubTokenId, numericUserId);
|
||||
if (!token) {
|
||||
throw new AppError('GitHub token not found', {
|
||||
code: 'GITHUB_TOKEN_NOT_FOUND',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
githubToken = token.github_token;
|
||||
} else if (input.newGithubToken && input.newGithubToken.trim().length > 0) {
|
||||
githubToken = input.newGithubToken.trim();
|
||||
}
|
||||
|
||||
const sanitizedGithubUrl = normalizedGithubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
|
||||
const repoName = sanitizedGithubUrl.split('/').pop() || 'repository';
|
||||
const clonePath = path.join(absolutePath, repoName);
|
||||
|
||||
if (await dependencies.pathExists(clonePath)) {
|
||||
throw new AppError(
|
||||
`Directory "${repoName}" already exists. Please choose a different location or remove the existing directory.`,
|
||||
{
|
||||
code: 'CLONE_TARGET_ALREADY_EXISTS',
|
||||
statusCode: 409,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let cloneUrl = normalizedGithubUrl;
|
||||
if (githubToken) {
|
||||
try {
|
||||
const url = new URL(normalizedGithubUrl);
|
||||
url.username = githubToken;
|
||||
url.password = '';
|
||||
cloneUrl = url.toString();
|
||||
} catch {
|
||||
// SSH URLs cannot be represented by URL constructor and are used as-is.
|
||||
}
|
||||
}
|
||||
|
||||
handlers.onProgress(`Cloning into '${repoName}'...`);
|
||||
const gitProcess = dependencies.spawnGitClone(cloneUrl, clonePath);
|
||||
let lastError = '';
|
||||
|
||||
gitProcess.stdout?.on('data', (data: Buffer | string) => {
|
||||
const message = data.toString().trim();
|
||||
if (message) {
|
||||
handlers.onProgress(message);
|
||||
}
|
||||
});
|
||||
|
||||
gitProcess.stderr?.on('data', (data: Buffer | string) => {
|
||||
const message = data.toString().trim();
|
||||
lastError = message;
|
||||
if (message) {
|
||||
handlers.onProgress(message);
|
||||
}
|
||||
});
|
||||
|
||||
const waitForCompletion = new Promise<void>((resolve, reject) => {
|
||||
gitProcess.on('close', async (code) => {
|
||||
if (code === 0) {
|
||||
try {
|
||||
const createdProject = await dependencies.registerProject(clonePath, repoName);
|
||||
handlers.onComplete({
|
||||
project: createdProject.project,
|
||||
message: 'Repository cloned successfully',
|
||||
});
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(
|
||||
new AppError(`Clone succeeded but failed to add project: ${resolveErrorMessage(error)}`, {
|
||||
code: 'CLONE_PROJECT_REGISTRATION_FAILED',
|
||||
statusCode: 500,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizedError = sanitizeGitError(lastError, githubToken);
|
||||
const errorMessage = resolveCloneFailureMessage(lastError, sanitizedError);
|
||||
|
||||
try {
|
||||
await dependencies.removePath(clonePath);
|
||||
} catch (cleanupError) {
|
||||
dependencies.logError('Failed to clean up after clone failure:', cleanupError);
|
||||
}
|
||||
|
||||
reject(
|
||||
new AppError(errorMessage, {
|
||||
code: 'GIT_CLONE_FAILED',
|
||||
statusCode: 500,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
gitProcess.on('error', (error) => {
|
||||
if (error.code === 'ENOENT') {
|
||||
reject(
|
||||
new AppError('Git is not installed or not in PATH', {
|
||||
code: 'GIT_NOT_FOUND',
|
||||
statusCode: 500,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(
|
||||
new AppError(error.message, {
|
||||
code: 'GIT_EXECUTION_FAILED',
|
||||
statusCode: 500,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
waitForCompletion,
|
||||
cancel: () => {
|
||||
gitProcess.kill();
|
||||
},
|
||||
};
|
||||
}
|
||||
75
server/modules/projects/services/project-delete.service.ts
Normal file
75
server/modules/projects/services/project-delete.service.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
function uniqueJsonlPathsFromSessions(
|
||||
sessions: Array<{ jsonl_path: string | null }>,
|
||||
): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
|
||||
for (const row of sessions) {
|
||||
const raw = row.jsonl_path?.trim();
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
const absolute = path.isAbsolute(raw) ? path.normalize(raw) : path.resolve(raw);
|
||||
if (seen.has(absolute)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(absolute);
|
||||
result.push(absolute);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function unlinkJsonlIfExists(filePath: string): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
console.warn(`[project-delete] Failed to remove ${filePath}:`, (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all session rows for the project path and removes each distinct `jsonl_path` file on disk.
|
||||
*/
|
||||
export async function deleteSessionJsonlFilesForProjectPath(projectPath: string): Promise<void> {
|
||||
const sessions = sessionsDb.getSessionsByProjectPath(projectPath);
|
||||
const paths = uniqueJsonlPathsFromSessions(sessions);
|
||||
|
||||
for (const filePath of paths) {
|
||||
await unlinkJsonlIfExists(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* - **Soft delete** (`force` false): set `isArchived` on the `projects` row (hide from the active list; DB only).
|
||||
* - **Force** (`force` true): for each session row for that `project_path`, delete the file at `jsonl_path`
|
||||
* (when set), then remove session rows and the `projects` row.
|
||||
*/
|
||||
export async function deleteOrArchiveProject(projectId: string, force: boolean): Promise<void> {
|
||||
const row = projectsDb.getProjectById(projectId);
|
||||
if (!row) {
|
||||
throw new AppError(`Unknown projectId: ${projectId}`, {
|
||||
code: 'PROJECT_NOT_FOUND',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (!force) {
|
||||
projectsDb.updateProjectIsArchivedById(projectId, true);
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteSessionJsonlFilesForProjectPath(row.project_path);
|
||||
sessionsDb.deleteSessionsByProjectPath(row.project_path);
|
||||
projectsDb.deleteProjectById(projectId);
|
||||
}
|
||||
150
server/modules/projects/services/project-management.service.ts
Normal file
150
server/modules/projects/services/project-management.service.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { projectsDb } from '@/modules/database/index.js';
|
||||
import type {
|
||||
CreateProjectPathResult,
|
||||
ProjectRepositoryRow,
|
||||
WorkspacePathValidationResult,
|
||||
} from '@/shared/types.js';
|
||||
import { AppError, normalizeProjectPath, validateWorkspacePath } from '@/shared/utils.js';
|
||||
|
||||
type CreateProjectInput = {
|
||||
projectPath: string;
|
||||
customName?: string | null;
|
||||
};
|
||||
|
||||
type CreateProjectDependencies = {
|
||||
validatePath: (projectPath: string) => Promise<WorkspacePathValidationResult>;
|
||||
ensureWorkspaceDirectory: (projectPath: string) => Promise<void>;
|
||||
persistProjectPath: (projectPath: string, customName: string | null) => CreateProjectPathResult;
|
||||
getProjectByPath: (projectPath: string) => ProjectRepositoryRow | null;
|
||||
};
|
||||
|
||||
type ProjectApiView = {
|
||||
projectId: string;
|
||||
path: string;
|
||||
fullPath: string;
|
||||
displayName: string;
|
||||
customName: string | null;
|
||||
isArchived: boolean;
|
||||
isStarred: boolean;
|
||||
sessions: [];
|
||||
cursorSessions: [];
|
||||
codexSessions: [];
|
||||
geminiSessions: [];
|
||||
sessionMeta: {
|
||||
hasMore: false;
|
||||
total: 0;
|
||||
};
|
||||
};
|
||||
|
||||
type CreateProjectServiceResult = {
|
||||
outcome: 'created' | 'reactivated_archived';
|
||||
project: ProjectApiView;
|
||||
};
|
||||
|
||||
const defaultDependencies: CreateProjectDependencies = {
|
||||
validatePath: validateWorkspacePath,
|
||||
ensureWorkspaceDirectory: async (projectPath: string): Promise<void> => {
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
const directoryStats = await fs.stat(projectPath);
|
||||
if (!directoryStats.isDirectory()) {
|
||||
throw new AppError('Path exists but is not a directory', {
|
||||
code: 'PROJECT_PATH_NOT_DIRECTORY',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
},
|
||||
persistProjectPath: (projectPath: string, customName: string | null): CreateProjectPathResult =>
|
||||
projectsDb.createProjectPath(projectPath, customName),
|
||||
getProjectByPath: (projectPath: string): ProjectRepositoryRow | null =>
|
||||
projectsDb.getProjectPath(projectPath),
|
||||
};
|
||||
|
||||
function resolveDisplayName(customName: string | null | undefined, projectPath: string): string {
|
||||
const trimmedCustomName = typeof customName === 'string' ? customName.trim() : '';
|
||||
if (trimmedCustomName.length > 0) {
|
||||
return trimmedCustomName;
|
||||
}
|
||||
|
||||
return path.basename(projectPath) || projectPath;
|
||||
}
|
||||
|
||||
function mapProjectRowToApiView(projectRow: ProjectRepositoryRow): ProjectApiView {
|
||||
return {
|
||||
projectId: projectRow.project_id,
|
||||
path: projectRow.project_path,
|
||||
fullPath: projectRow.project_path,
|
||||
displayName: resolveDisplayName(projectRow.custom_project_name, projectRow.project_path),
|
||||
customName: projectRow.custom_project_name,
|
||||
isArchived: Boolean(projectRow.isArchived),
|
||||
isStarred: Boolean(projectRow.isStarred),
|
||||
sessions: [],
|
||||
cursorSessions: [],
|
||||
codexSessions: [],
|
||||
geminiSessions: [],
|
||||
sessionMeta: {
|
||||
hasMore: false,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createProject(
|
||||
input: CreateProjectInput,
|
||||
dependencies: CreateProjectDependencies = defaultDependencies,
|
||||
): Promise<CreateProjectServiceResult> {
|
||||
const normalizedPath = normalizeProjectPath(input.projectPath || '');
|
||||
if (!normalizedPath) {
|
||||
throw new AppError('path is required', {
|
||||
code: 'PROJECT_PATH_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const pathValidation = await dependencies.validatePath(normalizedPath);
|
||||
if (!pathValidation.valid || !pathValidation.resolvedPath) {
|
||||
throw new AppError('Invalid project path', {
|
||||
code: 'INVALID_PROJECT_PATH',
|
||||
statusCode: 400,
|
||||
details: pathValidation.error ?? 'Path validation failed',
|
||||
});
|
||||
}
|
||||
|
||||
const resolvedProjectPath = normalizeProjectPath(pathValidation.resolvedPath);
|
||||
await dependencies.ensureWorkspaceDirectory(resolvedProjectPath);
|
||||
|
||||
const normalizedCustomName = resolveDisplayName(input.customName ?? null, resolvedProjectPath);
|
||||
const persistedProject = dependencies.persistProjectPath(resolvedProjectPath, normalizedCustomName);
|
||||
|
||||
if (persistedProject.outcome === 'active_conflict') {
|
||||
throw new AppError('Project path already exists and is active', {
|
||||
code: 'PROJECT_ALREADY_EXISTS',
|
||||
statusCode: 409,
|
||||
details: `Project path already exists: ${resolvedProjectPath}`,
|
||||
});
|
||||
}
|
||||
|
||||
const projectRow = persistedProject.project ?? dependencies.getProjectByPath(resolvedProjectPath);
|
||||
if (!projectRow) {
|
||||
throw new AppError('Failed to resolve project after creation', {
|
||||
code: 'PROJECT_CREATE_FAILED',
|
||||
statusCode: 500,
|
||||
});
|
||||
}
|
||||
|
||||
// Archived rows intentionally remain archived when reused, as requested.
|
||||
return {
|
||||
outcome: persistedProject.outcome,
|
||||
project: mapProjectRowToApiView(projectRow),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets `projects.custom_project_name` for the given `projectId` (or clears it when empty).
|
||||
*/
|
||||
export function updateProjectDisplayName(projectId: string, newDisplayName: unknown): void {
|
||||
const trimmed = typeof newDisplayName === 'string' ? newDisplayName.trim() : '';
|
||||
projectsDb.updateCustomProjectNameById(projectId, trimmed.length > 0 ? trimmed : null);
|
||||
}
|
||||
78
server/modules/projects/services/project-star.service.ts
Normal file
78
server/modules/projects/services/project-star.service.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { projectsDb } from '@/modules/database/index.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
type ToggleProjectStarResult = {
|
||||
isStarred: boolean;
|
||||
};
|
||||
|
||||
type ApplyLegacyStarredProjectIdsResult = {
|
||||
updated: number;
|
||||
};
|
||||
|
||||
function normalizeProjectId(projectId: string): string {
|
||||
return projectId.trim();
|
||||
}
|
||||
|
||||
function uniqueProjectIds(projectIds: string[]): string[] {
|
||||
const uniqueIds = new Set<string>();
|
||||
for (const projectId of projectIds) {
|
||||
const normalizedProjectId = normalizeProjectId(projectId);
|
||||
if (!normalizedProjectId) {
|
||||
continue;
|
||||
}
|
||||
uniqueIds.add(normalizedProjectId);
|
||||
}
|
||||
return [...uniqueIds];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies legacy `localStorage` stars keyed by DB `projectId` onto `projects.isStarred`.
|
||||
*
|
||||
* The operation is idempotent: already-starred projects are ignored, unknown ids are skipped.
|
||||
*/
|
||||
export function applyLegacyStarredProjectIds(projectIds: string[]): ApplyLegacyStarredProjectIdsResult {
|
||||
const normalizedProjectIds = uniqueProjectIds(projectIds);
|
||||
let updated = 0;
|
||||
|
||||
for (const projectId of normalizedProjectIds) {
|
||||
const project = projectsDb.getProjectById(projectId);
|
||||
if (!project) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Boolean(project.isStarred)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
projectsDb.updateProjectIsStarredById(projectId, true);
|
||||
updated += 1;
|
||||
}
|
||||
|
||||
return { updated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Flips `projects.isStarred` for one project and returns the new state.
|
||||
*/
|
||||
export function toggleProjectStar(projectId: string): ToggleProjectStarResult {
|
||||
const normalizedProjectId = normalizeProjectId(projectId);
|
||||
if (!normalizedProjectId) {
|
||||
throw new AppError('projectId is required', {
|
||||
code: 'PROJECT_ID_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const project = projectsDb.getProjectById(normalizedProjectId);
|
||||
if (!project) {
|
||||
throw new AppError('Project not found', {
|
||||
code: 'PROJECT_NOT_FOUND',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const nextStarredState = !Boolean(project.isStarred);
|
||||
projectsDb.updateProjectIsStarredById(normalizedProjectId, nextStarredState);
|
||||
|
||||
return { isStarred: nextStarredState };
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { access, readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { projectsDb } from '@/modules/database/index.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
type TaskMasterTask = {
|
||||
status?: string;
|
||||
subtasks?: Array<{
|
||||
status?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type TaskMasterMetadata =
|
||||
| {
|
||||
taskCount: number;
|
||||
subtaskCount: number;
|
||||
completed: number;
|
||||
pending: number;
|
||||
inProgress: number;
|
||||
review: number;
|
||||
completionPercentage: number;
|
||||
lastModified: string;
|
||||
}
|
||||
| {
|
||||
error: string;
|
||||
}
|
||||
| null;
|
||||
|
||||
type TaskMasterDetectionResult = {
|
||||
hasTaskmaster: boolean;
|
||||
hasEssentialFiles?: boolean;
|
||||
files?: Record<string, boolean>;
|
||||
metadata?: TaskMasterMetadata;
|
||||
path?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
type NormalizedTaskMasterInfo = {
|
||||
hasTaskmaster: boolean;
|
||||
hasEssentialFiles: boolean;
|
||||
metadata: TaskMasterMetadata;
|
||||
status: 'configured' | 'not-configured';
|
||||
};
|
||||
|
||||
type GetProjectTaskMasterByIdResult = {
|
||||
projectId: string;
|
||||
projectPath: string;
|
||||
taskmaster: NormalizedTaskMasterInfo;
|
||||
};
|
||||
|
||||
type GetProjectTaskMasterDependencies = {
|
||||
resolveProjectPathById: (projectId: string) => string | null;
|
||||
detectTaskMasterFolder: (projectPath: string) => Promise<TaskMasterDetectionResult>;
|
||||
};
|
||||
|
||||
type GetProjectTaskMasterResolver = (projectId: string) => Promise<GetProjectTaskMasterByIdResult | null>;
|
||||
|
||||
function extractTasksFromJson(tasksData: unknown): TaskMasterTask[] {
|
||||
if (!tasksData || typeof tasksData !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const legacyTasks = (tasksData as { tasks?: unknown }).tasks;
|
||||
if (Array.isArray(legacyTasks)) {
|
||||
return legacyTasks as TaskMasterTask[];
|
||||
}
|
||||
|
||||
const taggedTaskCollections: TaskMasterTask[] = [];
|
||||
for (const tagValue of Object.values(tasksData)) {
|
||||
if (!tagValue || typeof tagValue !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagTasks = (tagValue as { tasks?: unknown }).tasks;
|
||||
if (Array.isArray(tagTasks)) {
|
||||
taggedTaskCollections.push(...(tagTasks as TaskMasterTask[]));
|
||||
}
|
||||
}
|
||||
|
||||
return taggedTaskCollections;
|
||||
}
|
||||
|
||||
async function detectTaskMasterFolder(projectPath: string): Promise<TaskMasterDetectionResult> {
|
||||
try {
|
||||
const taskMasterPath = path.join(projectPath, '.taskmaster');
|
||||
|
||||
try {
|
||||
const taskMasterStats = await stat(taskMasterPath);
|
||||
if (!taskMasterStats.isDirectory()) {
|
||||
return {
|
||||
hasTaskmaster: false,
|
||||
reason: '.taskmaster exists but is not a directory',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
const fileError = error as NodeJS.ErrnoException;
|
||||
if (fileError.code === 'ENOENT') {
|
||||
return {
|
||||
hasTaskmaster: false,
|
||||
reason: '.taskmaster directory not found',
|
||||
};
|
||||
}
|
||||
|
||||
throw fileError;
|
||||
}
|
||||
|
||||
const keyFiles = ['tasks/tasks.json', 'config.json'];
|
||||
const fileStatus: Record<string, boolean> = {};
|
||||
let hasEssentialFiles = true;
|
||||
|
||||
for (const fileName of keyFiles) {
|
||||
const absoluteFilePath = path.join(taskMasterPath, fileName);
|
||||
try {
|
||||
await access(absoluteFilePath);
|
||||
fileStatus[fileName] = true;
|
||||
} catch {
|
||||
fileStatus[fileName] = false;
|
||||
if (fileName === 'tasks/tasks.json') {
|
||||
hasEssentialFiles = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let taskMetadata: TaskMasterMetadata = null;
|
||||
if (fileStatus['tasks/tasks.json']) {
|
||||
const tasksPath = path.join(taskMasterPath, 'tasks/tasks.json');
|
||||
try {
|
||||
const tasksContent = await readFile(tasksPath, 'utf8');
|
||||
const parsedTasksJson = JSON.parse(tasksContent) as unknown;
|
||||
const tasks = extractTasksFromJson(parsedTasksJson);
|
||||
|
||||
const stats = tasks.reduce(
|
||||
(accumulator, currentTask) => {
|
||||
accumulator.total += 1;
|
||||
const normalizedTaskStatus = currentTask.status || 'pending';
|
||||
accumulator.byStatus[normalizedTaskStatus] = (accumulator.byStatus[normalizedTaskStatus] || 0) + 1;
|
||||
|
||||
if (Array.isArray(currentTask.subtasks)) {
|
||||
for (const subtask of currentTask.subtasks) {
|
||||
accumulator.subtotalTasks += 1;
|
||||
const normalizedSubtaskStatus = subtask.status || 'pending';
|
||||
accumulator.subtaskByStatus[normalizedSubtaskStatus] =
|
||||
(accumulator.subtaskByStatus[normalizedSubtaskStatus] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return accumulator;
|
||||
},
|
||||
{
|
||||
total: 0,
|
||||
subtotalTasks: 0,
|
||||
byStatus: {} as Record<string, number>,
|
||||
subtaskByStatus: {} as Record<string, number>,
|
||||
},
|
||||
);
|
||||
|
||||
const tasksStat = await stat(tasksPath);
|
||||
taskMetadata = {
|
||||
taskCount: stats.total,
|
||||
subtaskCount: stats.subtotalTasks,
|
||||
completed: stats.byStatus.done || 0,
|
||||
pending: stats.byStatus.pending || 0,
|
||||
inProgress: stats.byStatus['in-progress'] || 0,
|
||||
review: stats.byStatus.review || 0,
|
||||
completionPercentage: stats.total > 0 ? Math.round(((stats.byStatus.done || 0) / stats.total) * 100) : 0,
|
||||
lastModified: tasksStat.mtime.toISOString(),
|
||||
};
|
||||
} catch (parseError) {
|
||||
console.warn('Failed to parse tasks.json:', (parseError as Error).message);
|
||||
taskMetadata = {
|
||||
error: 'Failed to parse tasks.json',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasTaskmaster: true,
|
||||
hasEssentialFiles,
|
||||
files: fileStatus,
|
||||
metadata: taskMetadata,
|
||||
path: taskMasterPath,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error detecting TaskMaster folder:', error);
|
||||
return {
|
||||
hasTaskmaster: false,
|
||||
reason: `Error checking directory: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTaskMasterInfo(taskMasterResult: TaskMasterDetectionResult | null = null): NormalizedTaskMasterInfo {
|
||||
const hasTaskmaster = Boolean(taskMasterResult?.hasTaskmaster);
|
||||
const hasEssentialFiles = Boolean(taskMasterResult?.hasEssentialFiles);
|
||||
|
||||
return {
|
||||
hasTaskmaster,
|
||||
hasEssentialFiles,
|
||||
metadata: taskMasterResult?.metadata ?? null,
|
||||
status: hasTaskmaster && hasEssentialFiles ? 'configured' : 'not-configured',
|
||||
};
|
||||
}
|
||||
|
||||
const defaultDependencies: GetProjectTaskMasterDependencies = {
|
||||
resolveProjectPathById: (projectId: string): string | null => projectsDb.getProjectPathById(projectId),
|
||||
detectTaskMasterFolder,
|
||||
};
|
||||
|
||||
export async function getProjectTaskMasterById(
|
||||
projectId: string,
|
||||
dependencies: GetProjectTaskMasterDependencies = defaultDependencies,
|
||||
): Promise<GetProjectTaskMasterByIdResult | null> {
|
||||
const projectPath = dependencies.resolveProjectPathById(projectId);
|
||||
if (!projectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const taskMasterResult = await dependencies.detectTaskMasterFolder(projectPath);
|
||||
return {
|
||||
projectId,
|
||||
projectPath,
|
||||
taskmaster: normalizeTaskMasterInfo(taskMasterResult),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getProjectTaskMaster(
|
||||
projectId: string,
|
||||
resolveById: GetProjectTaskMasterResolver = getProjectTaskMasterById,
|
||||
): Promise<GetProjectTaskMasterByIdResult> {
|
||||
const normalizedProjectId = projectId.trim();
|
||||
if (!normalizedProjectId) {
|
||||
throw new AppError('projectId is required', {
|
||||
code: 'PROJECT_ID_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const taskMasterDetails = await resolveById(normalizedProjectId);
|
||||
if (!taskMasterDetails) {
|
||||
throw new AppError('Project not found', {
|
||||
code: 'PROJECT_NOT_FOUND',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return taskMasterDetails;
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
||||
import { sessionSynchronizerService } from '@/modules/providers/index.js';
|
||||
import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js';
|
||||
import type { RealtimeClientConnection } from '@/shared/types.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
type SessionSummary = {
|
||||
id: string;
|
||||
summary: string;
|
||||
messageCount: number;
|
||||
lastActivity: string;
|
||||
};
|
||||
|
||||
type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini', SessionSummary[]>;
|
||||
|
||||
type SessionRepositoryRow = {
|
||||
provider: string;
|
||||
session_id: string;
|
||||
custom_name?: string | null;
|
||||
updated_at?: string | null;
|
||||
created_at?: string | null;
|
||||
};
|
||||
|
||||
export type ProjectListItem = {
|
||||
projectId: string;
|
||||
path: string;
|
||||
displayName: string;
|
||||
fullPath: string;
|
||||
isStarred: boolean;
|
||||
sessions: SessionSummary[];
|
||||
cursorSessions: SessionSummary[];
|
||||
codexSessions: SessionSummary[];
|
||||
geminiSessions: SessionSummary[];
|
||||
sessionMeta: {
|
||||
hasMore: boolean;
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
|
||||
type ProgressUpdate = {
|
||||
phase: 'loading' | 'complete';
|
||||
current: number;
|
||||
total: number;
|
||||
currentProject?: string;
|
||||
};
|
||||
|
||||
type GetProjectsWithSessionsOptions = {
|
||||
skipSynchronization?: boolean;
|
||||
sessionsLimit?: number;
|
||||
sessionsOffset?: number;
|
||||
};
|
||||
|
||||
type SessionPaginationOptions = {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
type ProjectSessionsPageResult = {
|
||||
sessionsByProvider: SessionsByProvider;
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
export type ProjectSessionsPageApiView = {
|
||||
projectId: string;
|
||||
sessions: SessionSummary[];
|
||||
cursorSessions: SessionSummary[];
|
||||
codexSessions: SessionSummary[];
|
||||
geminiSessions: SessionSummary[];
|
||||
sessionMeta: {
|
||||
hasMore: boolean;
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_PROJECT_SESSIONS_PAGE_SIZE = 20;
|
||||
const MAX_PROJECT_SESSIONS_PAGE_SIZE = 200;
|
||||
|
||||
/**
|
||||
* Generate better display name from path.
|
||||
*/
|
||||
export async function generateDisplayName(projectName: string, actualProjectDir: string | null = null): Promise<string> {
|
||||
// Use actual project directory if provided, otherwise decode from project name.
|
||||
const projectPath = actualProjectDir || projectName.replace(/-/g, '/');
|
||||
|
||||
// Try to read package.json from the project path.
|
||||
try {
|
||||
const packageJsonPath = path.join(projectPath, 'package.json');
|
||||
const packageData = await fs.readFile(packageJsonPath, 'utf8');
|
||||
const packageJson = JSON.parse(packageData) as { name?: string };
|
||||
|
||||
// Return the name from package.json if it exists.
|
||||
if (packageJson.name) {
|
||||
return packageJson.name;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to path-based naming if package.json doesn't exist or can't be read.
|
||||
}
|
||||
|
||||
// If it starts with /, it's an absolute path.
|
||||
if (projectPath.startsWith('/')) {
|
||||
const parts = projectPath.split('/').filter(Boolean);
|
||||
// Return only the last folder name.
|
||||
return parts[parts.length - 1] || projectPath;
|
||||
}
|
||||
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
function normalizeSessionPagination(options: SessionPaginationOptions = {}): { limit: number; offset: number } {
|
||||
const rawLimit = Number.isFinite(options.limit) ? Math.floor(Number(options.limit)) : DEFAULT_PROJECT_SESSIONS_PAGE_SIZE;
|
||||
const rawOffset = Number.isFinite(options.offset) ? Math.floor(Number(options.offset)) : 0;
|
||||
|
||||
return {
|
||||
limit: Math.min(Math.max(1, rawLimit), MAX_PROJECT_SESSIONS_PAGE_SIZE),
|
||||
offset: Math.max(0, rawOffset),
|
||||
};
|
||||
}
|
||||
|
||||
function mapSessionRowToSummary(row: SessionRepositoryRow): SessionSummary {
|
||||
return {
|
||||
id: row.session_id,
|
||||
summary: row.custom_name || '',
|
||||
messageCount: 0,
|
||||
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function bucketSessionRowsByProvider(rows: SessionRepositoryRow[]): SessionsByProvider {
|
||||
const byProvider: SessionsByProvider = {
|
||||
claude: [],
|
||||
cursor: [],
|
||||
codex: [],
|
||||
gemini: [],
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
const provider = row.provider as keyof SessionsByProvider;
|
||||
const bucket = byProvider[provider];
|
||||
if (!bucket) {
|
||||
continue;
|
||||
}
|
||||
|
||||
bucket.push(mapSessionRowToSummary(row));
|
||||
}
|
||||
|
||||
return byProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one paginated project session slice from the DB and groups rows by provider.
|
||||
*/
|
||||
function readProjectSessionsPageByPath(
|
||||
projectPath: string,
|
||||
options: SessionPaginationOptions = {},
|
||||
): ProjectSessionsPageResult {
|
||||
const pagination = normalizeSessionPagination(options);
|
||||
const rows = sessionsDb.getSessionsByProjectPathPage(
|
||||
projectPath,
|
||||
pagination.limit,
|
||||
pagination.offset,
|
||||
) as SessionRepositoryRow[];
|
||||
const total = sessionsDb.countSessionsByProjectPath(projectPath);
|
||||
|
||||
return {
|
||||
sessionsByProvider: bucketSessionRowsByProvider(rows),
|
||||
total,
|
||||
hasMore: pagination.offset + rows.length < total,
|
||||
};
|
||||
}
|
||||
|
||||
// Broadcast progress to all connected WebSocket clients
|
||||
function broadcastProgress(progress: ProgressUpdate) {
|
||||
const message = JSON.stringify({
|
||||
type: 'loading_progress',
|
||||
...progress,
|
||||
});
|
||||
|
||||
connectedClients.forEach((client: RealtimeClientConnection) => {
|
||||
if (client.readyState === WS_OPEN_STATE) {
|
||||
client.send(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads all projects from DB and returns provider-bucketed session summaries.
|
||||
*/
|
||||
export async function getProjectsWithSessions(
|
||||
options: GetProjectsWithSessionsOptions = {}
|
||||
): Promise<ProjectListItem[]> {
|
||||
if (!options.skipSynchronization) {
|
||||
await sessionSynchronizerService.synchronizeSessions();
|
||||
}
|
||||
|
||||
const projectRows = projectsDb.getProjectPaths() as Array<{
|
||||
project_id: string;
|
||||
project_path: string;
|
||||
custom_project_name?: string | null;
|
||||
isStarred?: number;
|
||||
}>;
|
||||
const totalProjects = projectRows.length;
|
||||
const projects: ProjectListItem[] = [];
|
||||
let processedProjects = 0;
|
||||
|
||||
for (const row of projectRows) {
|
||||
processedProjects += 1;
|
||||
|
||||
const projectId = row.project_id;
|
||||
const projectPath = row.project_path;
|
||||
|
||||
broadcastProgress({
|
||||
phase: 'loading',
|
||||
current: processedProjects,
|
||||
total: totalProjects,
|
||||
currentProject: projectPath,
|
||||
});
|
||||
|
||||
const displayName =
|
||||
row.custom_project_name && row.custom_project_name.trim().length > 0
|
||||
? row.custom_project_name
|
||||
: await generateDisplayName(path.basename(projectPath) || projectPath, projectPath);
|
||||
|
||||
const sessionsPage = readProjectSessionsPageByPath(projectPath, {
|
||||
limit: options.sessionsLimit,
|
||||
offset: options.sessionsOffset,
|
||||
});
|
||||
|
||||
projects.push({
|
||||
projectId,
|
||||
path: projectPath,
|
||||
displayName,
|
||||
fullPath: projectPath,
|
||||
isStarred: Boolean(row.isStarred),
|
||||
sessions: sessionsPage.sessionsByProvider.claude,
|
||||
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
||||
codexSessions: sessionsPage.sessionsByProvider.codex,
|
||||
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
||||
sessionMeta: {
|
||||
hasMore: sessionsPage.hasMore,
|
||||
total: sessionsPage.total,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
broadcastProgress({
|
||||
phase: 'complete',
|
||||
current: totalProjects,
|
||||
total: totalProjects,
|
||||
});
|
||||
|
||||
return projects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads one paginated session slice for a specific project id.
|
||||
*/
|
||||
export async function getProjectSessionsPage(
|
||||
projectId: string,
|
||||
options: SessionPaginationOptions = {},
|
||||
): Promise<ProjectSessionsPageApiView> {
|
||||
const projectRow = projectsDb.getProjectById(projectId);
|
||||
if (!projectRow) {
|
||||
throw new AppError(`Project "${projectId}" was not found.`, {
|
||||
code: 'PROJECT_NOT_FOUND',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const sessionsPage = readProjectSessionsPageByPath(projectRow.project_path, options);
|
||||
return {
|
||||
projectId: projectRow.project_id,
|
||||
sessions: sessionsPage.sessionsByProvider.claude,
|
||||
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
||||
codexSessions: sessionsPage.sessionsByProvider.codex,
|
||||
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
||||
sessionMeta: {
|
||||
hasMore: sessionsPage.hasMore,
|
||||
total: sessionsPage.total,
|
||||
},
|
||||
};
|
||||
}
|
||||
183
server/modules/projects/tests/project-clone.service.test.ts
Normal file
183
server/modules/projects/tests/project-clone.service.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import path from 'node:path';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import test from 'node:test';
|
||||
|
||||
import { startCloneProject } from '@/modules/projects/services/project-clone.service.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
type TestDependencies = Parameters<typeof startCloneProject>[2];
|
||||
|
||||
function buildDependencies(overrides: Partial<NonNullable<TestDependencies>> = {}): NonNullable<TestDependencies> {
|
||||
return {
|
||||
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/root' }),
|
||||
ensureDirectory: async () => undefined,
|
||||
pathExists: async () => false,
|
||||
removePath: async () => undefined,
|
||||
getGithubTokenById: async () => ({ github_token: 'token-value' }),
|
||||
spawnGitClone: () => {
|
||||
throw new Error('spawnGitClone should be overridden in this test');
|
||||
},
|
||||
registerProject: async () => ({ project: { projectId: 'project-1' } }),
|
||||
logError: () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockGitProcess() {
|
||||
const emitter = new EventEmitter() as EventEmitter & {
|
||||
stdout: PassThrough;
|
||||
stderr: PassThrough;
|
||||
kill: () => void;
|
||||
};
|
||||
|
||||
emitter.stdout = new PassThrough();
|
||||
emitter.stderr = new PassThrough();
|
||||
emitter.kill = () => {
|
||||
emitter.emit('close', null);
|
||||
};
|
||||
|
||||
return emitter;
|
||||
}
|
||||
|
||||
test('startCloneProject rejects when workspace path is missing', async () => {
|
||||
await assert.rejects(
|
||||
async () =>
|
||||
startCloneProject(
|
||||
{
|
||||
workspacePath: '',
|
||||
githubUrl: 'https://github.com/example/repo',
|
||||
userId: 1,
|
||||
},
|
||||
{
|
||||
onProgress: () => undefined,
|
||||
onComplete: () => undefined,
|
||||
},
|
||||
buildDependencies(),
|
||||
),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof AppError);
|
||||
assert.equal(error.code, 'WORKSPACE_PATH_REQUIRED');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('startCloneProject rejects when github URL is missing', async () => {
|
||||
await assert.rejects(
|
||||
async () =>
|
||||
startCloneProject(
|
||||
{
|
||||
workspacePath: '/workspace/root',
|
||||
githubUrl: '',
|
||||
userId: 1,
|
||||
},
|
||||
{
|
||||
onProgress: () => undefined,
|
||||
onComplete: () => undefined,
|
||||
},
|
||||
buildDependencies(),
|
||||
),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof AppError);
|
||||
assert.equal(error.code, 'GITHUB_URL_REQUIRED');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('startCloneProject rejects github URL values that begin with option prefixes', async () => {
|
||||
await assert.rejects(
|
||||
async () =>
|
||||
startCloneProject(
|
||||
{
|
||||
workspacePath: '/workspace/root',
|
||||
githubUrl: '--upload-pack=malicious',
|
||||
userId: 1,
|
||||
},
|
||||
{
|
||||
onProgress: () => undefined,
|
||||
onComplete: () => undefined,
|
||||
},
|
||||
buildDependencies(),
|
||||
),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof AppError);
|
||||
assert.equal(error.code, 'INVALID_GITHUB_URL');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('startCloneProject rejects when selected github token does not exist', async () => {
|
||||
await assert.rejects(
|
||||
async () =>
|
||||
startCloneProject(
|
||||
{
|
||||
workspacePath: '/workspace/root',
|
||||
githubUrl: 'https://github.com/example/repo',
|
||||
githubTokenId: 12,
|
||||
userId: 1,
|
||||
},
|
||||
{
|
||||
onProgress: () => undefined,
|
||||
onComplete: () => undefined,
|
||||
},
|
||||
buildDependencies({
|
||||
getGithubTokenById: async () => null,
|
||||
}),
|
||||
),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof AppError);
|
||||
assert.equal(error.code, 'GITHUB_TOKEN_NOT_FOUND');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('startCloneProject completes and emits complete payload when git exits successfully', async () => {
|
||||
const gitProcess = createMockGitProcess();
|
||||
const progressMessages: string[] = [];
|
||||
let completePayload: { project: Record<string, unknown>; message: string } | null = null;
|
||||
let capturedProjectPath = '';
|
||||
let capturedCustomName = '';
|
||||
|
||||
const operation = await startCloneProject(
|
||||
{
|
||||
workspacePath: '/workspace/root',
|
||||
githubUrl: 'https://github.com/example/repo.git',
|
||||
userId: 1,
|
||||
},
|
||||
{
|
||||
onProgress: (message) => {
|
||||
progressMessages.push(message);
|
||||
},
|
||||
onComplete: (payload: { project: Record<string, unknown>; message: string }) => {
|
||||
completePayload = payload;
|
||||
},
|
||||
},
|
||||
buildDependencies({
|
||||
spawnGitClone: () => gitProcess as any,
|
||||
registerProject: async (projectPath, customName) => {
|
||||
capturedProjectPath = projectPath;
|
||||
capturedCustomName = customName;
|
||||
return { project: { projectId: 'project-1', path: projectPath } };
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
gitProcess.emit('close', 0);
|
||||
await operation.waitForCompletion;
|
||||
|
||||
assert.ok(progressMessages.some((message) => message.includes("Cloning into 'repo'")));
|
||||
assert.equal(capturedCustomName, 'repo');
|
||||
assert.equal(path.basename(capturedProjectPath), 'repo');
|
||||
assert.notEqual(completePayload, null);
|
||||
const resolvedCompletePayload = completePayload as unknown as {
|
||||
project: Record<string, unknown>;
|
||||
message: string;
|
||||
};
|
||||
assert.equal(resolvedCompletePayload.message, 'Repository cloned successfully');
|
||||
assert.equal((resolvedCompletePayload.project.projectId as string) || '', 'project-1');
|
||||
});
|
||||
117
server/modules/projects/tests/project-management.service.test.ts
Normal file
117
server/modules/projects/tests/project-management.service.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createProject } from '@/modules/projects/services/project-management.service.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
const projectRow = {
|
||||
project_id: 'project-1',
|
||||
project_path: '/workspace/my-project',
|
||||
custom_project_name: 'my-project',
|
||||
isStarred: 0,
|
||||
isArchived: 0,
|
||||
};
|
||||
|
||||
test('createProject throws when project path is missing', async () => {
|
||||
await assert.rejects(
|
||||
async () => createProject({ projectPath: '' }),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof AppError);
|
||||
assert.equal(error.code, 'PROJECT_PATH_REQUIRED');
|
||||
assert.equal(error.statusCode, 400);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('createProject throws when path validation fails', async () => {
|
||||
await assert.rejects(
|
||||
async () =>
|
||||
createProject(
|
||||
{ projectPath: '/invalid/path' },
|
||||
{
|
||||
validatePath: async () => ({ valid: false, error: 'blocked path' }),
|
||||
ensureWorkspaceDirectory: async () => undefined,
|
||||
persistProjectPath: () => ({ outcome: 'created', project: projectRow }),
|
||||
getProjectByPath: () => projectRow,
|
||||
},
|
||||
),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof AppError);
|
||||
assert.equal(error.code, 'INVALID_PROJECT_PATH');
|
||||
assert.equal(error.statusCode, 400);
|
||||
assert.equal(error.details, 'blocked path');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('createProject throws conflict when active project path already exists', async () => {
|
||||
await assert.rejects(
|
||||
async () =>
|
||||
createProject(
|
||||
{ projectPath: '/workspace/my-project' },
|
||||
{
|
||||
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/my-project' }),
|
||||
ensureWorkspaceDirectory: async () => undefined,
|
||||
persistProjectPath: () => ({ outcome: 'active_conflict', project: projectRow }),
|
||||
getProjectByPath: () => projectRow,
|
||||
},
|
||||
),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof AppError);
|
||||
assert.equal(error.code, 'PROJECT_ALREADY_EXISTS');
|
||||
assert.equal(error.statusCode, 409);
|
||||
assert.equal(error.details, 'Project path already exists: /workspace/my-project');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('createProject falls back to directory name when custom name is not provided', async () => {
|
||||
let capturedCustomName: string | null = null;
|
||||
|
||||
const result = await createProject(
|
||||
{ projectPath: '/workspace/my-project', customName: '' },
|
||||
{
|
||||
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/my-project' }),
|
||||
ensureWorkspaceDirectory: async () => undefined,
|
||||
persistProjectPath: (_projectPath, customName) => {
|
||||
capturedCustomName = customName;
|
||||
return {
|
||||
outcome: 'created',
|
||||
project: {
|
||||
...projectRow,
|
||||
custom_project_name: customName,
|
||||
},
|
||||
};
|
||||
},
|
||||
getProjectByPath: () => projectRow,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(capturedCustomName, 'my-project');
|
||||
assert.equal(result.outcome, 'created');
|
||||
assert.equal(result.project.displayName, 'my-project');
|
||||
});
|
||||
|
||||
test('createProject returns archived reuse outcome when archived row is reused', async () => {
|
||||
const result = await createProject(
|
||||
{ projectPath: '/workspace/my-project' },
|
||||
{
|
||||
validatePath: async () => ({ valid: true, resolvedPath: '/workspace/my-project' }),
|
||||
ensureWorkspaceDirectory: async () => undefined,
|
||||
persistProjectPath: () => ({
|
||||
outcome: 'reactivated_archived',
|
||||
project: {
|
||||
...projectRow,
|
||||
isArchived: 1,
|
||||
},
|
||||
}),
|
||||
getProjectByPath: () => projectRow,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.outcome, 'reactivated_archived');
|
||||
assert.equal(result.project.isArchived, true);
|
||||
});
|
||||
123
server/modules/projects/tests/project-star.service.test.ts
Normal file
123
server/modules/projects/tests/project-star.service.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { projectsDb } from '@/modules/database/index.js';
|
||||
import { applyLegacyStarredProjectIds, toggleProjectStar } from '@/modules/projects/services/project-star.service.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
type ProjectRow = {
|
||||
project_id: string;
|
||||
project_path: string;
|
||||
custom_project_name: string | null;
|
||||
isStarred: number;
|
||||
isArchived: number;
|
||||
};
|
||||
|
||||
test('toggleProjectStar throws when projectId is missing', () => {
|
||||
assert.throws(
|
||||
() => toggleProjectStar(' '),
|
||||
(error: unknown) =>
|
||||
error instanceof AppError
|
||||
&& error.code === 'PROJECT_ID_REQUIRED'
|
||||
&& error.statusCode === 400,
|
||||
);
|
||||
});
|
||||
|
||||
test('toggleProjectStar throws when project does not exist', () => {
|
||||
const originalGetProjectById = projectsDb.getProjectById;
|
||||
try {
|
||||
projectsDb.getProjectById = () => null;
|
||||
assert.throws(
|
||||
() => toggleProjectStar('project-1'),
|
||||
(error: unknown) =>
|
||||
error instanceof AppError
|
||||
&& error.code === 'PROJECT_NOT_FOUND'
|
||||
&& error.statusCode === 404,
|
||||
);
|
||||
} finally {
|
||||
projectsDb.getProjectById = originalGetProjectById;
|
||||
}
|
||||
});
|
||||
|
||||
test('toggleProjectStar flips star state and persists it', () => {
|
||||
const originalGetProjectById = projectsDb.getProjectById;
|
||||
const originalUpdateProjectIsStarredById = projectsDb.updateProjectIsStarredById;
|
||||
|
||||
let capturedProjectId = '';
|
||||
let capturedState = false;
|
||||
|
||||
try {
|
||||
projectsDb.getProjectById = () =>
|
||||
({
|
||||
project_id: 'project-1',
|
||||
project_path: '/workspace/project-1',
|
||||
custom_project_name: 'project-1',
|
||||
isStarred: 0,
|
||||
isArchived: 0,
|
||||
}) as ProjectRow;
|
||||
projectsDb.updateProjectIsStarredById = (projectId: string, isStarred: boolean) => {
|
||||
capturedProjectId = projectId;
|
||||
capturedState = isStarred;
|
||||
};
|
||||
|
||||
const result = toggleProjectStar('project-1');
|
||||
|
||||
assert.equal(result.isStarred, true);
|
||||
assert.equal(capturedProjectId, 'project-1');
|
||||
assert.equal(capturedState, true);
|
||||
} finally {
|
||||
projectsDb.getProjectById = originalGetProjectById;
|
||||
projectsDb.updateProjectIsStarredById = originalUpdateProjectIsStarredById;
|
||||
}
|
||||
});
|
||||
|
||||
test('applyLegacyStarredProjectIds stars only valid, unstarred projects', () => {
|
||||
const originalGetProjectById = projectsDb.getProjectById;
|
||||
const originalUpdateProjectIsStarredById = projectsDb.updateProjectIsStarredById;
|
||||
|
||||
const updatedProjectIds: string[] = [];
|
||||
|
||||
try {
|
||||
projectsDb.getProjectById = (projectId: string) => {
|
||||
if (projectId === 'project-a') {
|
||||
return {
|
||||
project_id: 'project-a',
|
||||
project_path: '/workspace/project-a',
|
||||
custom_project_name: 'A',
|
||||
isStarred: 0,
|
||||
isArchived: 0,
|
||||
} as ProjectRow;
|
||||
}
|
||||
|
||||
if (projectId === 'project-b') {
|
||||
return {
|
||||
project_id: 'project-b',
|
||||
project_path: '/workspace/project-b',
|
||||
custom_project_name: 'B',
|
||||
isStarred: 1,
|
||||
isArchived: 0,
|
||||
} as ProjectRow;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
projectsDb.updateProjectIsStarredById = (projectId: string) => {
|
||||
updatedProjectIds.push(projectId);
|
||||
};
|
||||
|
||||
const result = applyLegacyStarredProjectIds([
|
||||
'project-a',
|
||||
'project-b',
|
||||
'missing-project',
|
||||
'project-a',
|
||||
'',
|
||||
' ',
|
||||
]);
|
||||
|
||||
assert.equal(result.updated, 1);
|
||||
assert.deepEqual(updatedProjectIds, ['project-a']);
|
||||
} finally {
|
||||
projectsDb.getProjectById = originalGetProjectById;
|
||||
projectsDb.updateProjectIsStarredById = originalUpdateProjectIsStarredById;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
getProjectTaskMaster,
|
||||
getProjectTaskMasterById,
|
||||
} from '@/modules/projects/services/projects-has-taskmaster.service.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
test('getProjectTaskMasterById returns null when project path is missing', async () => {
|
||||
const result = await getProjectTaskMasterById('project-1', {
|
||||
resolveProjectPathById: () => null,
|
||||
detectTaskMasterFolder: async () => {
|
||||
throw new Error('detectTaskMasterFolder should not be called when path is missing');
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('getProjectTaskMasterById returns configured status when taskmaster exists with essential files', async () => {
|
||||
const result = await getProjectTaskMasterById('project-1', {
|
||||
resolveProjectPathById: () => '/workspace/project-1',
|
||||
detectTaskMasterFolder: async () => ({
|
||||
hasTaskmaster: true,
|
||||
hasEssentialFiles: true,
|
||||
metadata: {
|
||||
taskCount: 3,
|
||||
subtaskCount: 0,
|
||||
completed: 1,
|
||||
pending: 2,
|
||||
inProgress: 0,
|
||||
review: 0,
|
||||
completionPercentage: 33,
|
||||
lastModified: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
assert.equal(result.projectId, 'project-1');
|
||||
assert.equal(result.projectPath, '/workspace/project-1');
|
||||
assert.equal(result.taskmaster.hasTaskmaster, true);
|
||||
assert.equal(result.taskmaster.hasEssentialFiles, true);
|
||||
assert.equal(result.taskmaster.status, 'configured');
|
||||
assert.deepEqual(result.taskmaster.metadata, {
|
||||
taskCount: 3,
|
||||
subtaskCount: 0,
|
||||
completed: 1,
|
||||
pending: 2,
|
||||
inProgress: 0,
|
||||
review: 0,
|
||||
completionPercentage: 33,
|
||||
lastModified: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('getProjectTaskMasterById returns not-configured status when taskmaster is missing', async () => {
|
||||
const result = await getProjectTaskMasterById('project-1', {
|
||||
resolveProjectPathById: () => '/workspace/project-1',
|
||||
detectTaskMasterFolder: async () => ({
|
||||
hasTaskmaster: false,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
assert.equal(result.taskmaster.hasTaskmaster, false);
|
||||
assert.equal(result.taskmaster.hasEssentialFiles, false);
|
||||
assert.equal(result.taskmaster.status, 'not-configured');
|
||||
assert.equal(result.taskmaster.metadata, null);
|
||||
});
|
||||
|
||||
test('getProjectTaskMaster throws when project id is missing', async () => {
|
||||
await assert.rejects(
|
||||
async () =>
|
||||
getProjectTaskMaster('', async () => ({
|
||||
projectId: 'project-1',
|
||||
projectPath: '/workspace/project-1',
|
||||
taskmaster: {
|
||||
hasTaskmaster: true,
|
||||
hasEssentialFiles: true,
|
||||
metadata: null,
|
||||
status: 'configured',
|
||||
},
|
||||
})),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof AppError);
|
||||
assert.equal(error.code, 'PROJECT_ID_REQUIRED');
|
||||
assert.equal(error.statusCode, 400);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('getProjectTaskMaster throws when project does not exist', async () => {
|
||||
await assert.rejects(
|
||||
async () => getProjectTaskMaster('project-that-does-not-exist', async () => null),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof AppError);
|
||||
assert.equal(error.code, 'PROJECT_NOT_FOUND');
|
||||
assert.equal(error.statusCode, 404);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
4
server/modules/providers/index.ts
Normal file
4
server/modules/providers/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { sessionSynchronizerService } from './services/session-synchronizer.service.js';
|
||||
|
||||
export { initializeSessionsWatcher } from './services/sessions-watcher.service.js';
|
||||
export { closeSessionsWatcher } from './services/sessions-watcher.service.js';
|
||||
123
server/modules/providers/list/claude/claude-auth.provider.ts
Normal file
123
server/modules/providers/list/claude/claude-auth.provider.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||
import type { ProviderAuthStatus } from '@/shared/types.js';
|
||||
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
|
||||
|
||||
type ClaudeCredentialsStatus = {
|
||||
authenticated: boolean;
|
||||
email: string | null;
|
||||
method: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export class ClaudeProviderAuth implements IProviderAuth {
|
||||
/**
|
||||
* Checks whether the Claude Code CLI is available on this host.
|
||||
*/
|
||||
private checkInstalled(): boolean {
|
||||
const cliPath = process.env.CLAUDE_CLI_PATH || 'claude';
|
||||
try {
|
||||
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Claude installation and credential status using Claude Code's auth priority.
|
||||
*/
|
||||
async getStatus(): Promise<ProviderAuthStatus> {
|
||||
const installed = this.checkInstalled();
|
||||
|
||||
if (!installed) {
|
||||
return {
|
||||
installed,
|
||||
provider: 'claude',
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Claude Code CLI is not installed',
|
||||
};
|
||||
}
|
||||
|
||||
const credentials = await this.checkCredentials();
|
||||
|
||||
return {
|
||||
installed,
|
||||
provider: 'claude',
|
||||
authenticated: credentials.authenticated,
|
||||
email: credentials.authenticated ? credentials.email || 'Authenticated' : credentials.email,
|
||||
method: credentials.method,
|
||||
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads Claude settings env values that the CLI can use even when the server process env is empty.
|
||||
*/
|
||||
private async loadSettingsEnv(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
||||
const content = await readFile(settingsPath, 'utf8');
|
||||
const settings = readObjectRecord(JSON.parse(content));
|
||||
return readObjectRecord(settings?.env) ?? {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks Claude credentials in the same priority order used by Claude Code.
|
||||
*/
|
||||
private async checkCredentials(): Promise<ClaudeCredentialsStatus> {
|
||||
if (process.env.ANTHROPIC_API_KEY?.trim()) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||
}
|
||||
|
||||
const settingsEnv = await this.loadSettingsEnv();
|
||||
if (readOptionalString(settingsEnv.ANTHROPIC_API_KEY)) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||
}
|
||||
|
||||
if (readOptionalString(settingsEnv.ANTHROPIC_AUTH_TOKEN)) {
|
||||
return { authenticated: true, email: 'Configured via settings.json', method: 'api_key' };
|
||||
}
|
||||
|
||||
try {
|
||||
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
||||
const content = await readFile(credPath, 'utf8');
|
||||
const creds = readObjectRecord(JSON.parse(content)) ?? {};
|
||||
const oauth = readObjectRecord(creds.claudeAiOauth);
|
||||
const accessToken = readOptionalString(oauth?.accessToken);
|
||||
|
||||
if (accessToken) {
|
||||
const expiresAt = typeof oauth?.expiresAt === 'number' ? oauth.expiresAt : undefined;
|
||||
const email = readOptionalString(creds.email) ?? readOptionalString(creds.user) ?? null;
|
||||
if (!expiresAt || Date.now() < expiresAt) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email,
|
||||
method: 'credentials_file',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email,
|
||||
method: 'credentials_file',
|
||||
error: 'OAuth token has expired. Please re-authenticate with claude login',
|
||||
};
|
||||
}
|
||||
|
||||
return { authenticated: false, email: null, method: null };
|
||||
} catch {
|
||||
return { authenticated: false, email: null, method: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
135
server/modules/providers/list/claude/claude-mcp.provider.ts
Normal file
135
server/modules/providers/list/claude/claude-mcp.provider.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
|
||||
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import {
|
||||
AppError,
|
||||
readJsonConfig,
|
||||
readObjectRecord,
|
||||
readOptionalString,
|
||||
readStringArray,
|
||||
readStringRecord,
|
||||
writeJsonConfig,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
export class ClaudeMcpProvider extends McpProvider {
|
||||
constructor() {
|
||||
super('claude', ['user', 'local', 'project'], ['stdio', 'http', 'sse']);
|
||||
}
|
||||
|
||||
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
|
||||
if (scope === 'project') {
|
||||
const filePath = path.join(workspacePath, '.mcp.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
return readObjectRecord(config.mcpServers) ?? {};
|
||||
}
|
||||
|
||||
const filePath = path.join(os.homedir(), '.claude.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
if (scope === 'user') {
|
||||
return readObjectRecord(config.mcpServers) ?? {};
|
||||
}
|
||||
|
||||
const projects = readObjectRecord(config.projects) ?? {};
|
||||
const projectConfig = readObjectRecord(projects[workspacePath]) ?? {};
|
||||
return readObjectRecord(projectConfig.mcpServers) ?? {};
|
||||
}
|
||||
|
||||
protected async writeScopedServers(
|
||||
scope: McpScope,
|
||||
workspacePath: string,
|
||||
servers: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
if (scope === 'project') {
|
||||
const filePath = path.join(workspacePath, '.mcp.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
config.mcpServers = servers;
|
||||
await writeJsonConfig(filePath, config);
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.join(os.homedir(), '.claude.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
if (scope === 'user') {
|
||||
config.mcpServers = servers;
|
||||
await writeJsonConfig(filePath, config);
|
||||
return;
|
||||
}
|
||||
|
||||
const projects = readObjectRecord(config.projects) ?? {};
|
||||
const projectConfig = readObjectRecord(projects[workspacePath]) ?? {};
|
||||
projectConfig.mcpServers = servers;
|
||||
projects[workspacePath] = projectConfig;
|
||||
config.projects = projects;
|
||||
await writeJsonConfig(filePath, config);
|
||||
}
|
||||
|
||||
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
|
||||
if (input.transport === 'stdio') {
|
||||
if (!input.command?.trim()) {
|
||||
throw new AppError('command is required for stdio MCP servers.', {
|
||||
code: 'MCP_COMMAND_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'stdio',
|
||||
command: input.command,
|
||||
args: input.args ?? [],
|
||||
env: input.env ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.url?.trim()) {
|
||||
throw new AppError('url is required for http/sse MCP servers.', {
|
||||
code: 'MCP_URL_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: input.transport,
|
||||
url: input.url,
|
||||
headers: input.headers ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
protected normalizeServerConfig(
|
||||
scope: McpScope,
|
||||
name: string,
|
||||
rawConfig: unknown,
|
||||
): ProviderMcpServer | null {
|
||||
if (!rawConfig || typeof rawConfig !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = rawConfig as Record<string, unknown>;
|
||||
if (typeof config.command === 'string') {
|
||||
return {
|
||||
provider: 'claude',
|
||||
name,
|
||||
scope,
|
||||
transport: 'stdio',
|
||||
command: config.command,
|
||||
args: readStringArray(config.args),
|
||||
env: readStringRecord(config.env),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof config.url === 'string') {
|
||||
const transport = readOptionalString(config.type) === 'sse' ? 'sse' : 'http';
|
||||
return {
|
||||
provider: 'claude',
|
||||
name,
|
||||
scope,
|
||||
transport,
|
||||
url: config.url,
|
||||
headers: readStringRecord(config.headers),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import {
|
||||
buildLookupMap,
|
||||
extractFirstValidJsonlData,
|
||||
findFilesRecursivelyCreatedAfter,
|
||||
normalizeSessionName,
|
||||
readFileTimestamps,
|
||||
} from '@/shared/utils.js';
|
||||
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
|
||||
|
||||
type ParsedSession = {
|
||||
sessionId: string;
|
||||
projectPath: string;
|
||||
sessionName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Session indexer for Claude transcript artifacts.
|
||||
*/
|
||||
export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
private readonly provider = 'claude' as const;
|
||||
private readonly claudeHome = path.join(os.homedir(), '.claude');
|
||||
|
||||
/**
|
||||
* Scans ~/.claude/projects and upserts discovered sessions into DB.
|
||||
*/
|
||||
async synchronize(since?: Date): Promise<number> {
|
||||
const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display');
|
||||
const files = await findFilesRecursivelyCreatedAfter(
|
||||
path.join(this.claudeHome, 'projects'),
|
||||
'.jsonl',
|
||||
since ?? null
|
||||
);
|
||||
|
||||
let processed = 0;
|
||||
for (const filePath of files) {
|
||||
const parsed = await this.processSessionFile(filePath, nameMap);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timestamps = await readFileTimestamps(filePath);
|
||||
sessionsDb.createSession(
|
||||
parsed.sessionId,
|
||||
this.provider,
|
||||
parsed.projectPath,
|
||||
parsed.sessionName,
|
||||
timestamps.createdAt,
|
||||
timestamps.updatedAt,
|
||||
filePath
|
||||
);
|
||||
processed += 1;
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and upserts one Claude session JSONL file.
|
||||
*/
|
||||
async synchronizeFile(filePath: string): Promise<string | null> {
|
||||
if (!filePath.endsWith('.jsonl')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display');
|
||||
const parsed = await this.processSessionFile(filePath, nameMap);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamps = await readFileTimestamps(filePath);
|
||||
return sessionsDb.createSession(
|
||||
parsed.sessionId,
|
||||
this.provider,
|
||||
parsed.projectPath,
|
||||
parsed.sessionName,
|
||||
timestamps.createdAt,
|
||||
timestamps.updatedAt,
|
||||
filePath
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts session metadata from one Claude JSONL session file.
|
||||
*/
|
||||
private async processSessionFile(
|
||||
filePath: string,
|
||||
nameMap: Map<string, string>
|
||||
): Promise<ParsedSession | null> {
|
||||
return extractFirstValidJsonlData(filePath, (rawData) => {
|
||||
const data = rawData as Record<string, unknown>;
|
||||
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined;
|
||||
const projectPath = typeof data.cwd === 'string' ? data.cwd : undefined;
|
||||
|
||||
if (!sessionId || !projectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
projectPath,
|
||||
sessionName: normalizeSessionName(nameMap.get(sessionId), 'Untitled Claude Session'),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
475
server/modules/providers/list/claude/claude-sessions.provider.ts
Normal file
475
server/modules/providers/list/claude/claude-sessions.provider.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import readline from 'node:readline';
|
||||
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
|
||||
const PROVIDER = 'claude';
|
||||
|
||||
type ClaudeToolResult = {
|
||||
content: unknown;
|
||||
isError: boolean;
|
||||
subagentTools?: unknown;
|
||||
toolUseResult?: unknown;
|
||||
};
|
||||
|
||||
type ClaudeHistoryResult =
|
||||
| AnyRecord[]
|
||||
| {
|
||||
messages?: AnyRecord[];
|
||||
total?: number;
|
||||
hasMore?: boolean;
|
||||
};
|
||||
|
||||
type ClaudeHistoryMessagesResult =
|
||||
| AnyRecord[]
|
||||
| {
|
||||
messages: AnyRecord[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
offset?: number;
|
||||
limit?: number | null;
|
||||
};
|
||||
|
||||
async function parseAgentTools(filePath: string): Promise<AnyRecord[]> {
|
||||
const tools: AnyRecord[] = [];
|
||||
|
||||
try {
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = JSON.parse(line) as AnyRecord;
|
||||
|
||||
if (entry.message?.role === 'assistant' && Array.isArray(entry.message?.content)) {
|
||||
for (const part of entry.message.content as AnyRecord[]) {
|
||||
if (part.type === 'tool_use') {
|
||||
tools.push({
|
||||
toolId: part.id,
|
||||
toolName: part.name,
|
||||
toolInput: part.input,
|
||||
timestamp: entry.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.message?.role === 'user' && Array.isArray(entry.message?.content)) {
|
||||
for (const part of entry.message.content as AnyRecord[]) {
|
||||
if (part.type !== 'tool_result') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tool = tools.find((candidate) => candidate.toolId === part.tool_use_id);
|
||||
if (!tool) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tool.toolResult = {
|
||||
content: typeof part.content === 'string'
|
||||
? part.content
|
||||
: Array.isArray(part.content)
|
||||
? part.content
|
||||
.map((contentPart: AnyRecord) => contentPart?.text || '')
|
||||
.join('\n')
|
||||
: JSON.stringify(part.content),
|
||||
isError: Boolean(part.is_error),
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed lines that can happen during concurrent writes.
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`Error parsing agent file ${filePath}:`, message);
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
async function getSessionMessages(
|
||||
sessionId: string,
|
||||
limit: number | null,
|
||||
offset: number,
|
||||
): Promise<ClaudeHistoryMessagesResult> {
|
||||
try {
|
||||
const jsonLPath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
|
||||
|
||||
if (!jsonLPath) {
|
||||
return { messages: [], total: 0, hasMore: false };
|
||||
}
|
||||
|
||||
const projectDir = path.dirname(jsonLPath);
|
||||
const files = await fsp.readdir(projectDir);
|
||||
const agentFiles = files.filter((file) => file.endsWith('.jsonl') && file.startsWith('agent-'));
|
||||
|
||||
const messages: AnyRecord[] = [];
|
||||
const agentToolsCache = new Map<string, AnyRecord[]>();
|
||||
|
||||
const fileStream = fs.createReadStream(jsonLPath);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = JSON.parse(line) as AnyRecord;
|
||||
if (entry.sessionId === sessionId) {
|
||||
messages.push(entry);
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed JSONL lines that can happen during concurrent writes.
|
||||
}
|
||||
}
|
||||
|
||||
const agentIds = new Set<string>();
|
||||
for (const message of messages) {
|
||||
const agentId = message.toolUseResult?.agentId;
|
||||
if (agentId) {
|
||||
agentIds.add(String(agentId));
|
||||
}
|
||||
}
|
||||
|
||||
for (const agentId of agentIds) {
|
||||
const agentFileName = `agent-${agentId}.jsonl`;
|
||||
if (!agentFiles.includes(agentFileName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const agentFilePath = path.join(projectDir, agentFileName);
|
||||
const tools = await parseAgentTools(agentFilePath);
|
||||
agentToolsCache.set(agentId, tools);
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
const agentId = message.toolUseResult?.agentId;
|
||||
if (!agentId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const agentTools = agentToolsCache.get(String(agentId));
|
||||
if (agentTools && agentTools.length > 0) {
|
||||
message.subagentTools = agentTools;
|
||||
}
|
||||
}
|
||||
|
||||
const sortedMessages = messages.sort(
|
||||
(a, b) => new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime(),
|
||||
);
|
||||
const total = sortedMessages.length;
|
||||
|
||||
if (limit === null) {
|
||||
return sortedMessages;
|
||||
}
|
||||
|
||||
const startIndex = Math.max(0, total - offset - limit);
|
||||
const endIndex = total - offset;
|
||||
const paginatedMessages = sortedMessages.slice(startIndex, endIndex);
|
||||
const hasMore = startIndex > 0;
|
||||
|
||||
return {
|
||||
messages: paginatedMessages,
|
||||
total,
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error reading messages for session ${sessionId}:`, error);
|
||||
return limit === null ? [] : { messages: [], total: 0, hasMore: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude writes internal command and system reminder entries into history.
|
||||
* Those are useful for the CLI but should not appear in the user-facing chat.
|
||||
*/
|
||||
const INTERNAL_CONTENT_PREFIXES = [
|
||||
'<command-name>',
|
||||
'<command-message>',
|
||||
'<command-args>',
|
||||
'<local-command-stdout>',
|
||||
'<system-reminder>',
|
||||
'Caveat:',
|
||||
'This session is being continued from a previous',
|
||||
'[Request interrupted',
|
||||
] as const;
|
||||
|
||||
function isInternalContent(content: string): boolean {
|
||||
return INTERNAL_CONTENT_PREFIXES.some((prefix) => content.startsWith(prefix));
|
||||
}
|
||||
|
||||
export class ClaudeSessionsProvider implements IProviderSessions {
|
||||
/**
|
||||
* Normalizes one Claude JSONL entry or live SDK stream event into the shared
|
||||
* message shape consumed by REST and WebSocket clients.
|
||||
*/
|
||||
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
|
||||
const raw = readObjectRecord(rawMessage);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (raw.type === 'content_block_delta' && raw.delta?.text) {
|
||||
return [createNormalizedMessage({ kind: 'stream_delta', content: raw.delta.text, sessionId, provider: PROVIDER })];
|
||||
}
|
||||
if (raw.type === 'content_block_stop') {
|
||||
return [createNormalizedMessage({ kind: 'stream_end', sessionId, provider: PROVIDER })];
|
||||
}
|
||||
|
||||
const messages: NormalizedMessage[] = [];
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('claude');
|
||||
|
||||
if (raw.message?.role === 'user' && raw.message?.content) {
|
||||
if (Array.isArray(raw.message.content)) {
|
||||
for (let partIndex = 0; partIndex < raw.message.content.length; partIndex++) {
|
||||
const part = raw.message.content[partIndex];
|
||||
if (part.type === 'tool_result') {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_tr_${part.tool_use_id}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: part.tool_use_id,
|
||||
content: typeof part.content === 'string' ? part.content : JSON.stringify(part.content),
|
||||
isError: Boolean(part.is_error),
|
||||
subagentTools: raw.subagentTools,
|
||||
toolUseResult: raw.toolUseResult,
|
||||
}));
|
||||
} else if (part.type === 'text') {
|
||||
const text = part.text || '';
|
||||
if (text && !isInternalContent(text)) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_text_${partIndex}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content: text,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
const textParts = raw.message.content
|
||||
.filter((part: AnyRecord) => part.type === 'text')
|
||||
.map((part: AnyRecord) => part.text)
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
if (textParts && !isInternalContent(textParts)) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_text`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content: textParts,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else if (typeof raw.message.content === 'string') {
|
||||
const text = raw.message.content;
|
||||
if (text && !isInternalContent(text)) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content: text,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
if (raw.type === 'thinking' && raw.message?.content) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: raw.message.content,
|
||||
}));
|
||||
return messages;
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_use' && raw.toolName) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: raw.toolName,
|
||||
toolInput: raw.toolInput,
|
||||
toolId: raw.toolCallId || baseId,
|
||||
}));
|
||||
return messages;
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_result') {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: raw.toolCallId || '',
|
||||
content: raw.output || '',
|
||||
isError: false,
|
||||
}));
|
||||
return messages;
|
||||
}
|
||||
|
||||
if (raw.message?.role === 'assistant' && raw.message?.content) {
|
||||
if (Array.isArray(raw.message.content)) {
|
||||
let partIndex = 0;
|
||||
for (const part of raw.message.content) {
|
||||
if (part.type === 'text' && part.text) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIndex}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'assistant',
|
||||
content: part.text,
|
||||
}));
|
||||
} else if (part.type === 'tool_use') {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIndex}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: part.name,
|
||||
toolInput: part.input,
|
||||
toolId: part.id,
|
||||
}));
|
||||
} else if (part.type === 'thinking' && part.thinking) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIndex}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: part.thinking,
|
||||
}));
|
||||
}
|
||||
partIndex++;
|
||||
}
|
||||
} else if (typeof raw.message.content === 'string') {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'assistant',
|
||||
content: raw.message.content,
|
||||
}));
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads Claude JSONL history for a project/session and returns normalized
|
||||
* messages, preserving the existing pagination behavior from projects.js.
|
||||
*/
|
||||
async fetchHistory(
|
||||
sessionId: string,
|
||||
options: FetchHistoryOptions = {},
|
||||
): Promise<FetchHistoryResult> {
|
||||
const { limit = null, offset = 0 } = options;
|
||||
|
||||
let result: ClaudeHistoryResult;
|
||||
try {
|
||||
result = await getSessionMessages(sessionId, limit, offset);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message);
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
|
||||
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
|
||||
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
|
||||
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
|
||||
|
||||
const toolResultMap = new Map<string, ClaudeToolResult>();
|
||||
for (const raw of rawMessages) {
|
||||
if (raw.message?.role === 'user' && Array.isArray(raw.message?.content)) {
|
||||
for (const part of raw.message.content) {
|
||||
if (part.type === 'tool_result' && part.tool_use_id) {
|
||||
toolResultMap.set(part.tool_use_id, {
|
||||
content: part.content,
|
||||
isError: Boolean(part.is_error),
|
||||
subagentTools: raw.subagentTools,
|
||||
toolUseResult: raw.toolUseResult,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalized: NormalizedMessage[] = [];
|
||||
for (const raw of rawMessages) {
|
||||
normalized.push(...this.normalizeMessage(raw, sessionId));
|
||||
}
|
||||
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
|
||||
const toolResult = toolResultMap.get(msg.toolId);
|
||||
if (!toolResult) {
|
||||
continue;
|
||||
}
|
||||
|
||||
msg.toolResult = {
|
||||
content: typeof toolResult.content === 'string'
|
||||
? toolResult.content
|
||||
: JSON.stringify(toolResult.content),
|
||||
isError: toolResult.isError,
|
||||
toolUseResult: toolResult.toolUseResult,
|
||||
};
|
||||
msg.subagentTools = toolResult.subagentTools;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages: normalized,
|
||||
total,
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
}
|
||||
17
server/modules/providers/list/claude/claude.provider.ts
Normal file
17
server/modules/providers/list/claude/claude.provider.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||
import { ClaudeProviderAuth } from '@/modules/providers/list/claude/claude-auth.provider.js';
|
||||
import { ClaudeMcpProvider } from '@/modules/providers/list/claude/claude-mcp.provider.js';
|
||||
import { ClaudeSessionSynchronizer } from '@/modules/providers/list/claude/claude-session-synchronizer.provider.js';
|
||||
import { ClaudeSessionsProvider } from '@/modules/providers/list/claude/claude-sessions.provider.js';
|
||||
import type { IProviderAuth, IProviderSessionSynchronizer, IProviderSessions } from '@/shared/interfaces.js';
|
||||
|
||||
export class ClaudeProvider extends AbstractProvider {
|
||||
readonly mcp = new ClaudeMcpProvider();
|
||||
readonly auth: IProviderAuth = new ClaudeProviderAuth();
|
||||
readonly sessions: IProviderSessions = new ClaudeSessionsProvider();
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizer = new ClaudeSessionSynchronizer();
|
||||
|
||||
constructor() {
|
||||
super('claude');
|
||||
}
|
||||
}
|
||||
100
server/modules/providers/list/codex/codex-auth.provider.ts
Normal file
100
server/modules/providers/list/codex/codex-auth.provider.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||
import type { ProviderAuthStatus } from '@/shared/types.js';
|
||||
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
|
||||
|
||||
type CodexCredentialsStatus = {
|
||||
authenticated: boolean;
|
||||
email: string | null;
|
||||
method: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export class CodexProviderAuth implements IProviderAuth {
|
||||
/**
|
||||
* Checks whether Codex is available to the server runtime.
|
||||
*/
|
||||
private checkInstalled(): boolean {
|
||||
try {
|
||||
spawn.sync('codex', ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Codex SDK availability and credential status.
|
||||
*/
|
||||
async getStatus(): Promise<ProviderAuthStatus> {
|
||||
const installed = this.checkInstalled();
|
||||
const credentials = await this.checkCredentials();
|
||||
|
||||
return {
|
||||
installed,
|
||||
provider: 'codex',
|
||||
authenticated: credentials.authenticated,
|
||||
email: credentials.email,
|
||||
method: credentials.method,
|
||||
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads Codex auth.json and checks OAuth tokens or an API key fallback.
|
||||
*/
|
||||
private async checkCredentials(): Promise<CodexCredentialsStatus> {
|
||||
try {
|
||||
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
|
||||
const content = await readFile(authPath, 'utf8');
|
||||
const auth = readObjectRecord(JSON.parse(content)) ?? {};
|
||||
const tokens = readObjectRecord(auth.tokens) ?? {};
|
||||
const idToken = readOptionalString(tokens.id_token);
|
||||
const accessToken = readOptionalString(tokens.access_token);
|
||||
|
||||
if (idToken || accessToken) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: idToken ? this.readEmailFromIdToken(idToken) : 'Authenticated',
|
||||
method: 'credentials_file',
|
||||
};
|
||||
}
|
||||
|
||||
if (readOptionalString(auth.OPENAI_API_KEY)) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||
}
|
||||
|
||||
return { authenticated: false, email: null, method: null, error: 'No valid tokens found' };
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: code === 'ENOENT' ? 'Codex not configured' : error instanceof Error ? error.message : 'Failed to read Codex auth',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the user email from a Codex id_token when a readable JWT payload exists.
|
||||
*/
|
||||
private readEmailFromIdToken(idToken: string): string {
|
||||
try {
|
||||
const parts = idToken.split('.');
|
||||
if (parts.length >= 2) {
|
||||
const payload = readObjectRecord(JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')));
|
||||
return readOptionalString(payload?.email) ?? readOptionalString(payload?.user) ?? 'Authenticated';
|
||||
}
|
||||
} catch {
|
||||
// Fall back to a generic authenticated marker if the token payload is not readable.
|
||||
}
|
||||
|
||||
return 'Authenticated';
|
||||
}
|
||||
}
|
||||
135
server/modules/providers/list/codex/codex-mcp.provider.ts
Normal file
135
server/modules/providers/list/codex/codex-mcp.provider.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import TOML from '@iarna/toml';
|
||||
|
||||
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
|
||||
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import {
|
||||
AppError,
|
||||
readObjectRecord,
|
||||
readOptionalString,
|
||||
readStringArray,
|
||||
readStringRecord,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
const readTomlConfig = async (filePath: string): Promise<Record<string, unknown>> => {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const parsed = TOML.parse(content) as Record<string, unknown>;
|
||||
return readObjectRecord(parsed) ?? {};
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const writeTomlConfig = async (filePath: string, data: Record<string, unknown>): Promise<void> => {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
const toml = TOML.stringify(data as never);
|
||||
await writeFile(filePath, toml, 'utf8');
|
||||
};
|
||||
|
||||
export class CodexMcpProvider extends McpProvider {
|
||||
constructor() {
|
||||
super('codex', ['user', 'project'], ['stdio', 'http']);
|
||||
}
|
||||
|
||||
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.codex', 'config.toml')
|
||||
: path.join(workspacePath, '.codex', 'config.toml');
|
||||
const config = await readTomlConfig(filePath);
|
||||
return readObjectRecord(config.mcp_servers) ?? {};
|
||||
}
|
||||
|
||||
protected async writeScopedServers(
|
||||
scope: McpScope,
|
||||
workspacePath: string,
|
||||
servers: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.codex', 'config.toml')
|
||||
: path.join(workspacePath, '.codex', 'config.toml');
|
||||
const config = await readTomlConfig(filePath);
|
||||
config.mcp_servers = servers;
|
||||
await writeTomlConfig(filePath, config);
|
||||
}
|
||||
|
||||
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
|
||||
if (input.transport === 'stdio') {
|
||||
if (!input.command?.trim()) {
|
||||
throw new AppError('command is required for stdio MCP servers.', {
|
||||
code: 'MCP_COMMAND_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
command: input.command,
|
||||
args: input.args ?? [],
|
||||
env: input.env ?? {},
|
||||
env_vars: input.envVars ?? [],
|
||||
cwd: input.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.url?.trim()) {
|
||||
throw new AppError('url is required for http MCP servers.', {
|
||||
code: 'MCP_URL_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
url: input.url,
|
||||
bearer_token_env_var: input.bearerTokenEnvVar,
|
||||
http_headers: input.headers ?? {},
|
||||
env_http_headers: input.envHttpHeaders ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
protected normalizeServerConfig(
|
||||
scope: McpScope,
|
||||
name: string,
|
||||
rawConfig: unknown,
|
||||
): ProviderMcpServer | null {
|
||||
if (!rawConfig || typeof rawConfig !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = rawConfig as Record<string, unknown>;
|
||||
if (typeof config.command === 'string') {
|
||||
return {
|
||||
provider: 'codex',
|
||||
name,
|
||||
scope,
|
||||
transport: 'stdio',
|
||||
command: config.command,
|
||||
args: readStringArray(config.args),
|
||||
env: readStringRecord(config.env),
|
||||
cwd: readOptionalString(config.cwd),
|
||||
envVars: readStringArray(config.env_vars),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof config.url === 'string') {
|
||||
return {
|
||||
provider: 'codex',
|
||||
name,
|
||||
scope,
|
||||
transport: 'http',
|
||||
url: config.url,
|
||||
headers: readStringRecord(config.http_headers),
|
||||
bearerTokenEnvVar: readOptionalString(config.bearer_token_env_var),
|
||||
envHttpHeaders: readStringRecord(config.env_http_headers),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import {
|
||||
buildLookupMap,
|
||||
extractFirstValidJsonlData,
|
||||
findFilesRecursivelyCreatedAfter,
|
||||
normalizeSessionName,
|
||||
readFileTimestamps,
|
||||
} from '@/shared/utils.js';
|
||||
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
|
||||
|
||||
type ParsedSession = {
|
||||
sessionId: string;
|
||||
projectPath: string;
|
||||
sessionName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Session indexer for Codex transcript artifacts.
|
||||
*/
|
||||
export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
private readonly provider = 'codex' as const;
|
||||
private readonly codexHome = path.join(os.homedir(), '.codex');
|
||||
|
||||
/**
|
||||
* Scans ~/.codex/sessions and upserts discovered sessions into DB.
|
||||
*/
|
||||
async synchronize(since?: Date): Promise<number> {
|
||||
const nameMap = await buildLookupMap(path.join(this.codexHome, 'session_index.jsonl'), 'id', 'thread_name');
|
||||
const files = await findFilesRecursivelyCreatedAfter(
|
||||
path.join(this.codexHome, 'sessions'),
|
||||
'.jsonl',
|
||||
since ?? null
|
||||
);
|
||||
|
||||
let processed = 0;
|
||||
for (const filePath of files) {
|
||||
const parsed = await this.processSessionFile(filePath, nameMap);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
|
||||
if (existingSession) {
|
||||
// If session name is untitled and we now have a name, update it
|
||||
if (existingSession.custom_name === 'Untitled Codex Session' && parsed.sessionName && parsed.sessionName !== 'Untitled Codex Session') {
|
||||
sessionsDb.updateSessionCustomName(parsed.sessionId, parsed.sessionName);
|
||||
}
|
||||
}
|
||||
|
||||
const timestamps = await readFileTimestamps(filePath);
|
||||
sessionsDb.createSession(
|
||||
parsed.sessionId,
|
||||
this.provider,
|
||||
parsed.projectPath,
|
||||
parsed.sessionName,
|
||||
timestamps.createdAt,
|
||||
timestamps.updatedAt,
|
||||
filePath
|
||||
);
|
||||
processed += 1;
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and upserts one Codex session JSONL file.
|
||||
*/
|
||||
async synchronizeFile(filePath: string): Promise<string | null> {
|
||||
if (!filePath.endsWith('.jsonl')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nameMap = await buildLookupMap(path.join(this.codexHome, 'session_index.jsonl'), 'id', 'thread_name');
|
||||
const parsed = await this.processSessionFile(filePath, nameMap);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamps = await readFileTimestamps(filePath);
|
||||
return sessionsDb.createSession(
|
||||
parsed.sessionId,
|
||||
this.provider,
|
||||
parsed.projectPath,
|
||||
parsed.sessionName,
|
||||
timestamps.createdAt,
|
||||
timestamps.updatedAt,
|
||||
filePath
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts session metadata from one Codex JSONL session file.
|
||||
*/
|
||||
private async processSessionFile(
|
||||
filePath: string,
|
||||
nameMap: Map<string, string>
|
||||
): Promise<ParsedSession | null> {
|
||||
return extractFirstValidJsonlData(filePath, (rawData) => {
|
||||
const data = rawData as Record<string, unknown>;
|
||||
const payload = data.payload as Record<string, unknown> | undefined;
|
||||
const sessionId = typeof payload?.id === 'string' ? payload.id : undefined;
|
||||
const projectPath = typeof payload?.cwd === 'string' ? payload.cwd : undefined;
|
||||
|
||||
if (!sessionId || !projectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
projectPath,
|
||||
sessionName: normalizeSessionName(nameMap.get(sessionId), 'Untitled Codex Session'),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
564
server/modules/providers/list/codex/codex-sessions.provider.ts
Normal file
564
server/modules/providers/list/codex/codex-sessions.provider.ts
Normal file
@@ -0,0 +1,564 @@
|
||||
import fsSync from 'node:fs';
|
||||
import readline from 'node:readline';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'codex';
|
||||
|
||||
type CodexHistoryResult =
|
||||
| AnyRecord[]
|
||||
| {
|
||||
messages?: AnyRecord[];
|
||||
total?: number;
|
||||
hasMore?: boolean;
|
||||
offset?: number;
|
||||
limit?: number | null;
|
||||
tokenUsage?: unknown;
|
||||
};
|
||||
|
||||
function isVisibleCodexUserMessage(payload: AnyRecord | null | undefined): boolean {
|
||||
if (!payload || payload.type !== 'user_message') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.kind && payload.kind !== 'plain') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return typeof payload.message === 'string' && payload.message.trim().length > 0;
|
||||
}
|
||||
|
||||
function extractCodexTextContent(content: unknown): string {
|
||||
if (!Array.isArray(content)) {
|
||||
return typeof content === 'string' ? content : '';
|
||||
}
|
||||
|
||||
return content
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const record = item as AnyRecord;
|
||||
if (
|
||||
(record.type === 'input_text' || record.type === 'output_text' || record.type === 'text')
|
||||
&& typeof record.text === 'string'
|
||||
) {
|
||||
return record.text;
|
||||
}
|
||||
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
async function getCodexSessionMessages(
|
||||
sessionId: string,
|
||||
limit: number | null = null,
|
||||
offset = 0,
|
||||
): Promise<CodexHistoryResult> {
|
||||
try {
|
||||
const sessionFilePath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
|
||||
|
||||
if (!sessionFilePath) {
|
||||
console.warn(`Codex session file not found for session ${sessionId}`);
|
||||
return { messages: [], total: 0, hasMore: false };
|
||||
}
|
||||
|
||||
const messages: AnyRecord[] = [];
|
||||
let tokenUsage: AnyRecord | null = null;
|
||||
const fileStream = fsSync.createReadStream(sessionFilePath);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = JSON.parse(line) as AnyRecord;
|
||||
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
|
||||
const info = entry.payload.info as AnyRecord;
|
||||
if (info.total_token_usage) {
|
||||
const usage = info.total_token_usage as AnyRecord;
|
||||
tokenUsage = {
|
||||
used: usage.total_tokens || 0,
|
||||
total: info.model_context_window || 200000,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload as AnyRecord)) {
|
||||
messages.push({
|
||||
type: 'user',
|
||||
timestamp: entry.timestamp,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: entry.payload.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
entry.type === 'response_item' &&
|
||||
entry.payload?.type === 'message' &&
|
||||
entry.payload.role === 'assistant'
|
||||
) {
|
||||
const textContent = extractCodexTextContent(entry.payload.content);
|
||||
if (textContent.trim()) {
|
||||
messages.push({
|
||||
type: 'assistant',
|
||||
timestamp: entry.timestamp,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: textContent,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'reasoning') {
|
||||
const summaryText = Array.isArray(entry.payload.summary)
|
||||
? entry.payload.summary
|
||||
.map((item: AnyRecord) => item?.text)
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
: '';
|
||||
|
||||
if (summaryText.trim()) {
|
||||
messages.push({
|
||||
type: 'thinking',
|
||||
timestamp: entry.timestamp,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: summaryText,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'function_call') {
|
||||
let toolName = entry.payload.name;
|
||||
let toolInput = entry.payload.arguments;
|
||||
|
||||
if (toolName === 'shell_command') {
|
||||
toolName = 'Bash';
|
||||
try {
|
||||
const args = JSON.parse(entry.payload.arguments) as AnyRecord;
|
||||
toolInput = JSON.stringify({ command: args.command });
|
||||
} catch {
|
||||
// Keep original arguments when parsing fails.
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({
|
||||
type: 'tool_use',
|
||||
timestamp: entry.timestamp,
|
||||
toolName,
|
||||
toolInput,
|
||||
toolCallId: entry.payload.call_id,
|
||||
});
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'function_call_output') {
|
||||
messages.push({
|
||||
type: 'tool_result',
|
||||
timestamp: entry.timestamp,
|
||||
toolCallId: entry.payload.call_id,
|
||||
output: entry.payload.output,
|
||||
});
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call') {
|
||||
const toolName = entry.payload.name || 'custom_tool';
|
||||
const input = entry.payload.input || '';
|
||||
|
||||
if (toolName === 'apply_patch') {
|
||||
const fileMatch = String(input).match(/\*\*\* Update File: (.+)/);
|
||||
const filePath = fileMatch ? fileMatch[1].trim() : 'unknown';
|
||||
const lines = String(input).split('\n');
|
||||
const oldLines: string[] = [];
|
||||
const newLines: string[] = [];
|
||||
|
||||
for (const lineContent of lines) {
|
||||
if (lineContent.startsWith('-') && !lineContent.startsWith('---')) {
|
||||
oldLines.push(lineContent.slice(1));
|
||||
} else if (lineContent.startsWith('+') && !lineContent.startsWith('+++')) {
|
||||
newLines.push(lineContent.slice(1));
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({
|
||||
type: 'tool_use',
|
||||
timestamp: entry.timestamp,
|
||||
toolName: 'Edit',
|
||||
toolInput: JSON.stringify({
|
||||
file_path: filePath,
|
||||
old_string: oldLines.join('\n'),
|
||||
new_string: newLines.join('\n'),
|
||||
}),
|
||||
toolCallId: entry.payload.call_id,
|
||||
});
|
||||
} else {
|
||||
messages.push({
|
||||
type: 'tool_use',
|
||||
timestamp: entry.timestamp,
|
||||
toolName,
|
||||
toolInput: input,
|
||||
toolCallId: entry.payload.call_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call_output') {
|
||||
messages.push({
|
||||
type: 'tool_result',
|
||||
timestamp: entry.timestamp,
|
||||
toolCallId: entry.payload.call_id,
|
||||
output: entry.payload.output || '',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed lines.
|
||||
}
|
||||
}
|
||||
|
||||
messages.sort(
|
||||
(a, b) => new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime(),
|
||||
);
|
||||
const total = messages.length;
|
||||
|
||||
if (limit !== null) {
|
||||
const startIndex = Math.max(0, total - offset - limit);
|
||||
const endIndex = total - offset;
|
||||
const paginatedMessages = messages.slice(startIndex, endIndex);
|
||||
const hasMore = startIndex > 0;
|
||||
|
||||
return {
|
||||
messages: paginatedMessages,
|
||||
total,
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
tokenUsage,
|
||||
};
|
||||
}
|
||||
|
||||
return { messages, tokenUsage };
|
||||
} catch (error) {
|
||||
console.error(`Error reading Codex session messages for ${sessionId}:`, error);
|
||||
return { messages: [], total: 0, hasMore: false };
|
||||
}
|
||||
}
|
||||
|
||||
export class CodexSessionsProvider implements IProviderSessions {
|
||||
/**
|
||||
* Normalizes a persisted Codex JSONL entry.
|
||||
*
|
||||
* Live Codex SDK events are transformed before they reach normalizeMessage(),
|
||||
* while history entries already use a compact message/tool shape from projects.js.
|
||||
*/
|
||||
private normalizeHistoryEntry(raw: AnyRecord, sessionId: string | null): NormalizedMessage[] {
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('codex');
|
||||
|
||||
if (raw.type === 'thinking' || raw.isReasoning) {
|
||||
const thinkingContent = typeof raw.message?.content === 'string'
|
||||
? raw.message.content
|
||||
: '';
|
||||
if (!thinkingContent.trim()) {
|
||||
return [];
|
||||
}
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: thinkingContent,
|
||||
})];
|
||||
}
|
||||
|
||||
if (raw.message?.role === 'user') {
|
||||
const content = typeof raw.message.content === 'string'
|
||||
? raw.message.content
|
||||
: Array.isArray(raw.message.content)
|
||||
? raw.message.content
|
||||
.map((part: string | AnyRecord) => typeof part === 'string' ? part : part?.text || '')
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
: String(raw.message.content || '');
|
||||
if (!content.trim()) {
|
||||
return [];
|
||||
}
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content,
|
||||
})];
|
||||
}
|
||||
|
||||
if (raw.message?.role === 'assistant') {
|
||||
const content = typeof raw.message.content === 'string'
|
||||
? raw.message.content
|
||||
: Array.isArray(raw.message.content)
|
||||
? raw.message.content
|
||||
.map((part: string | AnyRecord) => typeof part === 'string' ? part : part?.text || '')
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
: '';
|
||||
if (!content.trim()) {
|
||||
return [];
|
||||
}
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'assistant',
|
||||
content,
|
||||
})];
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_use' || raw.toolName) {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: raw.toolName || 'Unknown',
|
||||
toolInput: raw.toolInput,
|
||||
toolId: raw.toolCallId || baseId,
|
||||
})];
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_result') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: raw.toolCallId || '',
|
||||
content: raw.output || '',
|
||||
isError: Boolean(raw.isError),
|
||||
})];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes either a Codex history entry or a transformed live SDK event.
|
||||
*/
|
||||
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
|
||||
const raw = readObjectRecord(rawMessage);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (raw.message?.role) {
|
||||
return this.normalizeHistoryEntry(raw, sessionId);
|
||||
}
|
||||
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('codex');
|
||||
|
||||
if (raw.type === 'item') {
|
||||
switch (raw.itemType) {
|
||||
case 'agent_message':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'assistant',
|
||||
content: raw.message?.content || '',
|
||||
})];
|
||||
case 'reasoning':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: raw.message?.content || '',
|
||||
})];
|
||||
case 'command_execution':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: 'Bash',
|
||||
toolInput: { command: raw.command },
|
||||
toolId: baseId,
|
||||
output: raw.output,
|
||||
exitCode: raw.exitCode,
|
||||
status: raw.status,
|
||||
})];
|
||||
case 'file_change':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: 'FileChanges',
|
||||
toolInput: raw.changes,
|
||||
toolId: baseId,
|
||||
status: raw.status,
|
||||
})];
|
||||
case 'mcp_tool_call':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: raw.tool || 'MCP',
|
||||
toolInput: raw.arguments,
|
||||
toolId: baseId,
|
||||
server: raw.server,
|
||||
result: raw.result,
|
||||
error: raw.error,
|
||||
status: raw.status,
|
||||
})];
|
||||
case 'web_search':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: 'WebSearch',
|
||||
toolInput: { query: raw.query },
|
||||
toolId: baseId,
|
||||
})];
|
||||
case 'todo_list':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: 'TodoList',
|
||||
toolInput: { items: raw.items },
|
||||
toolId: baseId,
|
||||
})];
|
||||
case 'error':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'error',
|
||||
content: raw.message?.content || 'Unknown error',
|
||||
})];
|
||||
default:
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: raw.itemType || 'Unknown',
|
||||
toolInput: raw.item || raw,
|
||||
toolId: baseId,
|
||||
})];
|
||||
}
|
||||
}
|
||||
|
||||
if (raw.type === 'turn_complete') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'complete',
|
||||
})];
|
||||
}
|
||||
if (raw.type === 'turn_failed') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'error',
|
||||
content: raw.error?.message || 'Turn failed',
|
||||
})];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads Codex JSONL history and keeps token usage metadata when projects.js
|
||||
* provides it.
|
||||
*/
|
||||
async fetchHistory(
|
||||
sessionId: string,
|
||||
options: FetchHistoryOptions = {},
|
||||
): Promise<FetchHistoryResult> {
|
||||
const { limit = null, offset = 0 } = options;
|
||||
|
||||
let result: CodexHistoryResult;
|
||||
try {
|
||||
result = await getCodexSessionMessages(sessionId, limit, offset);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[CodexProvider] Failed to load session ${sessionId}:`, message);
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
|
||||
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
|
||||
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
|
||||
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
|
||||
const tokenUsage = Array.isArray(result) ? undefined : result.tokenUsage;
|
||||
|
||||
const normalized: NormalizedMessage[] = [];
|
||||
for (const raw of rawMessages) {
|
||||
normalized.push(...this.normalizeHistoryEntry(raw, sessionId));
|
||||
}
|
||||
|
||||
const toolResultMap = new Map<string, NormalizedMessage>();
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_result' && msg.toolId) {
|
||||
toolResultMap.set(msg.toolId, msg);
|
||||
}
|
||||
}
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
|
||||
const toolResult = toolResultMap.get(msg.toolId);
|
||||
if (toolResult) {
|
||||
msg.toolResult = { content: toolResult.content, isError: toolResult.isError };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages: normalized,
|
||||
total,
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
tokenUsage,
|
||||
};
|
||||
}
|
||||
}
|
||||
17
server/modules/providers/list/codex/codex.provider.ts
Normal file
17
server/modules/providers/list/codex/codex.provider.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||
import { CodexProviderAuth } from '@/modules/providers/list/codex/codex-auth.provider.js';
|
||||
import { CodexMcpProvider } from '@/modules/providers/list/codex/codex-mcp.provider.js';
|
||||
import { CodexSessionSynchronizer } from '@/modules/providers/list/codex/codex-session-synchronizer.provider.js';
|
||||
import { CodexSessionsProvider } from '@/modules/providers/list/codex/codex-sessions.provider.js';
|
||||
import type { IProviderAuth, IProviderSessionSynchronizer, IProviderSessions } from '@/shared/interfaces.js';
|
||||
|
||||
export class CodexProvider extends AbstractProvider {
|
||||
readonly mcp = new CodexMcpProvider();
|
||||
readonly auth: IProviderAuth = new CodexProviderAuth();
|
||||
readonly sessions: IProviderSessions = new CodexSessionsProvider();
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizer = new CodexSessionSynchronizer();
|
||||
|
||||
constructor() {
|
||||
super('codex');
|
||||
}
|
||||
}
|
||||
143
server/modules/providers/list/cursor/cursor-auth.provider.ts
Normal file
143
server/modules/providers/list/cursor/cursor-auth.provider.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||
import type { ProviderAuthStatus } from '@/shared/types.js';
|
||||
|
||||
type CursorLoginStatus = {
|
||||
authenticated: boolean;
|
||||
email: string | null;
|
||||
method: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export class CursorProviderAuth implements IProviderAuth {
|
||||
/**
|
||||
* Checks whether the cursor-agent CLI is available on this host.
|
||||
*/
|
||||
private checkInstalled(): boolean {
|
||||
try {
|
||||
spawn.sync('cursor-agent', ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Cursor CLI installation and login status.
|
||||
*/
|
||||
async getStatus(): Promise<ProviderAuthStatus> {
|
||||
const installed = this.checkInstalled();
|
||||
|
||||
if (!installed) {
|
||||
return {
|
||||
installed,
|
||||
provider: 'cursor',
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Cursor CLI is not installed',
|
||||
};
|
||||
}
|
||||
|
||||
const login = await this.checkCursorLogin();
|
||||
|
||||
return {
|
||||
installed,
|
||||
provider: 'cursor',
|
||||
authenticated: login.authenticated,
|
||||
email: login.email,
|
||||
method: login.method,
|
||||
error: login.authenticated ? undefined : login.error || 'Not logged in',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs cursor-agent status and parses the login marker from stdout.
|
||||
*/
|
||||
private checkCursorLogin(): Promise<CursorLoginStatus> {
|
||||
return new Promise((resolve) => {
|
||||
let processCompleted = false;
|
||||
let childProcess: ReturnType<typeof spawn> | undefined;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!processCompleted) {
|
||||
processCompleted = true;
|
||||
childProcess?.kill();
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Command timeout',
|
||||
});
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
try {
|
||||
childProcess = spawn('cursor-agent', ['status']);
|
||||
} catch {
|
||||
clearTimeout(timeout);
|
||||
processCompleted = true;
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Cursor CLI not found or not installed',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
childProcess.stdout?.on('data', (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
childProcess.stderr?.on('data', (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
childProcess.on('close', (code) => {
|
||||
if (processCompleted) {
|
||||
return;
|
||||
}
|
||||
processCompleted = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (code === 0) {
|
||||
const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
|
||||
if (emailMatch?.[1]) {
|
||||
resolve({ authenticated: true, email: emailMatch[1], method: 'cli' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (stdout.includes('Logged in')) {
|
||||
resolve({ authenticated: true, email: 'Logged in', method: 'cli' });
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({ authenticated: false, email: null, method: null, error: 'Not logged in' });
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({ authenticated: false, email: null, method: null, error: stderr || 'Not logged in' });
|
||||
});
|
||||
|
||||
childProcess.on('error', () => {
|
||||
if (processCompleted) {
|
||||
return;
|
||||
}
|
||||
processCompleted = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Cursor CLI not found or not installed',
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
108
server/modules/providers/list/cursor/cursor-mcp.provider.ts
Normal file
108
server/modules/providers/list/cursor/cursor-mcp.provider.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
|
||||
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import {
|
||||
AppError,
|
||||
readJsonConfig,
|
||||
readObjectRecord,
|
||||
readOptionalString,
|
||||
readStringArray,
|
||||
readStringRecord,
|
||||
writeJsonConfig,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
export class CursorMcpProvider extends McpProvider {
|
||||
constructor() {
|
||||
super('cursor', ['user', 'project'], ['stdio', 'http']);
|
||||
}
|
||||
|
||||
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.cursor', 'mcp.json')
|
||||
: path.join(workspacePath, '.cursor', 'mcp.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
return readObjectRecord(config.mcpServers) ?? {};
|
||||
}
|
||||
|
||||
protected async writeScopedServers(
|
||||
scope: McpScope,
|
||||
workspacePath: string,
|
||||
servers: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.cursor', 'mcp.json')
|
||||
: path.join(workspacePath, '.cursor', 'mcp.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
config.mcpServers = servers;
|
||||
await writeJsonConfig(filePath, config);
|
||||
}
|
||||
|
||||
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
|
||||
if (input.transport === 'stdio') {
|
||||
if (!input.command?.trim()) {
|
||||
throw new AppError('command is required for stdio MCP servers.', {
|
||||
code: 'MCP_COMMAND_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
command: input.command,
|
||||
args: input.args ?? [],
|
||||
env: input.env ?? {},
|
||||
cwd: input.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.url?.trim()) {
|
||||
throw new AppError('url is required for http MCP servers.', {
|
||||
code: 'MCP_URL_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
url: input.url,
|
||||
headers: input.headers ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
protected normalizeServerConfig(
|
||||
scope: McpScope,
|
||||
name: string,
|
||||
rawConfig: unknown,
|
||||
): ProviderMcpServer | null {
|
||||
if (!rawConfig || typeof rawConfig !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = rawConfig as Record<string, unknown>;
|
||||
if (typeof config.command === 'string') {
|
||||
return {
|
||||
provider: 'cursor',
|
||||
name,
|
||||
scope,
|
||||
transport: 'stdio',
|
||||
command: config.command,
|
||||
args: readStringArray(config.args),
|
||||
env: readStringRecord(config.env),
|
||||
cwd: readOptionalString(config.cwd),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof config.url === 'string') {
|
||||
return {
|
||||
provider: 'cursor',
|
||||
name,
|
||||
scope,
|
||||
transport: 'http',
|
||||
url: config.url,
|
||||
headers: readStringRecord(config.headers),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import readline from 'node:readline';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import {
|
||||
extractFirstValidJsonlData,
|
||||
findFilesRecursivelyCreatedAfter,
|
||||
normalizeSessionName,
|
||||
readFileTimestamps,
|
||||
} from '@/shared/utils.js';
|
||||
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
|
||||
|
||||
type ParsedSession = {
|
||||
sessionId: string;
|
||||
projectPath: string;
|
||||
sessionName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns directory entries or an empty list when the folder is missing.
|
||||
*/
|
||||
async function listDirectoryEntriesSafe(
|
||||
directoryPath: string
|
||||
): Promise<import('node:fs').Dirent[]> {
|
||||
try {
|
||||
return await fsp.readdir(directoryPath, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Session indexer for Cursor transcript artifacts.
|
||||
*/
|
||||
export class CursorSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
private readonly provider = 'cursor' as const;
|
||||
private readonly cursorHome = path.join(os.homedir(), '.cursor');
|
||||
|
||||
/**
|
||||
* Scans Cursor chats and upserts discovered sessions into DB.
|
||||
*/
|
||||
async synchronize(since?: Date): Promise<number> {
|
||||
const projectsDir = path.join(this.cursorHome, 'projects');
|
||||
const projectEntries = await listDirectoryEntriesSafe(projectsDir);
|
||||
const seenProjectPaths = new Set<string>();
|
||||
|
||||
let processed = 0;
|
||||
for (const entry of projectEntries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const workerLogPath = path.join(projectsDir, entry.name, 'worker.log');
|
||||
const projectPath = await this.extractProjectPathFromWorkerLog(workerLogPath);
|
||||
if (!projectPath || seenProjectPaths.has(projectPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenProjectPaths.add(projectPath);
|
||||
const projectHash = this.md5(projectPath);
|
||||
const chatsDir = path.join(this.cursorHome, 'chats', projectHash);
|
||||
const files = await findFilesRecursivelyCreatedAfter(chatsDir, '.jsonl', since ?? null);
|
||||
|
||||
for (const filePath of files) {
|
||||
const parsed = await this.processSessionFile(filePath);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timestamps = await readFileTimestamps(filePath);
|
||||
sessionsDb.createSession(
|
||||
parsed.sessionId,
|
||||
this.provider,
|
||||
parsed.projectPath,
|
||||
parsed.sessionName,
|
||||
timestamps.createdAt,
|
||||
timestamps.updatedAt,
|
||||
filePath
|
||||
);
|
||||
processed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and upserts one Cursor session JSONL file.
|
||||
*/
|
||||
async synchronizeFile(filePath: string): Promise<string | null> {
|
||||
if (!filePath.endsWith('.jsonl')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = await this.processSessionFile(filePath);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamps = await readFileTimestamps(filePath);
|
||||
return sessionsDb.createSession(
|
||||
parsed.sessionId,
|
||||
this.provider,
|
||||
parsed.projectPath,
|
||||
parsed.sessionName,
|
||||
timestamps.createdAt,
|
||||
timestamps.updatedAt,
|
||||
filePath
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces the same project hash Cursor uses in chat directory names.
|
||||
*/
|
||||
private md5(input: string): string {
|
||||
return crypto.createHash('md5').update(input).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts project path from Cursor worker.log.
|
||||
*/
|
||||
private async extractProjectPathFromWorkerLog(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
||||
const lineReader = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
|
||||
|
||||
for await (const line of lineReader) {
|
||||
const match = line.match(/workspacePath=(.*)$/);
|
||||
const projectPath = match?.[1]?.trim();
|
||||
if (projectPath) {
|
||||
lineReader.close();
|
||||
fileStream.close();
|
||||
return projectPath;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Missing worker logs are valid for partial or incomplete session data.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts session metadata from one Cursor JSONL session file.
|
||||
*/
|
||||
private async processSessionFile(filePath: string): Promise<ParsedSession | null> {
|
||||
const sessionId = path.basename(filePath, '.jsonl');
|
||||
const grandparentDir = path.dirname(path.dirname(filePath));
|
||||
const workerLogPath = path.join(grandparentDir, 'worker.log');
|
||||
const projectPath = await this.extractProjectPathFromWorkerLog(workerLogPath);
|
||||
|
||||
if (!projectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return extractFirstValidJsonlData(filePath, (rawData) => {
|
||||
const data = rawData as Record<string, any>;
|
||||
if (data.role !== 'user') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = typeof data.message?.content?.[0]?.text === 'string' ? data.message.content[0].text : '';
|
||||
const firstLine = text.replace(/<\/?user_query>/g, '').trim().split('\n')[0];
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
projectPath,
|
||||
sessionName: normalizeSessionName(firstLine, 'Untitled Cursor Session'),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
421
server/modules/providers/list/cursor/cursor-sessions.provider.ts
Normal file
421
server/modules/providers/list/cursor/cursor-sessions.provider.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
import crypto from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'cursor';
|
||||
|
||||
type CursorDbBlob = {
|
||||
rowid: number;
|
||||
id: string;
|
||||
data?: Buffer;
|
||||
};
|
||||
|
||||
type CursorJsonBlob = CursorDbBlob & {
|
||||
parsed: AnyRecord;
|
||||
};
|
||||
|
||||
type CursorMessageBlob = {
|
||||
id: string;
|
||||
sequence: number;
|
||||
rowid: number;
|
||||
content: AnyRecord;
|
||||
};
|
||||
|
||||
function sanitizeCursorSessionId(sessionId: string): string {
|
||||
const normalized = sessionId.trim();
|
||||
if (!normalized) {
|
||||
throw new Error('Cursor session id is required.');
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('..')
|
||||
|| normalized.includes(path.posix.sep)
|
||||
|| normalized.includes(path.win32.sep)
|
||||
|| normalized !== path.basename(normalized)
|
||||
) {
|
||||
throw new Error(`Invalid cursor session id "${sessionId}".`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export class CursorSessionsProvider implements IProviderSessions {
|
||||
/**
|
||||
* Loads Cursor's SQLite blob DAG and returns message blobs in conversation
|
||||
* order. Cursor history is stored as content-addressed blobs rather than JSONL.
|
||||
*/
|
||||
private async loadCursorBlobs(sessionId: string, projectPath: string): Promise<CursorMessageBlob[]> {
|
||||
// Lazy-import better-sqlite3 so the module doesn't fail if it's unavailable
|
||||
const { default: Database } = await import('better-sqlite3');
|
||||
|
||||
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
|
||||
const safeSessionId = sanitizeCursorSessionId(sessionId);
|
||||
const baseChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
|
||||
const storeDbPath = path.join(baseChatsPath, safeSessionId, 'store.db');
|
||||
const resolvedBaseChatsPath = path.resolve(baseChatsPath);
|
||||
const resolvedStoreDbPath = path.resolve(storeDbPath);
|
||||
const relativeStorePath = path.relative(resolvedBaseChatsPath, resolvedStoreDbPath);
|
||||
if (relativeStorePath.startsWith('..') || path.isAbsolute(relativeStorePath)) {
|
||||
throw new Error(`Invalid cursor session path for "${sessionId}".`);
|
||||
}
|
||||
|
||||
const db = new Database(resolvedStoreDbPath, { readonly: true, fileMustExist: true });
|
||||
|
||||
try {
|
||||
const allBlobs = db.prepare<[], CursorDbBlob>('SELECT rowid, id, data FROM blobs').all();
|
||||
|
||||
const blobMap = new Map<string, CursorDbBlob>();
|
||||
const parentRefs = new Map<string, string[]>();
|
||||
const childRefs = new Map<string, string[]>();
|
||||
const jsonBlobs: CursorJsonBlob[] = [];
|
||||
|
||||
for (const blob of allBlobs) {
|
||||
blobMap.set(blob.id, blob);
|
||||
|
||||
if (blob.data && blob.data[0] === 0x7B) {
|
||||
try {
|
||||
const parsed = JSON.parse(blob.data.toString('utf8')) as AnyRecord;
|
||||
jsonBlobs.push({ ...blob, parsed });
|
||||
} catch {
|
||||
// Cursor can include binary or partial blobs; only JSON blobs become messages.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const blob of allBlobs) {
|
||||
if (!blob.data || blob.data[0] === 0x7B) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parents: string[] = [];
|
||||
let i = 0;
|
||||
while (i < blob.data.length - 33) {
|
||||
if (blob.data[i] === 0x0A && blob.data[i + 1] === 0x20) {
|
||||
const parentHash = blob.data.slice(i + 2, i + 34).toString('hex');
|
||||
if (blobMap.has(parentHash)) {
|
||||
parents.push(parentHash);
|
||||
}
|
||||
i += 34;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
if (parents.length > 0) {
|
||||
parentRefs.set(blob.id, parents);
|
||||
for (const parentId of parents) {
|
||||
if (!childRefs.has(parentId)) {
|
||||
childRefs.set(parentId, []);
|
||||
}
|
||||
childRefs.get(parentId)?.push(blob.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const visited = new Set<string>();
|
||||
const sorted: CursorDbBlob[] = [];
|
||||
const visit = (nodeId: string): void => {
|
||||
if (visited.has(nodeId)) {
|
||||
return;
|
||||
}
|
||||
visited.add(nodeId);
|
||||
for (const parentId of parentRefs.get(nodeId) || []) {
|
||||
visit(parentId);
|
||||
}
|
||||
const blob = blobMap.get(nodeId);
|
||||
if (blob) {
|
||||
sorted.push(blob);
|
||||
}
|
||||
};
|
||||
|
||||
for (const blob of allBlobs) {
|
||||
if (!parentRefs.has(blob.id)) {
|
||||
visit(blob.id);
|
||||
}
|
||||
}
|
||||
for (const blob of allBlobs) {
|
||||
visit(blob.id);
|
||||
}
|
||||
|
||||
const messageOrder = new Map<string, number>();
|
||||
let orderIndex = 0;
|
||||
for (const blob of sorted) {
|
||||
if (blob.data && blob.data[0] !== 0x7B) {
|
||||
for (const jsonBlob of jsonBlobs) {
|
||||
try {
|
||||
const idBytes = Buffer.from(jsonBlob.id, 'hex');
|
||||
if (blob.data.includes(idBytes) && !messageOrder.has(jsonBlob.id)) {
|
||||
messageOrder.set(jsonBlob.id, orderIndex++);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed blob ids that cannot be decoded as hex.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sortedJsonBlobs = jsonBlobs.sort((a, b) => {
|
||||
const aOrder = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
|
||||
const bOrder = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
|
||||
return aOrder !== bOrder ? aOrder - bOrder : a.rowid - b.rowid;
|
||||
});
|
||||
|
||||
const messages: CursorMessageBlob[] = [];
|
||||
for (let idx = 0; idx < sortedJsonBlobs.length; idx++) {
|
||||
const blob = sortedJsonBlobs[idx];
|
||||
const parsed = blob.parsed;
|
||||
const role = parsed?.role || parsed?.message?.role;
|
||||
if (role === 'system') {
|
||||
continue;
|
||||
}
|
||||
messages.push({
|
||||
id: blob.id,
|
||||
sequence: idx + 1,
|
||||
rowid: blob.rowid,
|
||||
content: parsed,
|
||||
});
|
||||
}
|
||||
|
||||
return messages;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes live Cursor CLI NDJSON events. Persisted Cursor history is
|
||||
* normalized from SQLite blobs in fetchHistory().
|
||||
*/
|
||||
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
|
||||
const raw = readObjectRecord(rawMessage);
|
||||
if (raw?.type === 'assistant' && raw.message?.content?.[0]?.text) {
|
||||
return [createNormalizedMessage({
|
||||
kind: 'stream_delta',
|
||||
content: raw.message.content[0].text,
|
||||
sessionId,
|
||||
provider: PROVIDER,
|
||||
})];
|
||||
}
|
||||
|
||||
if (typeof rawMessage === 'string' && rawMessage.trim()) {
|
||||
return [createNormalizedMessage({
|
||||
kind: 'stream_delta',
|
||||
content: rawMessage,
|
||||
sessionId,
|
||||
provider: PROVIDER,
|
||||
})];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and paginates Cursor session history from its project-scoped store.db.
|
||||
*/
|
||||
async fetchHistory(
|
||||
sessionId: string,
|
||||
options: FetchHistoryOptions = {},
|
||||
): Promise<FetchHistoryResult> {
|
||||
const { projectPath = '', limit = null, offset = 0 } = options;
|
||||
|
||||
try {
|
||||
const blobs = await this.loadCursorBlobs(sessionId, projectPath);
|
||||
const allNormalized = this.normalizeCursorBlobs(blobs, sessionId);
|
||||
const total = allNormalized.length;
|
||||
|
||||
if (limit !== null) {
|
||||
const start = offset;
|
||||
const page = limit === 0
|
||||
? []
|
||||
: allNormalized.slice(start, start + limit);
|
||||
const hasMore = limit === 0
|
||||
? start < total
|
||||
: start + limit < total;
|
||||
return {
|
||||
messages: page,
|
||||
total,
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
messages: allNormalized,
|
||||
total,
|
||||
hasMore: false,
|
||||
offset: 0,
|
||||
limit: null,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[CursorProvider] Failed to load session ${sessionId}:`, message);
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Cursor SQLite message blobs into normalized messages and attaches
|
||||
* matching tool results to their tool_use entries.
|
||||
*/
|
||||
private normalizeCursorBlobs(blobs: CursorMessageBlob[], sessionId: string | null): NormalizedMessage[] {
|
||||
const messages: NormalizedMessage[] = [];
|
||||
const toolUseMap = new Map<string, NormalizedMessage>();
|
||||
const baseTime = Date.now();
|
||||
|
||||
for (let i = 0; i < blobs.length; i++) {
|
||||
const blob = blobs[i];
|
||||
const content = blob.content;
|
||||
const ts = new Date(baseTime + (blob.sequence ?? i) * 100).toISOString();
|
||||
const baseId = blob.id || generateMessageId('cursor');
|
||||
|
||||
try {
|
||||
if (!content?.role || !content?.content) {
|
||||
if (content?.message?.role && content?.message?.content) {
|
||||
if (content.message.role === 'system') {
|
||||
continue;
|
||||
}
|
||||
const role = content.message.role === 'user' ? 'user' : 'assistant';
|
||||
let text = '';
|
||||
if (Array.isArray(content.message.content)) {
|
||||
text = content.message.content
|
||||
.map((part: string | AnyRecord) => typeof part === 'string' ? part : part?.text || '')
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
} else if (typeof content.message.content === 'string') {
|
||||
text = content.message.content;
|
||||
}
|
||||
if (text?.trim()) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role,
|
||||
content: text,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
}));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.role === 'system') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.role === 'tool') {
|
||||
const toolItems = Array.isArray(content.content) ? content.content : [];
|
||||
for (const item of toolItems) {
|
||||
if (item?.type !== 'tool-result') {
|
||||
continue;
|
||||
}
|
||||
const toolCallId = item.toolCallId || content.id;
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_tr`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: toolCallId,
|
||||
content: item.result || '',
|
||||
isError: false,
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = content.role === 'user' ? 'user' : 'assistant';
|
||||
|
||||
if (Array.isArray(content.content)) {
|
||||
for (let partIdx = 0; partIdx < content.content.length; partIdx++) {
|
||||
const part = content.content[partIdx];
|
||||
|
||||
if (part?.type === 'text' && part?.text) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role,
|
||||
content: part.text,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
}));
|
||||
} else if (part?.type === 'reasoning' && part?.text) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: part.text,
|
||||
}));
|
||||
} else if (part?.type === 'tool-call' || part?.type === 'tool_use') {
|
||||
const rawToolName = part.toolName || part.name || 'Unknown Tool';
|
||||
const toolName = rawToolName === 'ApplyPatch' ? 'Edit' : rawToolName;
|
||||
const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`;
|
||||
const message = createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName,
|
||||
toolInput: part.args || part.input,
|
||||
toolId,
|
||||
});
|
||||
messages.push(message);
|
||||
toolUseMap.set(toolId, message);
|
||||
}
|
||||
}
|
||||
} else if (typeof content.content === 'string' && content.content.trim()) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role,
|
||||
content: content.content,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error normalizing cursor blob:', error);
|
||||
}
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.kind === 'tool_result' && msg.toolId && toolUseMap.has(msg.toolId)) {
|
||||
const toolUse = toolUseMap.get(msg.toolId);
|
||||
if (toolUse) {
|
||||
toolUse.toolResult = {
|
||||
content: msg.content,
|
||||
isError: msg.isError,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messages.sort((a, b) => {
|
||||
if (a.sequence !== undefined && b.sequence !== undefined) {
|
||||
return a.sequence - b.sequence;
|
||||
}
|
||||
if (a.rowid !== undefined && b.rowid !== undefined) {
|
||||
return a.rowid - b.rowid;
|
||||
}
|
||||
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
|
||||
});
|
||||
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
17
server/modules/providers/list/cursor/cursor.provider.ts
Normal file
17
server/modules/providers/list/cursor/cursor.provider.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||
import { CursorProviderAuth } from '@/modules/providers/list/cursor/cursor-auth.provider.js';
|
||||
import { CursorMcpProvider } from '@/modules/providers/list/cursor/cursor-mcp.provider.js';
|
||||
import { CursorSessionSynchronizer } from '@/modules/providers/list/cursor/cursor-session-synchronizer.provider.js';
|
||||
import { CursorSessionsProvider } from '@/modules/providers/list/cursor/cursor-sessions.provider.js';
|
||||
import type { IProviderAuth, IProviderSessionSynchronizer, IProviderSessions } from '@/shared/interfaces.js';
|
||||
|
||||
export class CursorProvider extends AbstractProvider {
|
||||
readonly mcp = new CursorMcpProvider();
|
||||
readonly auth: IProviderAuth = new CursorProviderAuth();
|
||||
readonly sessions: IProviderSessions = new CursorSessionsProvider();
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizer = new CursorSessionSynchronizer();
|
||||
|
||||
constructor() {
|
||||
super('cursor');
|
||||
}
|
||||
}
|
||||
151
server/modules/providers/list/gemini/gemini-auth.provider.ts
Normal file
151
server/modules/providers/list/gemini/gemini-auth.provider.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||
import type { ProviderAuthStatus } from '@/shared/types.js';
|
||||
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
|
||||
|
||||
type GeminiCredentialsStatus = {
|
||||
authenticated: boolean;
|
||||
email: string | null;
|
||||
method: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export class GeminiProviderAuth implements IProviderAuth {
|
||||
/**
|
||||
* Checks whether the Gemini CLI is available on this host.
|
||||
*/
|
||||
private checkInstalled(): boolean {
|
||||
const cliPath = process.env.GEMINI_PATH || 'gemini';
|
||||
try {
|
||||
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Gemini CLI installation and credential status.
|
||||
*/
|
||||
async getStatus(): Promise<ProviderAuthStatus> {
|
||||
const installed = this.checkInstalled();
|
||||
|
||||
if (!installed) {
|
||||
return {
|
||||
installed,
|
||||
provider: 'gemini',
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Gemini CLI is not installed',
|
||||
};
|
||||
}
|
||||
|
||||
const credentials = await this.checkCredentials();
|
||||
|
||||
return {
|
||||
installed,
|
||||
provider: 'gemini',
|
||||
authenticated: credentials.authenticated,
|
||||
email: credentials.email,
|
||||
method: credentials.method,
|
||||
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks Gemini credentials from API key env vars or local OAuth credential files.
|
||||
*/
|
||||
private async checkCredentials(): Promise<GeminiCredentialsStatus> {
|
||||
if (process.env.GEMINI_API_KEY?.trim()) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||
}
|
||||
|
||||
try {
|
||||
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
|
||||
const content = await readFile(credsPath, 'utf8');
|
||||
const creds = readObjectRecord(JSON.parse(content)) ?? {};
|
||||
const accessToken = readOptionalString(creds.access_token);
|
||||
|
||||
if (!accessToken) {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'No valid tokens found in oauth_creds',
|
||||
};
|
||||
}
|
||||
|
||||
const refreshToken = readOptionalString(creds.refresh_token);
|
||||
const tokenInfo = await this.getTokenInfoEmail(accessToken);
|
||||
if (tokenInfo.valid) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: tokenInfo.email || 'OAuth Session',
|
||||
method: 'credentials_file',
|
||||
};
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: 'credentials_file',
|
||||
error: 'Access token invalid and no refresh token found',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
email: await this.getActiveAccountEmail() || 'OAuth Session',
|
||||
method: 'credentials_file',
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Gemini CLI not configured',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Gemini OAuth access token and returns an email when Google reports one.
|
||||
*/
|
||||
private async getTokenInfoEmail(accessToken: string): Promise<{ valid: boolean; email: string | null }> {
|
||||
try {
|
||||
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${accessToken}`);
|
||||
if (!tokenRes.ok) {
|
||||
return { valid: false, email: null };
|
||||
}
|
||||
|
||||
const tokenInfo = readObjectRecord(await tokenRes.json());
|
||||
return {
|
||||
valid: true,
|
||||
email: readOptionalString(tokenInfo?.email) ?? null,
|
||||
};
|
||||
} catch {
|
||||
return { valid: false, email: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads Gemini's active local Google account as an offline fallback for display.
|
||||
*/
|
||||
private async getActiveAccountEmail(): Promise<string | null> {
|
||||
try {
|
||||
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
|
||||
const accContent = await readFile(accPath, 'utf8');
|
||||
const accounts = readObjectRecord(JSON.parse(accContent));
|
||||
return readOptionalString(accounts?.active) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
110
server/modules/providers/list/gemini/gemini-mcp.provider.ts
Normal file
110
server/modules/providers/list/gemini/gemini-mcp.provider.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
|
||||
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import {
|
||||
AppError,
|
||||
readJsonConfig,
|
||||
readObjectRecord,
|
||||
readOptionalString,
|
||||
readStringArray,
|
||||
readStringRecord,
|
||||
writeJsonConfig,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
export class GeminiMcpProvider extends McpProvider {
|
||||
constructor() {
|
||||
super('gemini', ['user', 'project'], ['stdio', 'http', 'sse']);
|
||||
}
|
||||
|
||||
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.gemini', 'settings.json')
|
||||
: path.join(workspacePath, '.gemini', 'settings.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
return readObjectRecord(config.mcpServers) ?? {};
|
||||
}
|
||||
|
||||
protected async writeScopedServers(
|
||||
scope: McpScope,
|
||||
workspacePath: string,
|
||||
servers: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.gemini', 'settings.json')
|
||||
: path.join(workspacePath, '.gemini', 'settings.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
config.mcpServers = servers;
|
||||
await writeJsonConfig(filePath, config);
|
||||
}
|
||||
|
||||
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
|
||||
if (input.transport === 'stdio') {
|
||||
if (!input.command?.trim()) {
|
||||
throw new AppError('command is required for stdio MCP servers.', {
|
||||
code: 'MCP_COMMAND_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
command: input.command,
|
||||
args: input.args ?? [],
|
||||
env: input.env ?? {},
|
||||
cwd: input.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.url?.trim()) {
|
||||
throw new AppError('url is required for http/sse MCP servers.', {
|
||||
code: 'MCP_URL_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: input.transport,
|
||||
url: input.url,
|
||||
headers: input.headers ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
protected normalizeServerConfig(
|
||||
scope: McpScope,
|
||||
name: string,
|
||||
rawConfig: unknown,
|
||||
): ProviderMcpServer | null {
|
||||
if (!rawConfig || typeof rawConfig !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = rawConfig as Record<string, unknown>;
|
||||
if (typeof config.command === 'string') {
|
||||
return {
|
||||
provider: 'gemini',
|
||||
name,
|
||||
scope,
|
||||
transport: 'stdio',
|
||||
command: config.command,
|
||||
args: readStringArray(config.args),
|
||||
env: readStringRecord(config.env),
|
||||
cwd: readOptionalString(config.cwd),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof config.url === 'string') {
|
||||
const transport = readOptionalString(config.type) === 'sse' ? 'sse' : 'http';
|
||||
return {
|
||||
provider: 'gemini',
|
||||
name,
|
||||
scope,
|
||||
transport,
|
||||
url: config.url,
|
||||
headers: readStringRecord(config.headers),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import crypto from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
||||
import {
|
||||
findFilesRecursivelyCreatedAfter,
|
||||
normalizeProjectPath,
|
||||
normalizeSessionName,
|
||||
readFileTimestamps,
|
||||
} from '@/shared/utils.js';
|
||||
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord } from '@/shared/types.js';
|
||||
|
||||
type ParsedSession = {
|
||||
sessionId: string;
|
||||
projectPath: string;
|
||||
sessionName?: string;
|
||||
};
|
||||
|
||||
type GeminiJsonlMetadata = {
|
||||
sessionId: string;
|
||||
projectPath?: string;
|
||||
projectHash?: string;
|
||||
firstUserMessage?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Session indexer for Gemini transcript artifacts.
|
||||
*/
|
||||
export class GeminiSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
private readonly provider = 'gemini' as const;
|
||||
private readonly geminiHome = path.join(os.homedir(), '.gemini');
|
||||
|
||||
/**
|
||||
* Scans Gemini legacy JSON and new JSONL artifacts and upserts sessions into DB.
|
||||
*/
|
||||
async synchronize(since?: Date): Promise<number> {
|
||||
const projectHashLookup = this.buildProjectHashLookup();
|
||||
|
||||
const legacySessionFiles = await findFilesRecursivelyCreatedAfter(
|
||||
path.join(this.geminiHome, 'sessions'),
|
||||
'.json',
|
||||
since ?? null
|
||||
);
|
||||
const legacyTempFiles = await findFilesRecursivelyCreatedAfter(
|
||||
path.join(this.geminiHome, 'tmp'),
|
||||
'.json',
|
||||
since ?? null
|
||||
);
|
||||
const jsonlSessionFiles = await findFilesRecursivelyCreatedAfter(
|
||||
path.join(this.geminiHome, 'sessions'),
|
||||
'.jsonl',
|
||||
since ?? null
|
||||
);
|
||||
const jsonlTempFiles = await findFilesRecursivelyCreatedAfter(
|
||||
path.join(this.geminiHome, 'tmp'),
|
||||
'.jsonl',
|
||||
since ?? null
|
||||
);
|
||||
|
||||
// Process legacy JSON first, then JSONL. If both exist for a session id,
|
||||
// the JSONL artifact becomes the canonical jsonl_path via upsert.
|
||||
const files = [
|
||||
...legacySessionFiles,
|
||||
...legacyTempFiles,
|
||||
...jsonlSessionFiles,
|
||||
...jsonlTempFiles,
|
||||
];
|
||||
|
||||
let processed = 0;
|
||||
for (const filePath of files) {
|
||||
if (this.shouldSkipTempArtifact(filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = filePath.endsWith('.jsonl')
|
||||
? await this.processJsonlSessionFile(filePath, projectHashLookup)
|
||||
: await this.processLegacySessionFile(filePath);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timestamps = await readFileTimestamps(filePath);
|
||||
sessionsDb.createSession(
|
||||
parsed.sessionId,
|
||||
this.provider,
|
||||
parsed.projectPath,
|
||||
parsed.sessionName,
|
||||
timestamps.createdAt,
|
||||
timestamps.updatedAt,
|
||||
filePath
|
||||
);
|
||||
processed += 1;
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and upserts one Gemini legacy JSON or JSONL artifact.
|
||||
*/
|
||||
async synchronizeFile(filePath: string): Promise<string | null> {
|
||||
if (!filePath.endsWith('.json') && !filePath.endsWith('.jsonl')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.shouldSkipTempArtifact(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = filePath.endsWith('.jsonl')
|
||||
? await this.processJsonlSessionFile(filePath, this.buildProjectHashLookup())
|
||||
: await this.processLegacySessionFile(filePath);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamps = await readFileTimestamps(filePath);
|
||||
return sessionsDb.createSession(
|
||||
parsed.sessionId,
|
||||
this.provider,
|
||||
parsed.projectPath,
|
||||
parsed.sessionName,
|
||||
timestamps.createdAt,
|
||||
timestamps.updatedAt,
|
||||
filePath
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts session metadata from one Gemini legacy JSON artifact.
|
||||
*/
|
||||
private async processLegacySessionFile(filePath: string): Promise<ParsedSession | null> {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const data = JSON.parse(content) as AnyRecord;
|
||||
|
||||
const sessionId =
|
||||
typeof data.sessionId === 'string'
|
||||
? data.sessionId
|
||||
: typeof data.id === 'string'
|
||||
? data.id
|
||||
: undefined;
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspaceProjectPath = await this.resolveProjectPathFromChatWorkspace(filePath);
|
||||
const projectPath = typeof data.projectPath === 'string' && data.projectPath.trim().length > 0
|
||||
? data.projectPath
|
||||
: workspaceProjectPath;
|
||||
if (!projectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const messages = Array.isArray(data.messages) ? data.messages : [];
|
||||
const firstMessage = messages[0] as AnyRecord | undefined;
|
||||
let rawName: string | undefined;
|
||||
|
||||
if (Array.isArray(firstMessage?.content) && typeof firstMessage.content[0]?.text === 'string') {
|
||||
rawName = firstMessage.content[0].text;
|
||||
} else if (typeof firstMessage?.content === 'string') {
|
||||
rawName = firstMessage.content;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
projectPath,
|
||||
sessionName: normalizeSessionName(rawName, 'New Gemini Chat'),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts session metadata from one Gemini JSONL artifact.
|
||||
*/
|
||||
private async processJsonlSessionFile(
|
||||
filePath: string,
|
||||
projectHashLookup: Map<string, string>
|
||||
): Promise<ParsedSession | null> {
|
||||
const metadata = await this.extractJsonlMetadata(filePath);
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let projectPath = typeof metadata.projectPath === 'string' ? metadata.projectPath.trim() : '';
|
||||
if (!projectPath) {
|
||||
const workspaceProjectPath = await this.resolveProjectPathFromChatWorkspace(filePath);
|
||||
if (workspaceProjectPath) {
|
||||
projectPath = workspaceProjectPath;
|
||||
}
|
||||
}
|
||||
if (!projectPath && typeof metadata.projectHash === 'string') {
|
||||
projectPath = projectHashLookup.get(metadata.projectHash.trim().toLowerCase()) ?? '';
|
||||
}
|
||||
if (!projectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Once we resolve a project hash/path pair, keep it in-memory for this sync run.
|
||||
if (typeof metadata.projectHash === 'string' && metadata.projectHash.trim()) {
|
||||
projectHashLookup.set(metadata.projectHash.trim().toLowerCase(), projectPath);
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: metadata.sessionId,
|
||||
projectPath,
|
||||
sessionName: normalizeSessionName(metadata.firstUserMessage, 'New Gemini Chat'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads first useful metadata from Gemini JSONL files.
|
||||
*/
|
||||
private async extractJsonlMetadata(filePath: string): Promise<GeminiJsonlMetadata | null> {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
let sessionId: string | undefined;
|
||||
let projectPath: string | undefined;
|
||||
let projectHash: string | undefined;
|
||||
let firstUserMessage: string | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed: AnyRecord;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed) as AnyRecord;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!sessionId && typeof parsed.sessionId === 'string') {
|
||||
sessionId = parsed.sessionId;
|
||||
}
|
||||
if (!projectPath && typeof parsed.projectPath === 'string') {
|
||||
projectPath = parsed.projectPath;
|
||||
}
|
||||
if (!projectHash && typeof parsed.projectHash === 'string') {
|
||||
projectHash = parsed.projectHash;
|
||||
}
|
||||
|
||||
if (!firstUserMessage && parsed.type === 'user') {
|
||||
firstUserMessage = this.extractGeminiTextContent(parsed.content);
|
||||
}
|
||||
|
||||
if (sessionId && (projectPath || projectHash) && firstUserMessage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
projectPath,
|
||||
projectHash,
|
||||
firstUserMessage,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to resolve project root from Gemini tmp chat workspaces.
|
||||
*/
|
||||
private async resolveProjectPathFromChatWorkspace(filePath: string): Promise<string> {
|
||||
if (!filePath.includes(`${path.sep}chats${path.sep}`)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const chatsDir = path.dirname(filePath);
|
||||
const workspaceDir = path.dirname(chatsDir);
|
||||
const projectRootPath = path.join(workspaceDir, '.project_root');
|
||||
|
||||
try {
|
||||
const rootContent = await readFile(projectRootPath, 'utf8');
|
||||
return rootContent.trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a hash->path lookup for Gemini JSONL metadata that stores projectHash.
|
||||
*/
|
||||
private buildProjectHashLookup(): Map<string, string> {
|
||||
const lookup = new Map<string, string>();
|
||||
const knownPaths = new Set<string>();
|
||||
|
||||
for (const project of projectsDb.getProjectPaths()) {
|
||||
if (typeof project.project_path === 'string' && project.project_path.trim()) {
|
||||
knownPaths.add(project.project_path.trim());
|
||||
}
|
||||
}
|
||||
|
||||
for (const session of sessionsDb.getAllSessions()) {
|
||||
if (session.provider === this.provider && typeof session.project_path === 'string' && session.project_path.trim()) {
|
||||
knownPaths.add(session.project_path.trim());
|
||||
}
|
||||
}
|
||||
|
||||
for (const knownPath of knownPaths) {
|
||||
this.addProjectHashCandidates(lookup, knownPath);
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds likely Gemini hash variants for one project path.
|
||||
*/
|
||||
private addProjectHashCandidates(lookup: Map<string, string>, projectPath: string): void {
|
||||
const trimmed = projectPath.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeProjectPath(trimmed);
|
||||
const resolved = path.resolve(trimmed);
|
||||
const resolvedNormalized = normalizeProjectPath(resolved);
|
||||
|
||||
const candidates = new Set<string>([
|
||||
trimmed,
|
||||
normalized,
|
||||
resolved,
|
||||
resolvedNormalized,
|
||||
]);
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
for (const candidate of [...candidates]) {
|
||||
candidates.add(candidate.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hash = this.sha256(candidate);
|
||||
if (!lookup.has(hash)) {
|
||||
lookup.set(hash, trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns first user text from Gemini content payload shapes.
|
||||
*/
|
||||
private extractGeminiTextContent(content: unknown): string | undefined {
|
||||
if (typeof content === 'string' && content.trim().length > 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const part of content) {
|
||||
if (typeof part === 'string' && part.trim().length > 0) {
|
||||
return part;
|
||||
}
|
||||
|
||||
if (part && typeof part === 'object' && typeof (part as AnyRecord).text === 'string') {
|
||||
const text = (part as AnyRecord).text;
|
||||
if (text.trim().length > 0) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps tmp scanning scoped to chat artifacts only.
|
||||
*/
|
||||
private shouldSkipTempArtifact(filePath: string): boolean {
|
||||
return (
|
||||
filePath.startsWith(path.join(this.geminiHome, 'tmp'))
|
||||
&& !filePath.includes(`${path.sep}chats${path.sep}`)
|
||||
);
|
||||
}
|
||||
|
||||
private sha256(value: string): string {
|
||||
return crypto.createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
}
|
||||
541
server/modules/providers/list/gemini/gemini-sessions.provider.ts
Normal file
541
server/modules/providers/list/gemini/gemini-sessions.provider.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
import fsSync from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import readline from 'node:readline';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'gemini';
|
||||
|
||||
type GeminiHistoryResult = {
|
||||
messages: AnyRecord[];
|
||||
tokenUsage?: unknown;
|
||||
};
|
||||
|
||||
function mapGeminiRole(value: unknown): 'user' | 'assistant' | null {
|
||||
if (value === 'user') {
|
||||
return 'user';
|
||||
}
|
||||
|
||||
if (value === 'gemini' || value === 'assistant') {
|
||||
return 'assistant';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractGeminiTextContent(content: unknown): string {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return content
|
||||
.map((part) => {
|
||||
if (typeof part === 'string') {
|
||||
return part;
|
||||
}
|
||||
if (!part || typeof part !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const record = part as AnyRecord;
|
||||
if (typeof record.text === 'string') {
|
||||
return record.text;
|
||||
}
|
||||
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function extractGeminiThoughts(thoughts: unknown): string {
|
||||
if (!Array.isArray(thoughts)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return thoughts
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const record = item as AnyRecord;
|
||||
const subject = typeof record.subject === 'string' ? record.subject.trim() : '';
|
||||
const description = typeof record.description === 'string' ? record.description.trim() : '';
|
||||
|
||||
if (subject && description) {
|
||||
return `${subject}: ${description}`;
|
||||
}
|
||||
|
||||
return description || subject;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function buildGeminiTokenUsage(tokens: unknown): AnyRecord | undefined {
|
||||
if (!tokens || typeof tokens !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const record = tokens as AnyRecord;
|
||||
const input = Number(record.input || 0);
|
||||
const output = Number(record.output || 0);
|
||||
const cached = Number(record.cached || 0);
|
||||
const thoughts = Number(record.thoughts || 0);
|
||||
const tool = Number(record.tool || 0);
|
||||
|
||||
const totalFromFields = input + output + cached + thoughts + tool;
|
||||
const total = Number(record.total || totalFromFields || 0);
|
||||
|
||||
return {
|
||||
used: total,
|
||||
total: total,
|
||||
breakdown: {
|
||||
input,
|
||||
output,
|
||||
cached,
|
||||
thoughts,
|
||||
tool,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getGeminiLegacySessionMessages(sessionFilePath: string): Promise<GeminiHistoryResult> {
|
||||
try {
|
||||
const data = await fs.readFile(sessionFilePath, 'utf8');
|
||||
const session = JSON.parse(data) as AnyRecord;
|
||||
const sourceMessages = Array.isArray(session.messages) ? session.messages : [];
|
||||
|
||||
const messages: AnyRecord[] = [];
|
||||
for (const msg of sourceMessages) {
|
||||
const role = mapGeminiRole(msg.type ?? msg.role);
|
||||
if (!role) {
|
||||
continue;
|
||||
}
|
||||
|
||||
messages.push({
|
||||
type: 'message',
|
||||
uuid: typeof msg.id === 'string' ? msg.id : undefined,
|
||||
message: { role, content: msg.content },
|
||||
timestamp: msg.timestamp || null,
|
||||
});
|
||||
}
|
||||
|
||||
return { messages };
|
||||
} catch {
|
||||
return { messages: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function getGeminiJsonlSessionMessages(sessionFilePath: string): Promise<GeminiHistoryResult> {
|
||||
const messages: AnyRecord[] = [];
|
||||
let tokenUsage: AnyRecord | undefined;
|
||||
|
||||
try {
|
||||
const fileStream = fsSync.createReadStream(sessionFilePath);
|
||||
const lineReader = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
for await (const line of lineReader) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entry: AnyRecord;
|
||||
try {
|
||||
entry = JSON.parse(trimmed) as AnyRecord;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Metadata/update lines (e.g. {$set:{lastUpdated:...}}) do not represent chat messages.
|
||||
if (entry.$set) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = mapGeminiRole(entry.type);
|
||||
if (role) {
|
||||
const textContent = extractGeminiTextContent(entry.content);
|
||||
if (textContent.trim()) {
|
||||
messages.push({
|
||||
type: 'message',
|
||||
uuid: typeof entry.id === 'string' ? entry.id : undefined,
|
||||
message: { role, content: textContent },
|
||||
timestamp: entry.timestamp || null,
|
||||
});
|
||||
}
|
||||
|
||||
const thinkingContent = extractGeminiThoughts(entry.thoughts);
|
||||
if (thinkingContent.trim()) {
|
||||
messages.push({
|
||||
type: 'thinking',
|
||||
uuid: typeof entry.id === 'string' ? `${entry.id}_thinking` : undefined,
|
||||
message: { role: 'assistant', content: thinkingContent },
|
||||
timestamp: entry.timestamp || null,
|
||||
isReasoning: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (role === 'assistant') {
|
||||
const usage = buildGeminiTokenUsage(entry.tokens);
|
||||
if (usage) {
|
||||
tokenUsage = usage;
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type === 'tool_use') {
|
||||
messages.push({
|
||||
type: 'tool_use',
|
||||
uuid: typeof entry.id === 'string' ? entry.id : undefined,
|
||||
timestamp: entry.timestamp || null,
|
||||
toolName: entry.tool_name || entry.name || 'Tool',
|
||||
toolInput: entry.parameters ?? entry.input ?? entry.arguments ?? '',
|
||||
toolCallId: entry.tool_id || entry.toolCallId || entry.id,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type === 'tool_result') {
|
||||
messages.push({
|
||||
type: 'tool_result',
|
||||
uuid: typeof entry.id === 'string' ? entry.id : undefined,
|
||||
timestamp: entry.timestamp || null,
|
||||
toolCallId: entry.tool_id || entry.toolCallId || entry.id || '',
|
||||
output: entry.output ?? entry.result ?? '',
|
||||
isError: Boolean(entry.error) || entry.status === 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return { messages: [] };
|
||||
}
|
||||
|
||||
messages.sort(
|
||||
(a, b) => new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime(),
|
||||
);
|
||||
|
||||
return { messages, tokenUsage };
|
||||
}
|
||||
|
||||
async function getGeminiCliSessionMessages(sessionId: string): Promise<GeminiHistoryResult> {
|
||||
const sessionFilePath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
|
||||
if (!sessionFilePath) {
|
||||
return { messages: [] };
|
||||
}
|
||||
|
||||
if (sessionFilePath.endsWith('.jsonl')) {
|
||||
return getGeminiJsonlSessionMessages(sessionFilePath);
|
||||
}
|
||||
|
||||
return getGeminiLegacySessionMessages(sessionFilePath);
|
||||
}
|
||||
|
||||
export class GeminiSessionsProvider implements IProviderSessions {
|
||||
/**
|
||||
* Normalizes live Gemini stream-json events into the shared message shape.
|
||||
*
|
||||
* Gemini history uses a different session file shape, so fetchHistory handles
|
||||
* that separately after loading raw persisted messages.
|
||||
*/
|
||||
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
|
||||
const raw = readObjectRecord(rawMessage);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('gemini');
|
||||
|
||||
if (raw.type === 'message' && raw.role === 'assistant') {
|
||||
const content = raw.content || '';
|
||||
const messages: NormalizedMessage[] = [];
|
||||
if (content) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'stream_delta',
|
||||
content,
|
||||
}));
|
||||
}
|
||||
if (raw.delta !== true) {
|
||||
messages.push(createNormalizedMessage({
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'stream_end',
|
||||
}));
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_use') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: raw.tool_name,
|
||||
toolInput: raw.parameters || {},
|
||||
toolId: raw.tool_id || baseId,
|
||||
})];
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_result') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: raw.tool_id || '',
|
||||
content: raw.output === undefined ? '' : String(raw.output),
|
||||
isError: raw.status === 'error',
|
||||
})];
|
||||
}
|
||||
|
||||
if (raw.type === 'result') {
|
||||
const messages = [createNormalizedMessage({
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'stream_end',
|
||||
})];
|
||||
if (raw.stats?.total_tokens) {
|
||||
messages.push(createNormalizedMessage({
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'status',
|
||||
text: 'Complete',
|
||||
tokens: raw.stats.total_tokens,
|
||||
canInterrupt: false,
|
||||
}));
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
if (raw.type === 'error') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'error',
|
||||
content: raw.error || raw.message || 'Unknown Gemini streaming error',
|
||||
})];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads Gemini history from Gemini CLI session files on disk.
|
||||
*/
|
||||
async fetchHistory(
|
||||
sessionId: string,
|
||||
options: FetchHistoryOptions = {},
|
||||
): Promise<FetchHistoryResult> {
|
||||
const { limit = null, offset = 0 } = options;
|
||||
|
||||
let result: GeminiHistoryResult;
|
||||
try {
|
||||
result = await getGeminiCliSessionMessages(sessionId);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[GeminiProvider] Failed to load session ${sessionId}:`, message);
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
|
||||
const rawMessages = result.messages;
|
||||
const normalized: NormalizedMessage[] = [];
|
||||
|
||||
for (let i = 0; i < rawMessages.length; i++) {
|
||||
const raw = rawMessages[i];
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('gemini');
|
||||
|
||||
if (raw.type === 'thinking' || raw.isReasoning) {
|
||||
const thinkingContent = typeof raw.message?.content === 'string'
|
||||
? raw.message.content
|
||||
: typeof raw.content === 'string'
|
||||
? raw.content
|
||||
: '';
|
||||
|
||||
if (thinkingContent.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: thinkingContent,
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_use' || raw.toolName) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: raw.toolName || 'Tool',
|
||||
toolInput: raw.toolInput,
|
||||
toolId: raw.toolCallId || baseId,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_result') {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: raw.toolCallId || '',
|
||||
content: raw.output === undefined ? '' : String(raw.output),
|
||||
isError: Boolean(raw.isError),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = raw.message?.role || raw.role;
|
||||
const content = raw.message?.content || raw.content;
|
||||
if (!role || !content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedRole = role === 'user' ? 'user' : 'assistant';
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
for (let partIdx = 0; partIdx < content.length; partIdx++) {
|
||||
const part = content[partIdx] as AnyRecord | string;
|
||||
|
||||
if (typeof part === 'string' && part.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: normalizedRole,
|
||||
content: part,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!part || typeof part !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((part.type === 'text' || !part.type) && typeof part.text === 'string' && part.text.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: normalizedRole,
|
||||
content: part.text,
|
||||
}));
|
||||
} else if (part.type === 'tool_use') {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: part.name,
|
||||
toolInput: part.input,
|
||||
toolId: part.id || generateMessageId('gemini_tool'),
|
||||
}));
|
||||
} else if (part.type === 'tool_result') {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: part.tool_use_id || '',
|
||||
content: part.content === undefined ? '' : String(part.content),
|
||||
isError: Boolean(part.is_error),
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else if (typeof content === 'string' && content.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: normalizedRole,
|
||||
content,
|
||||
}));
|
||||
} else {
|
||||
const textContent = extractGeminiTextContent(content);
|
||||
if (textContent.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: normalizedRole,
|
||||
content: textContent,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toolResultMap = new Map<string, NormalizedMessage>();
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_result' && msg.toolId) {
|
||||
toolResultMap.set(msg.toolId, msg);
|
||||
}
|
||||
}
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
|
||||
const toolResult = toolResultMap.get(msg.toolId);
|
||||
if (toolResult) {
|
||||
msg.toolResult = { content: toolResult.content, isError: toolResult.isError };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const start = Math.max(0, offset);
|
||||
const pageLimit = limit === null ? null : Math.max(0, limit);
|
||||
const messages = pageLimit === null
|
||||
? normalized.slice(start)
|
||||
: normalized.slice(start, start + pageLimit);
|
||||
|
||||
return {
|
||||
messages,
|
||||
total: normalized.length,
|
||||
hasMore: pageLimit === null ? false : start + pageLimit < normalized.length,
|
||||
offset: start,
|
||||
limit: pageLimit,
|
||||
tokenUsage: result.tokenUsage,
|
||||
};
|
||||
}
|
||||
}
|
||||
17
server/modules/providers/list/gemini/gemini.provider.ts
Normal file
17
server/modules/providers/list/gemini/gemini.provider.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||
import { GeminiProviderAuth } from '@/modules/providers/list/gemini/gemini-auth.provider.js';
|
||||
import { GeminiMcpProvider } from '@/modules/providers/list/gemini/gemini-mcp.provider.js';
|
||||
import { GeminiSessionSynchronizer } from '@/modules/providers/list/gemini/gemini-session-synchronizer.provider.js';
|
||||
import { GeminiSessionsProvider } from '@/modules/providers/list/gemini/gemini-sessions.provider.js';
|
||||
import type { IProviderAuth, IProviderSessionSynchronizer, IProviderSessions } from '@/shared/interfaces.js';
|
||||
|
||||
export class GeminiProvider extends AbstractProvider {
|
||||
readonly mcp = new GeminiMcpProvider();
|
||||
readonly auth: IProviderAuth = new GeminiProviderAuth();
|
||||
readonly sessions: IProviderSessions = new GeminiSessionsProvider();
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizer = new GeminiSessionSynchronizer();
|
||||
|
||||
constructor() {
|
||||
super('gemini');
|
||||
}
|
||||
}
|
||||
36
server/modules/providers/provider.registry.ts
Normal file
36
server/modules/providers/provider.registry.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider.js';
|
||||
import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js';
|
||||
import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js';
|
||||
import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js';
|
||||
import type { IProvider } from '@/shared/interfaces.js';
|
||||
import type { LLMProvider } from '@/shared/types.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
const providers: Record<LLMProvider, IProvider> = {
|
||||
claude: new ClaudeProvider(),
|
||||
codex: new CodexProvider(),
|
||||
cursor: new CursorProvider(),
|
||||
gemini: new GeminiProvider(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Central registry for resolving concrete provider implementations by id.
|
||||
*/
|
||||
export const providerRegistry = {
|
||||
listProviders(): IProvider[] {
|
||||
return Object.values(providers);
|
||||
},
|
||||
|
||||
resolveProvider(provider: string): IProvider {
|
||||
const key = provider as LLMProvider;
|
||||
const resolvedProvider = providers[key];
|
||||
if (!resolvedProvider) {
|
||||
throw new AppError(`Unsupported provider "${provider}".`, {
|
||||
code: 'UNSUPPORTED_PROVIDER',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return resolvedProvider;
|
||||
},
|
||||
};
|
||||
425
server/modules/providers/provider.routes.ts
Normal file
425
server/modules/providers/provider.routes.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import express, { type Request, type Response } from 'express';
|
||||
|
||||
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
|
||||
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
|
||||
import { sessionConversationsSearchService } from '@/modules/providers/services/session-conversations-search.service.js';
|
||||
import { sessionsService } from '@/modules/providers/services/sessions.service.js';
|
||||
import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const readPathParam = (value: unknown, name: string): string => {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && typeof value[0] === 'string') {
|
||||
return value[0];
|
||||
}
|
||||
|
||||
throw new AppError(`${name} path parameter is invalid.`, {
|
||||
code: 'INVALID_PATH_PARAMETER',
|
||||
statusCode: 400,
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeProviderParam = (value: unknown): string =>
|
||||
readPathParam(value, 'provider').trim().toLowerCase();
|
||||
|
||||
const SESSION_ID_PATTERN = /^[a-zA-Z0-9._-]{1,120}$/;
|
||||
|
||||
const parseSessionId = (value: unknown): string => {
|
||||
const sessionId = readPathParam(value, 'sessionId').trim();
|
||||
if (!SESSION_ID_PATTERN.test(sessionId)) {
|
||||
throw new AppError('Invalid sessionId.', {
|
||||
code: 'INVALID_SESSION_ID',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return sessionId;
|
||||
};
|
||||
|
||||
const readOptionalQueryString = (value: unknown): string | undefined => {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
};
|
||||
|
||||
const parseOptionalBooleanQuery = (value: unknown, name: string): boolean | undefined => {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = readOptionalQueryString(value);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (normalized === 'true') {
|
||||
return true;
|
||||
}
|
||||
if (normalized === 'false') {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new AppError(`${name} must be "true" or "false".`, {
|
||||
code: 'INVALID_QUERY_PARAMETER',
|
||||
statusCode: 400,
|
||||
});
|
||||
};
|
||||
|
||||
const parseMcpScope = (value: unknown): McpScope | undefined => {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = readOptionalQueryString(value);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (normalized === 'user' || normalized === 'local' || normalized === 'project') {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
throw new AppError(`Unsupported MCP scope "${normalized}".`, {
|
||||
code: 'INVALID_MCP_SCOPE',
|
||||
statusCode: 400,
|
||||
});
|
||||
};
|
||||
|
||||
const parseMcpTransport = (value: unknown): McpTransport => {
|
||||
const normalized = readOptionalQueryString(value);
|
||||
if (!normalized) {
|
||||
throw new AppError('transport is required.', {
|
||||
code: 'MCP_TRANSPORT_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (normalized === 'stdio' || normalized === 'http' || normalized === 'sse') {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
throw new AppError(`Unsupported MCP transport "${normalized}".`, {
|
||||
code: 'INVALID_MCP_TRANSPORT',
|
||||
statusCode: 400,
|
||||
});
|
||||
};
|
||||
|
||||
const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
throw new AppError('Request body must be an object.', {
|
||||
code: 'INVALID_REQUEST_BODY',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const body = payload as Record<string, unknown>;
|
||||
const name = readOptionalQueryString(body.name);
|
||||
if (!name) {
|
||||
throw new AppError('name is required.', {
|
||||
code: 'MCP_NAME_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const transport = parseMcpTransport(body.transport);
|
||||
const scope = parseMcpScope(body.scope);
|
||||
const workspacePath = readOptionalQueryString(body.workspacePath);
|
||||
|
||||
return {
|
||||
name,
|
||||
transport,
|
||||
scope,
|
||||
workspacePath,
|
||||
command: readOptionalQueryString(body.command),
|
||||
args: Array.isArray(body.args) ? body.args.filter((entry): entry is string => typeof entry === 'string') : undefined,
|
||||
env: typeof body.env === 'object' && body.env !== null
|
||||
? Object.fromEntries(
|
||||
Object.entries(body.env as Record<string, unknown>).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
cwd: readOptionalQueryString(body.cwd),
|
||||
url: readOptionalQueryString(body.url),
|
||||
headers: typeof body.headers === 'object' && body.headers !== null
|
||||
? Object.fromEntries(
|
||||
Object.entries(body.headers as Record<string, unknown>).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
envVars: Array.isArray(body.envVars)
|
||||
? body.envVars.filter((entry): entry is string => typeof entry === 'string')
|
||||
: undefined,
|
||||
bearerTokenEnvVar: readOptionalQueryString(body.bearerTokenEnvVar),
|
||||
envHttpHeaders: typeof body.envHttpHeaders === 'object' && body.envHttpHeaders !== null
|
||||
? Object.fromEntries(
|
||||
Object.entries(body.envHttpHeaders as Record<string, unknown>).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const parseProvider = (value: unknown): LLMProvider => {
|
||||
const normalized = normalizeProviderParam(value);
|
||||
if (normalized === 'claude' || normalized === 'codex' || normalized === 'cursor' || normalized === 'gemini') {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
throw new AppError(`Unsupported provider "${normalized}".`, {
|
||||
code: 'UNSUPPORTED_PROVIDER',
|
||||
statusCode: 400,
|
||||
});
|
||||
};
|
||||
|
||||
const parseSessionRenameSummary = (payload: unknown): string => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
throw new AppError('Request body must be an object.', {
|
||||
code: 'INVALID_REQUEST_BODY',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const body = payload as Record<string, unknown>;
|
||||
const summary = typeof body.summary === 'string' ? body.summary.trim() : '';
|
||||
if (!summary) {
|
||||
throw new AppError('Summary is required.', {
|
||||
code: 'INVALID_SESSION_SUMMARY',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (summary.length > 500) {
|
||||
throw new AppError('Summary must not exceed 500 characters.', {
|
||||
code: 'INVALID_SESSION_SUMMARY',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return summary;
|
||||
};
|
||||
|
||||
const parseSessionSearchQuery = (value: unknown): string => {
|
||||
const query = readOptionalQueryString(value) ?? '';
|
||||
if (query.length < 2) {
|
||||
throw new AppError('Query must be at least 2 characters', {
|
||||
code: 'INVALID_SEARCH_QUERY',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
const parseSessionSearchLimit = (value: unknown): number => {
|
||||
const raw = readOptionalQueryString(value);
|
||||
if (!raw) {
|
||||
return 50;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new AppError('limit must be a valid integer.', {
|
||||
code: 'INVALID_QUERY_PARAMETER',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return Math.max(1, Math.min(parsed, 100));
|
||||
};
|
||||
|
||||
router.get(
|
||||
'/:provider/auth/status',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const status = await providerAuthService.getProviderAuthStatus(provider);
|
||||
res.json(createApiSuccessResponse(status));
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------- MCP routes -----------------
|
||||
router.get(
|
||||
'/:provider/mcp/servers',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const workspacePath = readOptionalQueryString(req.query.workspacePath);
|
||||
const scope = parseMcpScope(req.query.scope);
|
||||
|
||||
if (scope) {
|
||||
const servers = await providerMcpService.listProviderMcpServersForScope(provider, scope, { workspacePath });
|
||||
res.json(createApiSuccessResponse({ provider, scope, servers }));
|
||||
return;
|
||||
}
|
||||
|
||||
const groupedServers = await providerMcpService.listProviderMcpServers(provider, { workspacePath });
|
||||
res.json(createApiSuccessResponse({ provider, scopes: groupedServers }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/mcp/servers',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const payload = parseMcpUpsertPayload(req.body);
|
||||
const server = await providerMcpService.upsertProviderMcpServer(provider, payload);
|
||||
res.status(201).json(createApiSuccessResponse({ server }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:provider/mcp/servers/:name',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const scope = parseMcpScope(req.query.scope);
|
||||
const workspacePath = readOptionalQueryString(req.query.workspacePath);
|
||||
const result = await providerMcpService.removeProviderMcpServer(provider, {
|
||||
name: readPathParam(req.params.name, 'name'),
|
||||
scope,
|
||||
workspacePath,
|
||||
});
|
||||
res.json(createApiSuccessResponse(result));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/mcp/servers/global',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const payload = parseMcpUpsertPayload(req.body);
|
||||
if (payload.scope === 'local') {
|
||||
throw new AppError('Global MCP add supports only "user" or "project" scopes.', {
|
||||
code: 'INVALID_GLOBAL_MCP_SCOPE',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const results = await providerMcpService.addMcpServerToAllProviders({
|
||||
...payload,
|
||||
scope: payload.scope === 'user' ? 'user' : 'project',
|
||||
});
|
||||
res.status(201).json(createApiSuccessResponse({ results }));
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------- Session routes -----------------
|
||||
router.delete(
|
||||
'/sessions/:sessionId',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const sessionId = parseSessionId(req.params.sessionId);
|
||||
const deletedFromDisk = parseOptionalBooleanQuery(req.query.deletedFromDisk, 'deletedFromDisk') ?? false;
|
||||
const result = await sessionsService.deleteSessionById(sessionId, deletedFromDisk);
|
||||
res.json(createApiSuccessResponse(result));
|
||||
}),
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/sessions/:sessionId',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const sessionId = parseSessionId(req.params.sessionId);
|
||||
const summary = parseSessionRenameSummary(req.body);
|
||||
const result = sessionsService.renameSessionById(sessionId, summary);
|
||||
res.json(createApiSuccessResponse(result));
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/sessions/:sessionId/messages',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const sessionId = parseSessionId(req.params.sessionId);
|
||||
const limitRaw = readOptionalQueryString(req.query.limit);
|
||||
const offsetRaw = readOptionalQueryString(req.query.offset);
|
||||
|
||||
let limit: number | null = null;
|
||||
if (limitRaw !== undefined) {
|
||||
const parsedLimit = Number.parseInt(limitRaw, 10);
|
||||
if (Number.isNaN(parsedLimit) || parsedLimit < 0) {
|
||||
throw new AppError('limit must be a non-negative integer.', {
|
||||
code: 'INVALID_QUERY_PARAMETER',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
limit = parsedLimit;
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
if (offsetRaw !== undefined) {
|
||||
const parsedOffset = Number.parseInt(offsetRaw, 10);
|
||||
if (Number.isNaN(parsedOffset) || parsedOffset < 0) {
|
||||
throw new AppError('offset must be a non-negative integer.', {
|
||||
code: 'INVALID_QUERY_PARAMETER',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
offset = parsedOffset;
|
||||
}
|
||||
|
||||
const result = await sessionsService.fetchHistory(sessionId, {
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
res.json(result);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get('/search/sessions', asyncHandler(async (req: Request, res: Response) => {
|
||||
const query = parseSessionSearchQuery(req.query.q);
|
||||
const limit = parseSessionSearchLimit(req.query.limit);
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
|
||||
let closed = false;
|
||||
const abortController = new AbortController();
|
||||
req.on('close', () => {
|
||||
closed = true;
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
try {
|
||||
await sessionConversationsSearchService.search({
|
||||
query,
|
||||
limit,
|
||||
signal: abortController.signal,
|
||||
onProgress: ({ projectResult, totalMatches, scannedProjects, totalProjects }) => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (projectResult) {
|
||||
res.write(`event: result\ndata: ${JSON.stringify({ projectResult, totalMatches, scannedProjects, totalProjects })}\n\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
res.write(`event: progress\ndata: ${JSON.stringify({ totalMatches, scannedProjects, totalProjects })}\n\n`);
|
||||
},
|
||||
});
|
||||
|
||||
if (!closed) {
|
||||
res.write('event: done\ndata: {}\n\n');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error searching conversations:', error);
|
||||
if (!closed) {
|
||||
res.write(`event: error\ndata: ${JSON.stringify({ error: 'Search failed' })}\n\n`);
|
||||
}
|
||||
} finally {
|
||||
if (!closed) {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
export default router;
|
||||
94
server/modules/providers/services/mcp.service.ts
Normal file
94
server/modules/providers/services/mcp.service.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import os from 'node:os';
|
||||
|
||||
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||
import type { LLMProvider, McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
/** Cursor MCP is not supported on Windows hosts (no Cursor CLI integration). */
|
||||
function includeProviderInGlobalMcp(providerId: LLMProvider): boolean {
|
||||
if (providerId === 'cursor' && os.platform() === 'win32') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
export const providerMcpService = {
|
||||
/**
|
||||
* Lists MCP servers for one provider grouped by supported scopes.
|
||||
*/
|
||||
async listProviderMcpServers(
|
||||
providerName: string,
|
||||
options?: { workspacePath?: string },
|
||||
): Promise<Record<McpScope, ProviderMcpServer[]>> {
|
||||
const provider = providerRegistry.resolveProvider(providerName);
|
||||
return provider.mcp.listServers(options);
|
||||
},
|
||||
|
||||
/**
|
||||
* Lists MCP servers for one provider scope.
|
||||
*/
|
||||
async listProviderMcpServersForScope(
|
||||
providerName: string,
|
||||
scope: McpScope,
|
||||
options?: { workspacePath?: string },
|
||||
): Promise<ProviderMcpServer[]> {
|
||||
const provider = providerRegistry.resolveProvider(providerName);
|
||||
return provider.mcp.listServersForScope(scope, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds or updates one provider MCP server.
|
||||
*/
|
||||
async upsertProviderMcpServer(
|
||||
providerName: string,
|
||||
input: UpsertProviderMcpServerInput,
|
||||
): Promise<ProviderMcpServer> {
|
||||
const provider = providerRegistry.resolveProvider(providerName);
|
||||
return provider.mcp.upsertServer(input);
|
||||
},
|
||||
|
||||
/**
|
||||
* Removes one provider MCP server.
|
||||
*/
|
||||
async removeProviderMcpServer(
|
||||
providerName: string,
|
||||
input: { name: string; scope?: McpScope; workspacePath?: string },
|
||||
): Promise<{ removed: boolean; provider: LLMProvider; name: string; scope: McpScope }> {
|
||||
const provider = providerRegistry.resolveProvider(providerName);
|
||||
return provider.mcp.removeServer(input);
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds one HTTP/stdio MCP server to every provider.
|
||||
*/
|
||||
async addMcpServerToAllProviders(
|
||||
input: Omit<UpsertProviderMcpServerInput, 'scope'> & { scope?: Exclude<McpScope, 'local'> },
|
||||
): Promise<Array<{ provider: LLMProvider; created: boolean; error?: string }>> {
|
||||
if (input.transport !== 'stdio' && input.transport !== 'http') {
|
||||
throw new AppError('Global MCP add supports only "stdio" and "http".', {
|
||||
code: 'INVALID_GLOBAL_MCP_TRANSPORT',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const scope = input.scope ?? 'project';
|
||||
const results: Array<{ provider: LLMProvider; created: boolean; error?: string }> = [];
|
||||
const providers = providerRegistry.listProviders().filter((p) => includeProviderInGlobalMcp(p.id));
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
await provider.mcp.upsertServer({ ...input, scope });
|
||||
results.push({ provider: provider.id, created: true });
|
||||
} catch (error) {
|
||||
results.push({
|
||||
provider: provider.id,
|
||||
created: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
};
|
||||
26
server/modules/providers/services/provider-auth.service.ts
Normal file
26
server/modules/providers/services/provider-auth.service.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||
import type { LLMProvider, ProviderAuthStatus } from '@/shared/types.js';
|
||||
|
||||
export const providerAuthService = {
|
||||
/**
|
||||
* Resolves a provider and returns its installation/authentication status.
|
||||
*/
|
||||
async getProviderAuthStatus(providerName: string): Promise<ProviderAuthStatus> {
|
||||
const provider = providerRegistry.resolveProvider(providerName);
|
||||
return provider.auth.getStatus();
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns whether a provider runtime appears installed.
|
||||
* Falls back to true if status lookup itself fails so callers preserve the
|
||||
* original runtime error instead of replacing it with a status-check failure.
|
||||
*/
|
||||
async isProviderInstalled(providerName: LLMProvider): Promise<boolean> {
|
||||
try {
|
||||
const status = await this.getProviderAuthStatus(providerName);
|
||||
return status.installed;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
import { scanStateDb } from '@/modules/database/index.js';
|
||||
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||
import type { LLMProvider } from '@/shared/types.js';
|
||||
|
||||
type SessionSynchronizeResult = {
|
||||
processedByProvider: Record<LLMProvider, number>;
|
||||
failures: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Orchestrates provider-specific session indexers and indexed-session lifecycle operations.
|
||||
*/
|
||||
export const sessionSynchronizerService = {
|
||||
/**
|
||||
* Runs all provider synchronizers and updates scan_state.last_scanned_at.
|
||||
*/
|
||||
async synchronizeSessions(): Promise<SessionSynchronizeResult> {
|
||||
const lastScanAt = scanStateDb.getLastScannedAt();
|
||||
const scanBoundary = new Date();
|
||||
const processedByProvider: Record<LLMProvider, number> = {
|
||||
claude: 0,
|
||||
codex: 0,
|
||||
cursor: 0,
|
||||
gemini: 0,
|
||||
};
|
||||
const failures: string[] = [];
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
providerRegistry.listProviders().map(async (provider) => ({
|
||||
provider: provider.id,
|
||||
processed: await provider.sessionSynchronizer.synchronize(lastScanAt ?? undefined),
|
||||
}))
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
processedByProvider[result.value.provider] = result.value.processed;
|
||||
continue;
|
||||
}
|
||||
|
||||
const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
||||
failures.push(reason);
|
||||
}
|
||||
|
||||
if (failures.length === 0) {
|
||||
scanStateDb.updateLastScannedAt(scanBoundary);
|
||||
} else {
|
||||
console.warn(
|
||||
`[Sessions] Skipping scan_state cursor advance because ${failures.length} provider sync(s) failed.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
processedByProvider,
|
||||
failures,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Indexes one provider artifact file without running a full provider rescan.
|
||||
*/
|
||||
async synchronizeProviderFile(
|
||||
provider: LLMProvider,
|
||||
filePath: string
|
||||
): Promise<{ provider: LLMProvider; indexed: boolean; sessionId: string | null }> {
|
||||
const resolvedProvider = providerRegistry.resolveProvider(provider);
|
||||
const sessionId = await resolvedProvider.sessionSynchronizer.synchronizeFile(filePath);
|
||||
return {
|
||||
provider,
|
||||
indexed: Boolean(sessionId),
|
||||
sessionId,
|
||||
};
|
||||
},
|
||||
};
|
||||
283
server/modules/providers/services/sessions-watcher.service.ts
Normal file
283
server/modules/providers/services/sessions-watcher.service.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { promises as fsPromises } from 'node:fs';
|
||||
|
||||
import chokidar, { type FSWatcher } from 'chokidar';
|
||||
|
||||
import { sessionSynchronizerService } from '@/modules/providers/services/session-synchronizer.service.js';
|
||||
import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js';
|
||||
import type { LLMProvider } from '@/shared/types.js';
|
||||
import { getProjectsWithSessions } from '@/modules/projects/index.js';
|
||||
|
||||
type WatcherEventType = 'add' | 'change';
|
||||
|
||||
const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> = [
|
||||
{
|
||||
provider: 'claude',
|
||||
rootPath: path.join(os.homedir(), '.claude', 'projects'),
|
||||
},
|
||||
{
|
||||
provider: 'cursor',
|
||||
rootPath: path.join(os.homedir(), '.cursor', 'chats'),
|
||||
},
|
||||
{
|
||||
provider: 'codex',
|
||||
rootPath: path.join(os.homedir(), '.codex', 'sessions'),
|
||||
},
|
||||
{
|
||||
provider: 'gemini',
|
||||
rootPath: path.join(os.homedir(), '.gemini', 'sessions'),
|
||||
},
|
||||
{
|
||||
provider: 'gemini',
|
||||
rootPath: path.join(os.homedir(), '.gemini', 'tmp'),
|
||||
},
|
||||
];
|
||||
|
||||
const WATCHER_IGNORED_PATTERNS = [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
'**/dist/**',
|
||||
'**/build/**',
|
||||
'**/*.tmp',
|
||||
'**/*.swp',
|
||||
'**/.DS_Store',
|
||||
];
|
||||
|
||||
const PROJECTS_UPDATE_DEBOUNCE_MS = 500;
|
||||
const PROJECTS_UPDATE_MAX_WAIT_MS = 2_000;
|
||||
|
||||
const watchers: FSWatcher[] = [];
|
||||
|
||||
type PendingWatcherUpdate = {
|
||||
providers: Set<LLMProvider>;
|
||||
changeTypes: Set<WatcherEventType>;
|
||||
updatedSessionIds: Set<string>;
|
||||
};
|
||||
|
||||
let pendingWatcherUpdate: PendingWatcherUpdate | null = null;
|
||||
let pendingWatcherUpdateStartedAt: number | null = null;
|
||||
let pendingWatcherFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let watcherRefreshInFlight = false;
|
||||
let watcherRescheduleAfterRefresh = false;
|
||||
|
||||
/**
|
||||
* Filters watcher events to provider-specific session artifact file types.
|
||||
*/
|
||||
function isWatcherTargetFile(provider: LLMProvider, filePath: string): boolean {
|
||||
if (provider === 'gemini') {
|
||||
return filePath.endsWith('.json') || filePath.endsWith('.jsonl');
|
||||
}
|
||||
|
||||
return filePath.endsWith('.jsonl');
|
||||
}
|
||||
|
||||
function clearPendingWatcherFlushTimer(): void {
|
||||
if (pendingWatcherFlushTimer) {
|
||||
clearTimeout(pendingWatcherFlushTimer);
|
||||
pendingWatcherFlushTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePendingWatcherFlush(): void {
|
||||
if (!pendingWatcherUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (pendingWatcherUpdateStartedAt === null) {
|
||||
pendingWatcherUpdateStartedAt = now;
|
||||
}
|
||||
|
||||
const elapsed = now - pendingWatcherUpdateStartedAt;
|
||||
const remainingMaxWait = Math.max(0, PROJECTS_UPDATE_MAX_WAIT_MS - elapsed);
|
||||
const delay = Math.min(PROJECTS_UPDATE_DEBOUNCE_MS, remainingMaxWait);
|
||||
|
||||
clearPendingWatcherFlushTimer();
|
||||
pendingWatcherFlushTimer = setTimeout(() => {
|
||||
void flushPendingWatcherUpdate();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function queuePendingWatcherUpdate(
|
||||
eventType: WatcherEventType,
|
||||
provider: LLMProvider,
|
||||
updatedSessionId: string | null
|
||||
): void {
|
||||
if (!pendingWatcherUpdate) {
|
||||
pendingWatcherUpdate = {
|
||||
providers: new Set<LLMProvider>(),
|
||||
changeTypes: new Set<WatcherEventType>(),
|
||||
updatedSessionIds: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
pendingWatcherUpdate.providers.add(provider);
|
||||
pendingWatcherUpdate.changeTypes.add(eventType);
|
||||
if (updatedSessionId) {
|
||||
pendingWatcherUpdate.updatedSessionIds.add(updatedSessionId);
|
||||
}
|
||||
|
||||
schedulePendingWatcherFlush();
|
||||
}
|
||||
|
||||
async function flushPendingWatcherUpdate(): Promise<void> {
|
||||
clearPendingWatcherFlushTimer();
|
||||
|
||||
if (!pendingWatcherUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (watcherRefreshInFlight) {
|
||||
watcherRescheduleAfterRefresh = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const queuedUpdate = pendingWatcherUpdate;
|
||||
pendingWatcherUpdate = null;
|
||||
pendingWatcherUpdateStartedAt = null;
|
||||
watcherRefreshInFlight = true;
|
||||
|
||||
try {
|
||||
const updatedProjects = await getProjectsWithSessions({ skipSynchronization: true });
|
||||
const changeTypes = Array.from(queuedUpdate.changeTypes);
|
||||
const watchProviders = Array.from(queuedUpdate.providers);
|
||||
const updatedSessionIds = Array.from(queuedUpdate.updatedSessionIds);
|
||||
|
||||
// Backward-compatible fields stay populated with the first queued values.
|
||||
const updateMessage = JSON.stringify({
|
||||
type: 'projects_updated',
|
||||
projects: updatedProjects,
|
||||
timestamp: new Date().toISOString(),
|
||||
changeType: changeTypes[0] ?? 'change',
|
||||
updatedSessionId: updatedSessionIds[0] ?? undefined,
|
||||
watchProvider: watchProviders[0] ?? undefined,
|
||||
changeTypes,
|
||||
updatedSessionIds,
|
||||
watchProviders,
|
||||
batched: true,
|
||||
});
|
||||
|
||||
connectedClients.forEach(client => {
|
||||
if (client.readyState === WS_OPEN_STATE) {
|
||||
client.send(updateMessage);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error('Session watcher refresh failed while broadcasting projects_updated', { error: message });
|
||||
} finally {
|
||||
watcherRefreshInFlight = false;
|
||||
|
||||
if (pendingWatcherUpdate || watcherRescheduleAfterRefresh) {
|
||||
watcherRescheduleAfterRefresh = false;
|
||||
schedulePendingWatcherFlush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles file watcher updates and triggers provider file-level synchronization.
|
||||
*/
|
||||
async function onUpdate(
|
||||
eventType: WatcherEventType,
|
||||
filePath: string,
|
||||
provider: LLMProvider
|
||||
): Promise<void> {
|
||||
if (!isWatcherTargetFile(provider, filePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sessionSynchronizerService.synchronizeProviderFile(provider, filePath);
|
||||
if (!result.indexed) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Session synchronization triggered by ${eventType} event for provider "${provider}"`, {
|
||||
filePath,
|
||||
sessionId: result.sessionId,
|
||||
});
|
||||
queuePendingWatcherUpdate(eventType, provider, result.sessionId);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Session watcher sync failed for provider "${provider}"`, {
|
||||
eventType,
|
||||
filePath,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts provider filesystem watchers and performs initial DB synchronization.
|
||||
*/
|
||||
export async function initializeSessionsWatcher(): Promise<void> {
|
||||
console.log('Setting up session watchers');
|
||||
|
||||
const initialSync = await sessionSynchronizerService.synchronizeSessions();
|
||||
console.log('Initial session synchronization complete', {
|
||||
processedByProvider: initialSync.processedByProvider,
|
||||
failures: initialSync.failures,
|
||||
});
|
||||
|
||||
for (const { provider, rootPath } of PROVIDER_WATCH_PATHS) {
|
||||
try {
|
||||
await fsPromises.mkdir(rootPath, { recursive: true });
|
||||
|
||||
const watcher = chokidar.watch(rootPath, {
|
||||
ignored: WATCHER_IGNORED_PATTERNS,
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
followSymlinks: false,
|
||||
depth: 6,
|
||||
usePolling: true,
|
||||
interval: 6_000,
|
||||
binaryInterval: 6_000,
|
||||
});
|
||||
|
||||
watcher
|
||||
.on('add', (filePath: string) => {
|
||||
void onUpdate('add', filePath, provider);
|
||||
})
|
||||
.on('change', (filePath: string) => {
|
||||
void onUpdate('change', filePath, provider);
|
||||
})
|
||||
.on('error', (error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Session watcher error for provider "${provider}"`, { error: message });
|
||||
});
|
||||
|
||||
watchers.push(watcher);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Failed to initialize session watcher for provider "${provider}"`, {
|
||||
rootPath,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops all active provider session watchers.
|
||||
*/
|
||||
export async function closeSessionsWatcher(): Promise<void> {
|
||||
clearPendingWatcherFlushTimer();
|
||||
|
||||
await Promise.all(
|
||||
watchers.map(async (watcher) => {
|
||||
try {
|
||||
await watcher.close();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error('Failed to close session watcher', { error: message });
|
||||
}
|
||||
})
|
||||
);
|
||||
watchers.length = 0;
|
||||
pendingWatcherUpdate = null;
|
||||
pendingWatcherUpdateStartedAt = null;
|
||||
watcherRefreshInFlight = false;
|
||||
watcherRescheduleAfterRefresh = false;
|
||||
}
|
||||
130
server/modules/providers/services/sessions.service.ts
Normal file
130
server/modules/providers/services/sessions.service.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||
import type {
|
||||
FetchHistoryOptions,
|
||||
FetchHistoryResult,
|
||||
LLMProvider,
|
||||
NormalizedMessage,
|
||||
} from '@/shared/types.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
/**
|
||||
* Removes one file if it exists.
|
||||
*/
|
||||
async function removeFileIfExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fsp.unlink(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Application service for provider-backed session message operations.
|
||||
*
|
||||
* Callers pass a provider id and this service resolves the concrete provider
|
||||
* class, keeping normalization/history call sites decoupled from implementation
|
||||
* file layout.
|
||||
*/
|
||||
export const sessionsService = {
|
||||
/**
|
||||
* Lists provider ids that can load session history and normalize live messages.
|
||||
*/
|
||||
listProviderIds(): LLMProvider[] {
|
||||
return providerRegistry.listProviders().map((provider) => provider.id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Normalizes one provider-native event into frontend session message events.
|
||||
*/
|
||||
normalizeMessage(
|
||||
providerName: string,
|
||||
raw: unknown,
|
||||
sessionId: string | null,
|
||||
): NormalizedMessage[] {
|
||||
return providerRegistry.resolveProvider(providerName).sessions.normalizeMessage(raw, sessionId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches persisted history by session id.
|
||||
*
|
||||
* Provider and provider-specific lookup hints are resolved from the indexed
|
||||
* session metadata in the database.
|
||||
*/
|
||||
fetchHistory(
|
||||
sessionId: string,
|
||||
options: Pick<FetchHistoryOptions, 'limit' | 'offset'> = {},
|
||||
): Promise<FetchHistoryResult> {
|
||||
const session = sessionsDb.getSessionById(sessionId);
|
||||
if (!session) {
|
||||
throw new AppError(`Session "${sessionId}" was not found.`, {
|
||||
code: 'SESSION_NOT_FOUND',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const provider = session.provider as LLMProvider;
|
||||
return providerRegistry.resolveProvider(provider).sessions.fetchHistory(sessionId, {
|
||||
limit: options.limit ?? null,
|
||||
offset: options.offset ?? 0,
|
||||
projectPath: session.project_path ?? '',
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Deletes one persisted session row by id.
|
||||
*
|
||||
* When `deletedFromDisk` is true and a session `jsonl_path` exists, the path
|
||||
* is deleted from disk before the DB row is removed.
|
||||
*/
|
||||
async deleteSessionById(
|
||||
sessionId: string,
|
||||
deletedFromDisk = false,
|
||||
): Promise<{ sessionId: string; deletedFromDisk: boolean }> {
|
||||
const session = sessionsDb.getSessionById(sessionId);
|
||||
if (!session) {
|
||||
throw new AppError(`Session "${sessionId}" was not found.`, {
|
||||
code: 'SESSION_NOT_FOUND',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
let removedFromDisk = false;
|
||||
if (deletedFromDisk && session.jsonl_path) {
|
||||
removedFromDisk = await removeFileIfExists(session.jsonl_path);
|
||||
}
|
||||
|
||||
const deleted = sessionsDb.deleteSessionById(sessionId);
|
||||
if (!deleted) {
|
||||
throw new AppError(`Session "${sessionId}" was not found.`, {
|
||||
code: 'SESSION_NOT_FOUND',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return { sessionId, deletedFromDisk: removedFromDisk };
|
||||
},
|
||||
|
||||
/**
|
||||
* Renames one session by id without requiring the caller to pass provider.
|
||||
*/
|
||||
renameSessionById(sessionId: string, summary: string): { sessionId: string; summary: string } {
|
||||
const session = sessionsDb.getSessionById(sessionId);
|
||||
if (!session) {
|
||||
throw new AppError(`Session "${sessionId}" was not found.`, {
|
||||
code: 'SESSION_NOT_FOUND',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
sessionsDb.updateSessionCustomName(sessionId, summary);
|
||||
return { sessionId, summary };
|
||||
},
|
||||
};
|
||||
27
server/modules/providers/shared/base/abstract.provider.ts
Normal file
27
server/modules/providers/shared/base/abstract.provider.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type {
|
||||
IProvider,
|
||||
IProviderAuth,
|
||||
IProviderMcp,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSessions,
|
||||
} from '@/shared/interfaces.js';
|
||||
import type { LLMProvider } from '@/shared/types.js';
|
||||
|
||||
/**
|
||||
* Shared provider base.
|
||||
*
|
||||
* Concrete providers must expose auth/MCP handlers and implement message
|
||||
* normalization/history loading because those behaviors depend on native
|
||||
* SDK/CLI formats.
|
||||
*/
|
||||
export abstract class AbstractProvider implements IProvider {
|
||||
readonly id: LLMProvider;
|
||||
abstract readonly mcp: IProviderMcp;
|
||||
abstract readonly auth: IProviderAuth;
|
||||
abstract readonly sessions: IProviderSessions;
|
||||
abstract readonly sessionSynchronizer: IProviderSessionSynchronizer;
|
||||
|
||||
protected constructor(id: LLMProvider) {
|
||||
this.id = id;
|
||||
}
|
||||
}
|
||||
151
server/modules/providers/shared/mcp/mcp.provider.ts
Normal file
151
server/modules/providers/shared/mcp/mcp.provider.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import type { IProviderMcp } from '@/shared/interfaces.js';
|
||||
import type { LLMProvider, McpScope, McpTransport, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
const resolveWorkspacePath = (workspacePath?: string): string =>
|
||||
path.resolve(workspacePath ?? process.cwd());
|
||||
|
||||
const normalizeServerName = (name: string): string => {
|
||||
const normalized = name.trim();
|
||||
if (!normalized) {
|
||||
throw new AppError('MCP server name is required.', {
|
||||
code: 'MCP_SERVER_NAME_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared MCP provider for provider-specific config readers/writers.
|
||||
*/
|
||||
export abstract class McpProvider implements IProviderMcp {
|
||||
protected readonly provider: LLMProvider;
|
||||
protected readonly supportedScopes: McpScope[];
|
||||
protected readonly supportedTransports: McpTransport[];
|
||||
|
||||
protected constructor(
|
||||
provider: LLMProvider,
|
||||
supportedScopes: McpScope[],
|
||||
supportedTransports: McpTransport[],
|
||||
) {
|
||||
this.provider = provider;
|
||||
this.supportedScopes = supportedScopes;
|
||||
this.supportedTransports = supportedTransports;
|
||||
}
|
||||
|
||||
async listServers(options?: { workspacePath?: string }): Promise<Record<McpScope, ProviderMcpServer[]>> {
|
||||
const grouped: Record<McpScope, ProviderMcpServer[]> = {
|
||||
user: [],
|
||||
local: [],
|
||||
project: [],
|
||||
};
|
||||
|
||||
for (const scope of this.supportedScopes) {
|
||||
grouped[scope] = await this.listServersForScope(scope, options);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
async listServersForScope(
|
||||
scope: McpScope,
|
||||
options?: { workspacePath?: string },
|
||||
): Promise<ProviderMcpServer[]> {
|
||||
if (!this.supportedScopes.includes(scope)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const workspacePath = resolveWorkspacePath(options?.workspacePath);
|
||||
const scopedServers = await this.readScopedServers(scope, workspacePath);
|
||||
return Object.entries(scopedServers)
|
||||
.map(([name, rawConfig]) => this.normalizeServerConfig(scope, name, rawConfig))
|
||||
.filter((entry): entry is ProviderMcpServer => entry !== null);
|
||||
}
|
||||
|
||||
async upsertServer(input: UpsertProviderMcpServerInput): Promise<ProviderMcpServer> {
|
||||
const scope = input.scope ?? 'project';
|
||||
this.assertScopeAndTransport(scope, input.transport);
|
||||
|
||||
const workspacePath = resolveWorkspacePath(input.workspacePath);
|
||||
const normalizedName = normalizeServerName(input.name);
|
||||
const scopedServers = await this.readScopedServers(scope, workspacePath);
|
||||
scopedServers[normalizedName] = this.buildServerConfig(input);
|
||||
await this.writeScopedServers(scope, workspacePath, scopedServers);
|
||||
|
||||
return {
|
||||
provider: this.provider,
|
||||
name: normalizedName,
|
||||
scope,
|
||||
transport: input.transport,
|
||||
command: input.command,
|
||||
args: input.args,
|
||||
env: input.env,
|
||||
cwd: input.cwd,
|
||||
url: input.url,
|
||||
headers: input.headers,
|
||||
envVars: input.envVars,
|
||||
bearerTokenEnvVar: input.bearerTokenEnvVar,
|
||||
envHttpHeaders: input.envHttpHeaders,
|
||||
};
|
||||
}
|
||||
|
||||
async removeServer(
|
||||
input: { name: string; scope?: McpScope; workspacePath?: string },
|
||||
): Promise<{ removed: boolean; provider: LLMProvider; name: string; scope: McpScope }> {
|
||||
const scope = input.scope ?? 'project';
|
||||
this.assertScope(scope);
|
||||
|
||||
const workspacePath = resolveWorkspacePath(input.workspacePath);
|
||||
const normalizedName = normalizeServerName(input.name);
|
||||
const scopedServers = await this.readScopedServers(scope, workspacePath);
|
||||
const removed = Object.prototype.hasOwnProperty.call(scopedServers, normalizedName);
|
||||
if (removed) {
|
||||
delete scopedServers[normalizedName];
|
||||
await this.writeScopedServers(scope, workspacePath, scopedServers);
|
||||
}
|
||||
|
||||
return { removed, provider: this.provider, name: normalizedName, scope };
|
||||
}
|
||||
|
||||
protected abstract readScopedServers(
|
||||
scope: McpScope,
|
||||
workspacePath: string,
|
||||
): Promise<Record<string, unknown>>;
|
||||
|
||||
protected abstract writeScopedServers(
|
||||
scope: McpScope,
|
||||
workspacePath: string,
|
||||
servers: Record<string, unknown>,
|
||||
): Promise<void>;
|
||||
|
||||
protected abstract buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown>;
|
||||
|
||||
protected abstract normalizeServerConfig(
|
||||
scope: McpScope,
|
||||
name: string,
|
||||
rawConfig: unknown,
|
||||
): ProviderMcpServer | null;
|
||||
|
||||
protected assertScope(scope: McpScope): void {
|
||||
if (!this.supportedScopes.includes(scope)) {
|
||||
throw new AppError(`Provider "${this.provider}" does not support "${scope}" MCP scope.`, {
|
||||
code: 'MCP_SCOPE_NOT_SUPPORTED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected assertScopeAndTransport(scope: McpScope, transport: McpTransport): void {
|
||||
this.assertScope(scope);
|
||||
if (!this.supportedTransports.includes(transport)) {
|
||||
throw new AppError(`Provider "${this.provider}" does not support "${transport}" MCP transport.`, {
|
||||
code: 'MCP_TRANSPORT_NOT_SUPPORTED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
293
server/modules/providers/tests/mcp.test.ts
Normal file
293
server/modules/providers/tests/mcp.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import TOML from '@iarna/toml';
|
||||
|
||||
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
const patchHomeDir = (nextHomeDir: string) => {
|
||||
const original = os.homedir;
|
||||
(os as any).homedir = () => nextHomeDir;
|
||||
return () => {
|
||||
(os as any).homedir = original;
|
||||
};
|
||||
};
|
||||
|
||||
const readJson = async (filePath: string): Promise<Record<string, unknown>> => {
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
return JSON.parse(content) as Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* This test covers Claude MCP support for all scopes (user/local/project) and all transports (stdio/http/sse),
|
||||
* including add, update/list, and remove operations.
|
||||
*/
|
||||
test('providerMcpService handles claude MCP scopes/transports with file-backed persistence', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-claude-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await fs.mkdir(workspacePath, { recursive: true });
|
||||
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
try {
|
||||
await providerMcpService.upsertProviderMcpServer('claude', {
|
||||
name: 'claude-user-stdio',
|
||||
scope: 'user',
|
||||
transport: 'stdio',
|
||||
command: 'npx',
|
||||
args: ['-y', 'my-server'],
|
||||
env: { API_KEY: 'secret' },
|
||||
});
|
||||
|
||||
await providerMcpService.upsertProviderMcpServer('claude', {
|
||||
name: 'claude-local-http',
|
||||
scope: 'local',
|
||||
transport: 'http',
|
||||
url: 'https://example.com/mcp',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
await providerMcpService.upsertProviderMcpServer('claude', {
|
||||
name: 'claude-project-sse',
|
||||
scope: 'project',
|
||||
transport: 'sse',
|
||||
url: 'https://example.com/sse',
|
||||
headers: { 'X-API-Key': 'abc' },
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
const grouped = await providerMcpService.listProviderMcpServers('claude', { workspacePath });
|
||||
assert.ok(grouped.user.some((server) => server.name === 'claude-user-stdio' && server.transport === 'stdio'));
|
||||
assert.ok(grouped.local.some((server) => server.name === 'claude-local-http' && server.transport === 'http'));
|
||||
assert.ok(grouped.project.some((server) => server.name === 'claude-project-sse' && server.transport === 'sse'));
|
||||
|
||||
// update behavior is the same upsert route with same name
|
||||
await providerMcpService.upsertProviderMcpServer('claude', {
|
||||
name: 'claude-project-sse',
|
||||
scope: 'project',
|
||||
transport: 'sse',
|
||||
url: 'https://example.com/sse-updated',
|
||||
headers: { 'X-API-Key': 'updated' },
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
const projectConfig = await readJson(path.join(workspacePath, '.mcp.json'));
|
||||
const projectServers = projectConfig.mcpServers as Record<string, unknown>;
|
||||
const projectServer = projectServers['claude-project-sse'] as Record<string, unknown>;
|
||||
assert.equal(projectServer.url, 'https://example.com/sse-updated');
|
||||
|
||||
const removeResult = await providerMcpService.removeProviderMcpServer('claude', {
|
||||
name: 'claude-local-http',
|
||||
scope: 'local',
|
||||
workspacePath,
|
||||
});
|
||||
assert.equal(removeResult.removed, true);
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This test covers Codex MCP support for user/project scopes, stdio/http formats,
|
||||
* and validation for unsupported scope/transport combinations.
|
||||
*/
|
||||
test('providerMcpService handles codex MCP TOML config and capability validation', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-codex-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await fs.mkdir(workspacePath, { recursive: true });
|
||||
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
try {
|
||||
await providerMcpService.upsertProviderMcpServer('codex', {
|
||||
name: 'codex-user-stdio',
|
||||
scope: 'user',
|
||||
transport: 'stdio',
|
||||
command: 'python',
|
||||
args: ['server.py'],
|
||||
env: { API_KEY: 'x' },
|
||||
envVars: ['API_KEY'],
|
||||
cwd: '/tmp',
|
||||
});
|
||||
|
||||
await providerMcpService.upsertProviderMcpServer('codex', {
|
||||
name: 'codex-project-http',
|
||||
scope: 'project',
|
||||
transport: 'http',
|
||||
url: 'https://codex.example.com/mcp',
|
||||
headers: { 'X-Custom-Header': 'value' },
|
||||
envHttpHeaders: { 'X-API-Key': 'MY_API_KEY_ENV' },
|
||||
bearerTokenEnvVar: 'MY_API_TOKEN',
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
const userTomlPath = path.join(tempRoot, '.codex', 'config.toml');
|
||||
const userConfig = TOML.parse(await fs.readFile(userTomlPath, 'utf8')) as Record<string, unknown>;
|
||||
const userServers = userConfig.mcp_servers as Record<string, unknown>;
|
||||
const userStdio = userServers['codex-user-stdio'] as Record<string, unknown>;
|
||||
assert.equal(userStdio.command, 'python');
|
||||
|
||||
const projectTomlPath = path.join(workspacePath, '.codex', 'config.toml');
|
||||
const projectConfig = TOML.parse(await fs.readFile(projectTomlPath, 'utf8')) as Record<string, unknown>;
|
||||
const projectServers = projectConfig.mcp_servers as Record<string, unknown>;
|
||||
const projectHttp = projectServers['codex-project-http'] as Record<string, unknown>;
|
||||
assert.equal(projectHttp.url, 'https://codex.example.com/mcp');
|
||||
|
||||
await assert.rejects(
|
||||
providerMcpService.upsertProviderMcpServer('codex', {
|
||||
name: 'codex-local',
|
||||
scope: 'local',
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof AppError &&
|
||||
error.code === 'MCP_SCOPE_NOT_SUPPORTED' &&
|
||||
error.statusCode === 400,
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
providerMcpService.upsertProviderMcpServer('codex', {
|
||||
name: 'codex-sse',
|
||||
scope: 'project',
|
||||
transport: 'sse',
|
||||
url: 'https://example.com/sse',
|
||||
workspacePath,
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof AppError &&
|
||||
error.code === 'MCP_TRANSPORT_NOT_SUPPORTED' &&
|
||||
error.statusCode === 400,
|
||||
);
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This test covers Gemini/Cursor MCP JSON formats and user/project scope persistence.
|
||||
*/
|
||||
test('providerMcpService handles gemini and cursor MCP JSON config formats', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-gc-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await fs.mkdir(workspacePath, { recursive: true });
|
||||
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
try {
|
||||
await providerMcpService.upsertProviderMcpServer('gemini', {
|
||||
name: 'gemini-stdio',
|
||||
scope: 'user',
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: { TOKEN: '$TOKEN' },
|
||||
cwd: './server',
|
||||
});
|
||||
|
||||
await providerMcpService.upsertProviderMcpServer('gemini', {
|
||||
name: 'gemini-http',
|
||||
scope: 'project',
|
||||
transport: 'http',
|
||||
url: 'https://gemini.example.com/mcp',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
await providerMcpService.upsertProviderMcpServer('cursor', {
|
||||
name: 'cursor-stdio',
|
||||
scope: 'project',
|
||||
transport: 'stdio',
|
||||
command: 'npx',
|
||||
args: ['-y', 'mcp-server'],
|
||||
env: { API_KEY: 'value' },
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
await providerMcpService.upsertProviderMcpServer('cursor', {
|
||||
name: 'cursor-http',
|
||||
scope: 'user',
|
||||
transport: 'http',
|
||||
url: 'http://localhost:3333/mcp',
|
||||
headers: { API_KEY: 'value' },
|
||||
});
|
||||
|
||||
const geminiUserConfig = await readJson(path.join(tempRoot, '.gemini', 'settings.json'));
|
||||
const geminiUserServer = (geminiUserConfig.mcpServers as Record<string, unknown>)['gemini-stdio'] as Record<string, unknown>;
|
||||
assert.equal(geminiUserServer.command, 'node');
|
||||
assert.equal(geminiUserServer.type, undefined);
|
||||
|
||||
const geminiProjectConfig = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
|
||||
const geminiProjectServer = (geminiProjectConfig.mcpServers as Record<string, unknown>)['gemini-http'] as Record<string, unknown>;
|
||||
assert.equal(geminiProjectServer.type, 'http');
|
||||
|
||||
const cursorUserConfig = await readJson(path.join(tempRoot, '.cursor', 'mcp.json'));
|
||||
const cursorHttpServer = (cursorUserConfig.mcpServers as Record<string, unknown>)['cursor-http'] as Record<string, unknown>;
|
||||
assert.equal(cursorHttpServer.url, 'http://localhost:3333/mcp');
|
||||
assert.equal(cursorHttpServer.type, undefined);
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This test covers the global MCP adder requirement: only http/stdio are allowed and
|
||||
* one payload is written to all providers.
|
||||
*/
|
||||
test('providerMcpService global adder writes to all providers and rejects unsupported transports', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-global-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await fs.mkdir(workspacePath, { recursive: true });
|
||||
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
try {
|
||||
const globalResult = await providerMcpService.addMcpServerToAllProviders({
|
||||
name: 'global-http',
|
||||
scope: 'project',
|
||||
transport: 'http',
|
||||
url: 'https://global.example.com/mcp',
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
const expectCursorGlobal = process.platform !== 'win32';
|
||||
assert.equal(globalResult.length, expectCursorGlobal ? 4 : 3);
|
||||
assert.ok(globalResult.every((entry) => entry.created === true));
|
||||
|
||||
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
|
||||
assert.ok((claudeProject.mcpServers as Record<string, unknown>)['global-http']);
|
||||
|
||||
const codexProject = TOML.parse(await fs.readFile(path.join(workspacePath, '.codex', 'config.toml'), 'utf8')) as Record<string, unknown>;
|
||||
assert.ok((codexProject.mcp_servers as Record<string, unknown>)['global-http']);
|
||||
|
||||
const geminiProject = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
|
||||
assert.ok((geminiProject.mcpServers as Record<string, unknown>)['global-http']);
|
||||
|
||||
if (expectCursorGlobal) {
|
||||
const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
|
||||
assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);
|
||||
}
|
||||
|
||||
await assert.rejects(
|
||||
providerMcpService.addMcpServerToAllProviders({
|
||||
name: 'global-sse',
|
||||
scope: 'project',
|
||||
transport: 'sse',
|
||||
url: 'https://example.com/sse',
|
||||
workspacePath,
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof AppError &&
|
||||
error.code === 'INVALID_GLOBAL_MCP_TRANSPORT' &&
|
||||
error.statusCode === 400,
|
||||
);
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
267
server/modules/websocket/README.md
Normal file
267
server/modules/websocket/README.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# WebSocket Module
|
||||
|
||||
This module owns the server-side WebSocket gateway used by:
|
||||
|
||||
1. Chat streaming (`/ws`)
|
||||
2. Interactive terminal sessions (`/shell`)
|
||||
3. Plugin WebSocket passthrough (`/plugin-ws/:pluginName`)
|
||||
|
||||
It is intentionally structured as **small services** plus a **barrel export** in `index.ts`.
|
||||
|
||||
## Public API
|
||||
|
||||
`server/modules/websocket/index.ts` exports:
|
||||
|
||||
1. `createWebSocketServer(server, dependencies)`
|
||||
Creates and wires the shared `ws` server.
|
||||
2. `connectedClients` and `WS_OPEN_STATE`
|
||||
Shared chat client registry and open-state constant used by other modules.
|
||||
|
||||
## Why Dependency Injection Is Used
|
||||
|
||||
The module receives runtime-specific functions from `server/index.js` instead of importing legacy runtime files directly.
|
||||
|
||||
Benefits:
|
||||
|
||||
1. Keeps module boundaries clean (`server/modules/*` architecture rule).
|
||||
2. Makes each service easier to test in isolation.
|
||||
3. Keeps WebSocket transport concerns separate from provider runtime concerns.
|
||||
|
||||
## Service Map
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `services/websocket-server.service.ts` | Creates `WebSocketServer`, binds `verifyClient`, routes connection by pathname |
|
||||
| `services/websocket-auth.service.ts` | Authenticates upgrade requests and attaches `request.user` |
|
||||
| `services/chat-websocket.service.ts` | Handles `/ws` chat protocol and provider command/session control messages |
|
||||
| `services/shell-websocket.service.ts` | Handles `/shell` PTY lifecycle, reconnect buffering, auth URL detection |
|
||||
| `services/plugin-websocket-proxy.service.ts` | Bridges client socket to plugin socket |
|
||||
| `services/websocket-writer.service.ts` | Adapts raw WebSocket to writer interface (`send`, `setSessionId`, `getSessionId`) |
|
||||
| `services/websocket-state.service.ts` | Holds shared chat client set and open-state constant |
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[HTTP Server] --> B[createWebSocketServer]
|
||||
B --> C[verifyWebSocketClient]
|
||||
B --> D{Pathname}
|
||||
D -->|/ws| E[handleChatConnection]
|
||||
D -->|/shell| F[handleShellConnection]
|
||||
D -->|/plugin-ws/:name| G[handlePluginWsProxy]
|
||||
D -->|other| H[close()]
|
||||
|
||||
E --> I[connectedClients Set]
|
||||
E --> J[WebSocketWriter]
|
||||
F --> K[ptySessionsMap]
|
||||
G --> L[Upstream Plugin ws://127.0.0.1:port/ws]
|
||||
|
||||
I --> M[projects.service broadcastProgress]
|
||||
I --> N[sessions-watcher.service projects_updated]
|
||||
```
|
||||
|
||||
## Connection Handshake + Routing
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant WSS as WebSocketServer
|
||||
participant Auth as verifyWebSocketClient
|
||||
participant Router as connection router
|
||||
participant Chat as /ws handler
|
||||
participant Shell as /shell handler
|
||||
participant Proxy as /plugin-ws handler
|
||||
|
||||
Client->>WSS: Upgrade Request
|
||||
WSS->>Auth: verifyClient(info)
|
||||
alt Platform mode
|
||||
Auth->>Auth: authenticateWebSocket(null)
|
||||
Auth->>Auth: attach request.user
|
||||
else OSS mode
|
||||
Auth->>Auth: read token from ?token or Authorization
|
||||
Auth->>Auth: authenticateWebSocket(token)
|
||||
Auth->>Auth: attach request.user
|
||||
end
|
||||
|
||||
alt Auth failed
|
||||
Auth-->>WSS: false (reject handshake)
|
||||
else Auth ok
|
||||
Auth-->>WSS: true
|
||||
WSS->>Router: on("connection", ws, request)
|
||||
alt pathname == /ws
|
||||
Router->>Chat: handleChatConnection(ws, request, deps.chat)
|
||||
else pathname == /shell
|
||||
Router->>Shell: handleShellConnection(ws, deps.shell)
|
||||
else pathname startsWith /plugin-ws/
|
||||
Router->>Proxy: handlePluginWsProxy(ws, pathname, getPluginPort)
|
||||
else unknown
|
||||
Router->>Router: ws.close()
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## `/ws` Chat Flow
|
||||
|
||||
When a chat socket connects:
|
||||
|
||||
1. Add socket to `connectedClients`.
|
||||
2. Build `WebSocketWriter` (captures `userId` from authenticated request).
|
||||
3. Parse each incoming message with `parseIncomingJsonObject`.
|
||||
4. Dispatch by `data.type`.
|
||||
5. On close, remove socket from `connectedClients`.
|
||||
|
||||
### Chat Message Dispatch
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Incoming WS message] --> B[parseIncomingJsonObject]
|
||||
B -->|invalid| C[send {type:error}]
|
||||
B -->|ok| D{data.type}
|
||||
|
||||
D -->|claude-command| E[queryClaudeSDK]
|
||||
D -->|cursor-command| F[spawnCursor]
|
||||
D -->|codex-command| G[queryCodex]
|
||||
D -->|gemini-command| H[spawnGemini]
|
||||
D -->|cursor-resume| I[spawnCursor resume]
|
||||
D -->|abort-session| J[abort by provider]
|
||||
D -->|claude-permission-response| K[resolveToolApproval]
|
||||
D -->|cursor-abort| L[abortCursorSession]
|
||||
D -->|check-session-status| M[is*SessionActive + optional reconnectSessionWriter]
|
||||
D -->|get-pending-permissions| N[getPendingApprovalsForSession]
|
||||
D -->|get-active-sessions| O[getActive*Sessions]
|
||||
```
|
||||
|
||||
### Chat Notes
|
||||
|
||||
1. `abort-session` returns a normalized `complete` message with `aborted: true`.
|
||||
2. `check-session-status` returns `{ type: "session-status", isProcessing }`.
|
||||
3. Claude status checks can reconnect output stream to the new socket via `reconnectSessionWriter`.
|
||||
|
||||
## `/shell` Terminal Flow
|
||||
|
||||
The shell handler manages persistent PTY sessions keyed by:
|
||||
|
||||
`<projectPath>_<sessionIdOrDefault>[_cmd_<hash>]`
|
||||
|
||||
This enables reconnect behavior and isolates command-specific plain-shell sessions.
|
||||
|
||||
### Shell Lifecycle
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> WaitingInit
|
||||
WaitingInit --> ValidateInit: message.type == init
|
||||
ValidateInit --> ReconnectExisting: session key exists and not login reset
|
||||
ValidateInit --> SpawnNewPTY: valid path + valid sessionId
|
||||
ValidateInit --> EmitError: invalid payload/path/sessionId
|
||||
|
||||
ReconnectExisting --> Running: attach ws, replay buffer
|
||||
SpawnNewPTY --> Running: pty.spawn + wire onData/onExit
|
||||
|
||||
Running --> Running: input -> pty.write
|
||||
Running --> Running: resize -> pty.resize
|
||||
Running --> Running: onData -> buffer + output + auth_url detection
|
||||
Running --> Exited: onExit
|
||||
Running --> Detached: ws close
|
||||
|
||||
Detached --> Running: reconnect before timeout
|
||||
Detached --> Killed: timeout reached -> pty.kill
|
||||
Exited --> [*]
|
||||
Killed --> [*]
|
||||
EmitError --> WaitingInit
|
||||
```
|
||||
|
||||
### Shell Behaviors in Detail
|
||||
|
||||
1. `init`:
|
||||
Reads `projectPath`, `sessionId`, `provider`, `hasSession`, `initialCommand`, `isPlainShell`.
|
||||
2. Login reset:
|
||||
For login-like commands, existing keyed PTY session is killed and recreated.
|
||||
3. Validation:
|
||||
Path must exist and be a directory; `sessionId` must match safe pattern.
|
||||
4. Command build:
|
||||
Provider-specific command construction with resume semantics.
|
||||
5. PTY output buffering:
|
||||
Stores up to 5000 chunks for replay on reconnect.
|
||||
6. URL detection:
|
||||
Strips ANSI, accumulates text buffer, extracts URLs, emits `auth_url` once per normalized URL, supports `autoOpen`.
|
||||
7. Close behavior:
|
||||
Socket disconnect does not instantly kill PTY; session is kept alive and terminated on timeout.
|
||||
|
||||
## `/plugin-ws/:pluginName` Proxy Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Proxy as handlePluginWsProxy
|
||||
participant PM as getPluginPort
|
||||
participant Upstream as Plugin WS
|
||||
|
||||
Client->>Proxy: Connect /plugin-ws/:name
|
||||
Proxy->>Proxy: Validate pluginName regex
|
||||
alt Invalid name
|
||||
Proxy-->>Client: close(4400, "Invalid plugin name")
|
||||
else Valid
|
||||
Proxy->>PM: getPluginPort(name)
|
||||
alt Plugin not running
|
||||
Proxy-->>Client: close(4404, "Plugin not running")
|
||||
else Port found
|
||||
Proxy->>Upstream: new WebSocket(ws://127.0.0.1:port/ws)
|
||||
Client-->>Upstream: relay messages bidirectionally
|
||||
Upstream-->>Client: relay messages bidirectionally
|
||||
Upstream-->>Client: close propagation
|
||||
Client-->>Upstream: close propagation
|
||||
Upstream-->>Client: close(4502, "Upstream error") on upstream error
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Shared Client Registry and Broadcasts
|
||||
|
||||
Only chat sockets (`/ws`) are tracked in `connectedClients`.
|
||||
|
||||
That shared set is consumed by:
|
||||
|
||||
1. `modules/projects/services/projects-with-sessions-fetch.service.ts`
|
||||
Broadcasts `loading_progress` while project snapshots are being built.
|
||||
2. `modules/providers/services/sessions-watcher.service.ts`
|
||||
Broadcasts `projects_updated` when provider session artifacts change.
|
||||
|
||||
This design centralizes cross-module realtime fanout without requiring route-local references to WebSocket internals.
|
||||
|
||||
## Writer Adapter (`WebSocketWriter`)
|
||||
|
||||
`WebSocketWriter` normalizes chat transport behavior to match existing writer-style interfaces used elsewhere.
|
||||
|
||||
Methods:
|
||||
|
||||
1. `send(data)`
|
||||
JSON-serializes and sends only if socket is open.
|
||||
2. `setSessionId(sessionId)` / `getSessionId()`
|
||||
Supports provider session bookkeeping and resume flows.
|
||||
3. `updateWebSocket(newRawWs)`
|
||||
Allows active session stream redirection on reconnect.
|
||||
|
||||
## Error Handling and Close Codes
|
||||
|
||||
Current explicit close codes in this module:
|
||||
|
||||
1. `4400`: Invalid plugin name
|
||||
2. `4404`: Plugin not running
|
||||
3. `4502`: Upstream plugin WebSocket error
|
||||
|
||||
Other errors:
|
||||
|
||||
1. Chat handler catches and emits `{ type: "error", error }`.
|
||||
2. Shell handler catches and writes terminal-visible error output.
|
||||
3. Unknown websocket paths are closed immediately.
|
||||
|
||||
## Extending This Module
|
||||
|
||||
To add a new websocket route:
|
||||
|
||||
1. Add a new handler service under `services/`.
|
||||
2. Extend `WebSocketServerDependencies` in `websocket-server.service.ts` if needed.
|
||||
3. Add a new pathname branch in the router.
|
||||
4. Wire dependency injection from `server/index.js`.
|
||||
5. Keep `index.ts` as barrel-only export surface.
|
||||
2
server/modules/websocket/index.ts
Normal file
2
server/modules/websocket/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { WS_OPEN_STATE, connectedClients } from './services/websocket-state.service.js';
|
||||
export { createWebSocketServer } from './services/websocket-server.service.js';
|
||||
271
server/modules/websocket/services/chat-websocket.service.ts
Normal file
271
server/modules/websocket/services/chat-websocket.service.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import type { WebSocket } from 'ws';
|
||||
|
||||
import { connectedClients } from '@/modules/websocket/services/websocket-state.service.js';
|
||||
import { WebSocketWriter } from '@/modules/websocket/services/websocket-writer.service.js';
|
||||
import type {
|
||||
AnyRecord,
|
||||
AuthenticatedWebSocketRequest,
|
||||
LLMProvider,
|
||||
} from '@/shared/types.js';
|
||||
import { createNormalizedMessage, parseIncomingJsonObject } from '@/shared/utils.js';
|
||||
|
||||
type ChatIncomingMessage = AnyRecord & {
|
||||
type?: string;
|
||||
command?: string;
|
||||
options?: AnyRecord;
|
||||
provider?: string;
|
||||
sessionId?: string;
|
||||
requestId?: string;
|
||||
allow?: unknown;
|
||||
updatedInput?: unknown;
|
||||
message?: unknown;
|
||||
rememberEntry?: unknown;
|
||||
};
|
||||
|
||||
const DEFAULT_PROVIDER: LLMProvider = 'claude';
|
||||
|
||||
type ChatWebSocketDependencies = {
|
||||
queryClaudeSDK: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
||||
spawnCursor: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
||||
queryCodex: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
||||
spawnGemini: (command: string, options: unknown, writer: WebSocketWriter) => Promise<unknown>;
|
||||
abortClaudeSDKSession: (sessionId: string) => Promise<boolean>;
|
||||
abortCursorSession: (sessionId: string) => boolean;
|
||||
abortCodexSession: (sessionId: string) => boolean;
|
||||
abortGeminiSession: (sessionId: string) => boolean;
|
||||
resolveToolApproval: (
|
||||
requestId: string,
|
||||
payload: {
|
||||
allow: boolean;
|
||||
updatedInput?: unknown;
|
||||
message?: string;
|
||||
rememberEntry?: unknown;
|
||||
}
|
||||
) => void;
|
||||
isClaudeSDKSessionActive: (sessionId: string) => boolean;
|
||||
isCursorSessionActive: (sessionId: string) => boolean;
|
||||
isCodexSessionActive: (sessionId: string) => boolean;
|
||||
isGeminiSessionActive: (sessionId: string) => boolean;
|
||||
reconnectSessionWriter: (sessionId: string, ws: WebSocket) => boolean;
|
||||
getPendingApprovalsForSession: (sessionId: string) => unknown[];
|
||||
getActiveClaudeSDKSessions: () => unknown;
|
||||
getActiveCursorSessions: () => unknown;
|
||||
getActiveCodexSessions: () => unknown;
|
||||
getActiveGeminiSessions: () => unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes potentially invalid provider names coming from websocket payloads.
|
||||
*/
|
||||
function readProvider(value: unknown): LLMProvider {
|
||||
if (value === 'claude' || value === 'cursor' || value === 'codex' || value === 'gemini') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return DEFAULT_PROVIDER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the authenticated request user id in the formats currently produced
|
||||
* by platform and OSS auth code paths.
|
||||
*/
|
||||
function readRequestUserId(
|
||||
request: AuthenticatedWebSocketRequest | undefined
|
||||
): string | number | null {
|
||||
const user = request?.user;
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof user.id === 'string' || typeof user.id === 'number') {
|
||||
return user.id;
|
||||
}
|
||||
|
||||
if (typeof user.userId === 'string' || typeof user.userId === 'number') {
|
||||
return user.userId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles authenticated chat websocket messages used by the main chat panel.
|
||||
*/
|
||||
export function handleChatConnection(
|
||||
ws: WebSocket,
|
||||
request: AuthenticatedWebSocketRequest,
|
||||
dependencies: ChatWebSocketDependencies
|
||||
): void {
|
||||
console.log('[INFO] Chat WebSocket connected');
|
||||
connectedClients.add(ws);
|
||||
|
||||
const writer = new WebSocketWriter(ws, readRequestUserId(request));
|
||||
|
||||
ws.on('message', async (rawMessage) => {
|
||||
try {
|
||||
const parsed = parseIncomingJsonObject(rawMessage);
|
||||
if (!parsed) {
|
||||
throw new Error('Invalid websocket payload');
|
||||
}
|
||||
|
||||
const data = parsed as ChatIncomingMessage;
|
||||
const messageType = data.type;
|
||||
if (!messageType) {
|
||||
throw new Error('Message type is required');
|
||||
}
|
||||
|
||||
if (messageType === 'claude-command') {
|
||||
await dependencies.queryClaudeSDK(data.command ?? '', data.options, writer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'cursor-command') {
|
||||
await dependencies.spawnCursor(data.command ?? '', data.options, writer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'codex-command') {
|
||||
await dependencies.queryCodex(data.command ?? '', data.options, writer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'gemini-command') {
|
||||
await dependencies.spawnGemini(data.command ?? '', data.options, writer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'cursor-resume') {
|
||||
await dependencies.spawnCursor(
|
||||
'',
|
||||
{
|
||||
sessionId: data.sessionId,
|
||||
resume: true,
|
||||
cwd: data.options?.cwd,
|
||||
},
|
||||
writer
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'abort-session') {
|
||||
const provider = readProvider(data.provider);
|
||||
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
|
||||
let success = false;
|
||||
|
||||
if (provider === 'cursor') {
|
||||
success = dependencies.abortCursorSession(sessionId);
|
||||
} else if (provider === 'codex') {
|
||||
success = dependencies.abortCodexSession(sessionId);
|
||||
} else if (provider === 'gemini') {
|
||||
success = dependencies.abortGeminiSession(sessionId);
|
||||
} else {
|
||||
success = await dependencies.abortClaudeSDKSession(sessionId);
|
||||
}
|
||||
|
||||
writer.send(
|
||||
createNormalizedMessage({
|
||||
kind: 'complete',
|
||||
exitCode: success ? 0 : 1,
|
||||
aborted: true,
|
||||
success,
|
||||
sessionId,
|
||||
provider,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'claude-permission-response') {
|
||||
if (typeof data.requestId === 'string' && data.requestId.length > 0) {
|
||||
dependencies.resolveToolApproval(data.requestId, {
|
||||
allow: Boolean(data.allow),
|
||||
updatedInput: data.updatedInput,
|
||||
message: typeof data.message === 'string' ? data.message : undefined,
|
||||
rememberEntry: data.rememberEntry,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'cursor-abort') {
|
||||
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
|
||||
const success = dependencies.abortCursorSession(sessionId);
|
||||
writer.send(
|
||||
createNormalizedMessage({
|
||||
kind: 'complete',
|
||||
exitCode: success ? 0 : 1,
|
||||
aborted: true,
|
||||
success,
|
||||
sessionId,
|
||||
provider: 'cursor',
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'check-session-status') {
|
||||
const provider = readProvider(data.provider);
|
||||
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
|
||||
let isActive = false;
|
||||
|
||||
if (provider === 'cursor') {
|
||||
isActive = dependencies.isCursorSessionActive(sessionId);
|
||||
} else if (provider === 'codex') {
|
||||
isActive = dependencies.isCodexSessionActive(sessionId);
|
||||
} else if (provider === 'gemini') {
|
||||
isActive = dependencies.isGeminiSessionActive(sessionId);
|
||||
} else {
|
||||
isActive = dependencies.isClaudeSDKSessionActive(sessionId);
|
||||
if (isActive) {
|
||||
dependencies.reconnectSessionWriter(sessionId, ws);
|
||||
}
|
||||
}
|
||||
|
||||
writer.send({
|
||||
type: 'session-status',
|
||||
sessionId,
|
||||
provider,
|
||||
isProcessing: isActive,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'get-pending-permissions') {
|
||||
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
|
||||
if (sessionId && dependencies.isClaudeSDKSessionActive(sessionId)) {
|
||||
const pending = dependencies.getPendingApprovalsForSession(sessionId);
|
||||
writer.send({
|
||||
type: 'pending-permissions-response',
|
||||
sessionId,
|
||||
data: pending,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageType === 'get-active-sessions') {
|
||||
writer.send({
|
||||
type: 'active-sessions',
|
||||
sessions: {
|
||||
claude: dependencies.getActiveClaudeSDKSessions(),
|
||||
cursor: dependencies.getActiveCursorSessions(),
|
||||
codex: dependencies.getActiveCodexSessions(),
|
||||
gemini: dependencies.getActiveGeminiSessions(),
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error('[ERROR] Chat WebSocket error:', message);
|
||||
writer.send({
|
||||
type: 'error',
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('[INFO] Chat client disconnected');
|
||||
connectedClients.delete(ws);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
/**
|
||||
* Proxies an authenticated client websocket to a plugin websocket endpoint.
|
||||
*/
|
||||
export function handlePluginWsProxy(
|
||||
clientWs: WebSocket,
|
||||
pathname: string,
|
||||
getPluginPort: (pluginName: string) => number | null
|
||||
): void {
|
||||
const pluginName = pathname.replace('/plugin-ws/', '');
|
||||
if (!pluginName || /[^a-zA-Z0-9_-]/.test(pluginName)) {
|
||||
clientWs.close(4400, 'Invalid plugin name');
|
||||
return;
|
||||
}
|
||||
|
||||
const port = getPluginPort(pluginName);
|
||||
if (!port) {
|
||||
clientWs.close(4404, 'Plugin not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const upstream = new WebSocket(`ws://127.0.0.1:${port}/ws`);
|
||||
|
||||
upstream.on('open', () => {
|
||||
console.log(`[Plugins] WS proxy connected to "${pluginName}" on port ${port}`);
|
||||
});
|
||||
|
||||
upstream.on('message', (data) => {
|
||||
if (clientWs.readyState === WebSocket.OPEN) {
|
||||
clientWs.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
clientWs.on('message', (data) => {
|
||||
if (upstream.readyState === WebSocket.OPEN) {
|
||||
upstream.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
upstream.on('close', () => {
|
||||
if (clientWs.readyState === WebSocket.OPEN) {
|
||||
clientWs.close();
|
||||
}
|
||||
});
|
||||
|
||||
clientWs.on('close', () => {
|
||||
if (upstream.readyState === WebSocket.OPEN) {
|
||||
upstream.close();
|
||||
}
|
||||
});
|
||||
|
||||
upstream.on('error', (error) => {
|
||||
console.error(`[Plugins] WS proxy error for "${pluginName}":`, error.message);
|
||||
if (clientWs.readyState === WebSocket.OPEN) {
|
||||
clientWs.close(4502, 'Upstream error');
|
||||
}
|
||||
});
|
||||
|
||||
clientWs.on('error', () => {
|
||||
if (upstream.readyState === WebSocket.OPEN) {
|
||||
upstream.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
453
server/modules/websocket/services/shell-websocket.service.ts
Normal file
453
server/modules/websocket/services/shell-websocket.service.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import pty, { type IPty } from 'node-pty';
|
||||
import { WebSocket, type RawData } from 'ws';
|
||||
|
||||
import { parseIncomingJsonObject } from '@/shared/utils.js';
|
||||
|
||||
type ShellIncomingMessage = {
|
||||
type?: string;
|
||||
data?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
projectPath?: string;
|
||||
sessionId?: string;
|
||||
hasSession?: boolean;
|
||||
provider?: string;
|
||||
initialCommand?: string;
|
||||
isPlainShell?: boolean;
|
||||
};
|
||||
|
||||
type PtySessionEntry = {
|
||||
pty: IPty;
|
||||
ws: WebSocket | null;
|
||||
buffer: string[];
|
||||
timeoutId: NodeJS.Timeout | null;
|
||||
projectPath: string;
|
||||
sessionId: string | null;
|
||||
};
|
||||
|
||||
const ptySessionsMap = new Map<string, PtySessionEntry>();
|
||||
const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
|
||||
const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
|
||||
|
||||
type ShellWebSocketDependencies = {
|
||||
getSessionById: (sessionId: string) => { cliSessionId?: string } | null | undefined;
|
||||
stripAnsiSequences: (content: string) => string;
|
||||
normalizeDetectedUrl: (url: string) => string | null;
|
||||
extractUrlsFromText: (content: string) => string[];
|
||||
shouldAutoOpenUrlFromOutput: (content: string) => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads a string field from untyped payloads and falls back when absent.
|
||||
*/
|
||||
function readString(value: unknown, fallback = ''): string {
|
||||
return typeof value === 'string' ? value : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a boolean field from untyped payloads and falls back when absent.
|
||||
*/
|
||||
function readBoolean(value: unknown, fallback = false): boolean {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a finite number field from untyped payloads and falls back when absent.
|
||||
*/
|
||||
function readNumber(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses incoming websocket shell messages and keeps processing safe when
|
||||
* malformed payloads are received.
|
||||
*/
|
||||
function parseShellMessage(rawMessage: RawData): ShellIncomingMessage | null {
|
||||
const payload = parseIncomingJsonObject(rawMessage);
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload as ShellIncomingMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves provider command line for plain shell and agent-backed shell modes.
|
||||
*/
|
||||
function buildShellCommand(
|
||||
message: ShellIncomingMessage,
|
||||
dependencies: ShellWebSocketDependencies
|
||||
): string {
|
||||
const hasSession = readBoolean(message.hasSession);
|
||||
const sessionId = readString(message.sessionId);
|
||||
const initialCommand = readString(message.initialCommand);
|
||||
const provider = readString(message.provider, 'claude');
|
||||
const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
|
||||
const isPlainShell =
|
||||
readBoolean(message.isPlainShell) ||
|
||||
(!!initialCommand && !hasSession) ||
|
||||
provider === 'plain-shell';
|
||||
|
||||
if (isPlainShell) {
|
||||
return initialCommand;
|
||||
}
|
||||
|
||||
if (provider === 'cursor') {
|
||||
if (hasSession && sessionId) {
|
||||
return `cursor-agent --resume="${sessionId}"`;
|
||||
}
|
||||
return 'cursor-agent';
|
||||
}
|
||||
|
||||
if (provider === 'codex') {
|
||||
if (hasSession && sessionId) {
|
||||
if (os.platform() === 'win32') {
|
||||
return `codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
|
||||
}
|
||||
return `codex resume "${sessionId}" || codex`;
|
||||
}
|
||||
return 'codex';
|
||||
}
|
||||
|
||||
if (provider === 'gemini') {
|
||||
const command = initialCommand || 'gemini';
|
||||
let resumeId = sessionId;
|
||||
if (hasSession && sessionId) {
|
||||
try {
|
||||
const existingSession = dependencies.getSessionById(sessionId);
|
||||
if (existingSession && existingSession.cliSessionId) {
|
||||
resumeId = existingSession.cliSessionId;
|
||||
if (!safeSessionIdPattern.test(resumeId)) {
|
||||
resumeId = '';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get Gemini CLI session ID:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasSession && resumeId) {
|
||||
return `${command} --resume "${resumeId}"`;
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
const command = initialCommand || 'claude';
|
||||
if (hasSession && sessionId) {
|
||||
if (os.platform() === 'win32') {
|
||||
return `claude --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
|
||||
}
|
||||
return `claude --resume "${sessionId}" || claude`;
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles websocket connections used by the standalone shell terminal UI.
|
||||
*/
|
||||
export function handleShellConnection(
|
||||
ws: WebSocket,
|
||||
dependencies: ShellWebSocketDependencies
|
||||
): void {
|
||||
console.log('[INFO] Shell websocket connected');
|
||||
|
||||
let shellProcess: IPty | null = null;
|
||||
let ptySessionKey: string | null = null;
|
||||
let urlDetectionBuffer = '';
|
||||
const announcedAuthUrls = new Set<string>();
|
||||
|
||||
ws.on('message', async (rawMessage) => {
|
||||
try {
|
||||
const data = parseShellMessage(rawMessage);
|
||||
if (!data?.type) {
|
||||
throw new Error('Invalid websocket payload');
|
||||
}
|
||||
|
||||
if (data.type === 'init') {
|
||||
const projectPath = readString(data.projectPath, process.cwd());
|
||||
const sessionId = readString(data.sessionId) || null;
|
||||
const hasSession = readBoolean(data.hasSession);
|
||||
const provider = readString(data.provider, 'claude');
|
||||
const initialCommand = readString(data.initialCommand);
|
||||
const isPlainShell =
|
||||
readBoolean(data.isPlainShell) ||
|
||||
(!!initialCommand && !hasSession) ||
|
||||
provider === 'plain-shell';
|
||||
|
||||
urlDetectionBuffer = '';
|
||||
announcedAuthUrls.clear();
|
||||
|
||||
const isLoginCommand =
|
||||
!!initialCommand &&
|
||||
(initialCommand.includes('setup-token') ||
|
||||
initialCommand.includes('cursor-agent login') ||
|
||||
initialCommand.includes('auth login'));
|
||||
|
||||
const commandSuffix =
|
||||
isPlainShell && initialCommand
|
||||
? `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}`
|
||||
: '';
|
||||
ptySessionKey = `${projectPath}_${sessionId ?? 'default'}${commandSuffix}`;
|
||||
|
||||
if (isLoginCommand) {
|
||||
const oldSession = ptySessionsMap.get(ptySessionKey);
|
||||
if (oldSession) {
|
||||
if (oldSession.timeoutId) {
|
||||
clearTimeout(oldSession.timeoutId);
|
||||
}
|
||||
oldSession.pty.kill();
|
||||
ptySessionsMap.delete(ptySessionKey);
|
||||
}
|
||||
}
|
||||
|
||||
const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
|
||||
if (existingSession) {
|
||||
shellProcess = existingSession.pty;
|
||||
if (existingSession.timeoutId) {
|
||||
clearTimeout(existingSession.timeoutId);
|
||||
}
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'output',
|
||||
data: '\x1b[36m[Reconnected to existing session]\x1b[0m\r\n',
|
||||
})
|
||||
);
|
||||
|
||||
if (existingSession.buffer.length > 0) {
|
||||
existingSession.buffer.forEach((bufferedData) => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'output',
|
||||
data: bufferedData,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
existingSession.ws = ws;
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedProjectPath = path.resolve(projectPath);
|
||||
try {
|
||||
const stats = fs.statSync(resolvedProjectPath);
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error('Not a directory');
|
||||
}
|
||||
} catch {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid project path' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
|
||||
if (sessionId && !safeSessionIdPattern.test(sessionId)) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid session ID' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const shellCommand = buildShellCommand(data, dependencies);
|
||||
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
|
||||
const shellArgs =
|
||||
os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];
|
||||
const termCols = readNumber(data.cols, 80);
|
||||
const termRows = readNumber(data.rows, 24);
|
||||
|
||||
shellProcess = pty.spawn(shell, shellArgs, {
|
||||
name: 'xterm-256color',
|
||||
cols: termCols,
|
||||
rows: termRows,
|
||||
cwd: resolvedProjectPath,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
FORCE_COLOR: '3',
|
||||
},
|
||||
});
|
||||
|
||||
ptySessionsMap.set(ptySessionKey, {
|
||||
pty: shellProcess,
|
||||
ws,
|
||||
buffer: [],
|
||||
timeoutId: null,
|
||||
projectPath,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
shellProcess.onData((chunk) => {
|
||||
if (!ptySessionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const session = ptySessionsMap.get(ptySessionKey);
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.buffer.length < 5000) {
|
||||
session.buffer.push(chunk);
|
||||
} else {
|
||||
session.buffer.shift();
|
||||
session.buffer.push(chunk);
|
||||
}
|
||||
|
||||
if (session.ws && session.ws.readyState === WebSocket.OPEN) {
|
||||
let outputData = chunk;
|
||||
const cleanChunk = dependencies.stripAnsiSequences(chunk);
|
||||
urlDetectionBuffer = `${urlDetectionBuffer}${cleanChunk}`.slice(-SHELL_URL_PARSE_BUFFER_LIMIT);
|
||||
|
||||
outputData = outputData.replace(
|
||||
/OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
|
||||
'[INFO] Opening in browser: $1'
|
||||
);
|
||||
|
||||
const emitAuthUrl = (detectedUrl: string, autoOpen = false) => {
|
||||
const normalizedUrl = dependencies.normalizeDetectedUrl(detectedUrl);
|
||||
if (!normalizedUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isNewUrl = !announcedAuthUrls.has(normalizedUrl);
|
||||
if (isNewUrl) {
|
||||
announcedAuthUrls.add(normalizedUrl);
|
||||
session.ws?.send(
|
||||
JSON.stringify({
|
||||
type: 'auth_url',
|
||||
url: normalizedUrl,
|
||||
autoOpen,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizedDetectedUrls = dependencies.extractUrlsFromText(urlDetectionBuffer)
|
||||
.map((url) => dependencies.normalizeDetectedUrl(url))
|
||||
.filter((url): url is string => Boolean(url));
|
||||
|
||||
const dedupedDetectedUrls = Array.from(new Set(normalizedDetectedUrls)).filter(
|
||||
(url, _, urls) =>
|
||||
!urls.some((otherUrl) => otherUrl !== url && otherUrl.startsWith(url))
|
||||
);
|
||||
|
||||
dedupedDetectedUrls.forEach((url) => emitAuthUrl(url, false));
|
||||
|
||||
if (
|
||||
dependencies.shouldAutoOpenUrlFromOutput(cleanChunk) &&
|
||||
dedupedDetectedUrls.length > 0
|
||||
) {
|
||||
const bestUrl = dedupedDetectedUrls.reduce((longest, current) =>
|
||||
current.length > longest.length ? current : longest
|
||||
);
|
||||
emitAuthUrl(bestUrl, true);
|
||||
}
|
||||
|
||||
session.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'output',
|
||||
data: outputData,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
shellProcess.onExit((exitCode) => {
|
||||
if (!ptySessionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const session = ptySessionsMap.get(ptySessionKey);
|
||||
if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
|
||||
session.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'output',
|
||||
data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${
|
||||
exitCode.signal != null ? ` (${exitCode.signal})` : ''
|
||||
}\x1b[0m\r\n`,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (session?.timeoutId) {
|
||||
clearTimeout(session.timeoutId);
|
||||
}
|
||||
|
||||
ptySessionsMap.delete(ptySessionKey);
|
||||
shellProcess = null;
|
||||
});
|
||||
|
||||
let welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
|
||||
if (!isPlainShell) {
|
||||
const providerName =
|
||||
provider === 'cursor'
|
||||
? 'Cursor'
|
||||
: provider === 'codex'
|
||||
? 'Codex'
|
||||
: provider === 'gemini'
|
||||
? 'Gemini'
|
||||
: 'Claude';
|
||||
welcomeMsg = hasSession
|
||||
? `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n`
|
||||
: `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
|
||||
}
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'output',
|
||||
data: welcomeMsg,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'input') {
|
||||
if (shellProcess) {
|
||||
shellProcess.write(readString(data.data));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'resize') {
|
||||
if (shellProcess) {
|
||||
shellProcess.resize(readNumber(data.cols, 80), readNumber(data.rows, 24));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error('[ERROR] Shell WebSocket error:', message);
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'output',
|
||||
data: `\r\n\x1b[31mError: ${message}\x1b[0m\r\n`,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
if (!ptySessionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const session = ptySessionsMap.get(ptySessionKey);
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.ws = null;
|
||||
session.timeoutId = setTimeout(() => {
|
||||
session.pty.kill();
|
||||
ptySessionsMap.delete(ptySessionKey as string);
|
||||
}, PTY_SESSION_TIMEOUT);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('[ERROR] Shell WebSocket error:', error);
|
||||
});
|
||||
}
|
||||
54
server/modules/websocket/services/websocket-auth.service.ts
Normal file
54
server/modules/websocket/services/websocket-auth.service.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { VerifyClientCallbackSync } from 'ws';
|
||||
|
||||
import type { AuthenticatedWebSocketRequest } from '@/shared/types.js';
|
||||
|
||||
type WebSocketAuthDependencies = {
|
||||
isPlatform: boolean;
|
||||
authenticateWebSocket: (token: string | null) => {
|
||||
id?: string | number;
|
||||
userId?: string | number;
|
||||
username?: string;
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Authenticates websocket upgrade requests before the `connection` handler runs.
|
||||
*/
|
||||
export function verifyWebSocketClient(
|
||||
info: Parameters<VerifyClientCallbackSync<AuthenticatedWebSocketRequest>>[0],
|
||||
dependencies: WebSocketAuthDependencies
|
||||
): boolean {
|
||||
const request = info.req as AuthenticatedWebSocketRequest;
|
||||
console.log('WebSocket connection attempt to:', request.url);
|
||||
|
||||
// Platform mode: use the first DB user and skip token checks.
|
||||
if (dependencies.isPlatform) {
|
||||
const user = dependencies.authenticateWebSocket(null);
|
||||
if (!user) {
|
||||
console.log('[WARN] Platform mode: No user found in database');
|
||||
return false;
|
||||
}
|
||||
|
||||
request.user = user;
|
||||
console.log('[OK] Platform mode WebSocket authenticated for user:', user.username);
|
||||
return true;
|
||||
}
|
||||
|
||||
// OSS mode: read JWT from query string first, then Authorization header.
|
||||
const upgradeUrl = new URL(request.url ?? '/', 'http://localhost');
|
||||
const token =
|
||||
upgradeUrl.searchParams.get('token') ??
|
||||
request.headers.authorization?.split(' ')[1] ??
|
||||
null;
|
||||
|
||||
const user = dependencies.authenticateWebSocket(token);
|
||||
if (!user) {
|
||||
console.log('[WARN] WebSocket authentication failed');
|
||||
return false;
|
||||
}
|
||||
|
||||
request.user = user;
|
||||
console.log('[OK] WebSocket authenticated for user:', user.username);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Server as HttpServer } from 'node:http';
|
||||
|
||||
import { WebSocketServer, type VerifyClientCallbackSync } from 'ws';
|
||||
|
||||
import { handleChatConnection } from '@/modules/websocket/services/chat-websocket.service.js';
|
||||
import { verifyWebSocketClient } from '@/modules/websocket/services/websocket-auth.service.js';
|
||||
import { handlePluginWsProxy } from '@/modules/websocket/services/plugin-websocket-proxy.service.js';
|
||||
import { handleShellConnection } from '@/modules/websocket/services/shell-websocket.service.js';
|
||||
import type { AuthenticatedWebSocketRequest } from '@/shared/types.js';
|
||||
|
||||
type WebSocketServerDependencies = {
|
||||
verifyClient: Parameters<typeof verifyWebSocketClient>[1];
|
||||
chat: Parameters<typeof handleChatConnection>[2];
|
||||
shell: Parameters<typeof handleShellConnection>[1];
|
||||
getPluginPort: Parameters<typeof handlePluginWsProxy>[2];
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates and wires the server-wide websocket gateway used for chat, shell, and
|
||||
* plugin proxy routes.
|
||||
*/
|
||||
export function createWebSocketServer(
|
||||
server: HttpServer,
|
||||
dependencies: WebSocketServerDependencies
|
||||
): WebSocketServer {
|
||||
const wss = new WebSocketServer({
|
||||
server,
|
||||
verifyClient: ((
|
||||
info: Parameters<VerifyClientCallbackSync<AuthenticatedWebSocketRequest>>[0]
|
||||
) => verifyWebSocketClient(info, dependencies.verifyClient)),
|
||||
});
|
||||
|
||||
wss.on('connection', (ws, request) => {
|
||||
const incomingRequest = request as AuthenticatedWebSocketRequest;
|
||||
const url = incomingRequest.url ?? '/';
|
||||
const pathname = new URL(url, 'http://localhost').pathname;
|
||||
|
||||
if (pathname === '/shell') {
|
||||
handleShellConnection(ws, dependencies.shell);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/ws') {
|
||||
handleChatConnection(ws, incomingRequest, dependencies.chat);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/plugin-ws/')) {
|
||||
handlePluginWsProxy(ws, pathname, dependencies.getPluginPort);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[WARN] Unknown WebSocket path:', pathname);
|
||||
ws.close();
|
||||
});
|
||||
|
||||
return wss;
|
||||
}
|
||||
16
server/modules/websocket/services/websocket-state.service.ts
Normal file
16
server/modules/websocket/services/websocket-state.service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { RealtimeClientConnection } from '@/shared/types.js';
|
||||
|
||||
/**
|
||||
* Numeric readyState for an open WebSocket connection.
|
||||
*
|
||||
* We keep this in module state so services that broadcast updates do not need
|
||||
* to import `ws` directly just to compare open/closed state.
|
||||
*/
|
||||
export const WS_OPEN_STATE = 1;
|
||||
|
||||
/**
|
||||
* Shared registry of active chat WebSocket connections.
|
||||
*
|
||||
* Project/session services publish realtime updates by iterating this set.
|
||||
*/
|
||||
export const connectedClients = new Set<RealtimeClientConnection>();
|
||||
@@ -0,0 +1,38 @@
|
||||
import { WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js';
|
||||
import type { RealtimeClientConnection } from '@/shared/types.js';
|
||||
|
||||
/**
|
||||
* Thin transport adapter that gives WebSocket connections the same interface as
|
||||
* SSE writers used by API routes (`send`, `setSessionId`, `getSessionId`).
|
||||
*/
|
||||
export class WebSocketWriter {
|
||||
ws: RealtimeClientConnection;
|
||||
sessionId: string | null;
|
||||
userId: string | number | null;
|
||||
isWebSocketWriter: boolean;
|
||||
|
||||
constructor(ws: RealtimeClientConnection, userId: string | number | null = null) {
|
||||
this.ws = ws;
|
||||
this.sessionId = null;
|
||||
this.userId = userId;
|
||||
this.isWebSocketWriter = true;
|
||||
}
|
||||
|
||||
send(data: unknown): void {
|
||||
if (this.ws.readyState === WS_OPEN_STATE) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
updateWebSocket(newRawWs: RealtimeClientConnection): void {
|
||||
this.ws = newRawWs;
|
||||
}
|
||||
|
||||
setSessionId(sessionId: string): void {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
getSessionId(): string | null {
|
||||
return this.sessionId;
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,9 @@
|
||||
|
||||
import { Codex } from '@openai/codex-sdk';
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||
import { codexAdapter } from './providers/codex/adapter.js';
|
||||
import { createNormalizedMessage } from './providers/types.js';
|
||||
import { getStatusChecker } from './providers/registry.js';
|
||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||
import { createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
// Track active sessions
|
||||
const activeCodexSessions = new Map();
|
||||
@@ -265,7 +265,7 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
const transformed = transformCodexEvent(event);
|
||||
|
||||
// Normalize the transformed event into NormalizedMessage(s) via adapter
|
||||
const normalizedMsgs = codexAdapter.normalizeMessage(transformed, currentSessionId);
|
||||
const normalizedMsgs = sessionsService.normalizeMessage('codex', transformed, currentSessionId);
|
||||
for (const msg of normalizedMsgs) {
|
||||
sendMessage(ws, msg);
|
||||
}
|
||||
@@ -311,7 +311,7 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
console.error('[Codex] Error:', error);
|
||||
|
||||
// Check if Codex SDK is available for a clearer error message
|
||||
const installed = getStatusChecker('codex')?.checkInstalled() ?? true;
|
||||
const installed = await providerAuthService.isProviderInstalled('codex');
|
||||
const errorContent = !installed
|
||||
? 'Codex CLI is not configured. Please set up authentication first.'
|
||||
: error.message;
|
||||
|
||||
2555
server/projects.js
2555
server/projects.js
File diff suppressed because it is too large
Load Diff
@@ -1,278 +0,0 @@
|
||||
/**
|
||||
* Claude provider adapter.
|
||||
*
|
||||
* Normalizes Claude SDK session history into NormalizedMessage format.
|
||||
* @module adapters/claude
|
||||
*/
|
||||
|
||||
import { getSessionMessages } from '../../projects.js';
|
||||
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
||||
import { isInternalContent } from '../utils.js';
|
||||
|
||||
const PROVIDER = 'claude';
|
||||
|
||||
/**
|
||||
* Normalize a raw JSONL message or realtime SDK event into NormalizedMessage(s).
|
||||
* Handles both history entries (JSONL `{ message: { role, content } }`) and
|
||||
* realtime streaming events (`content_block_delta`, `content_block_stop`, etc.).
|
||||
* @param {object} raw - A single entry from JSONL or a live SDK event
|
||||
* @param {string} sessionId
|
||||
* @returns {import('../types.js').NormalizedMessage[]}
|
||||
*/
|
||||
export function normalizeMessage(raw, sessionId) {
|
||||
// ── Streaming events (realtime) ──────────────────────────────────────────
|
||||
if (raw.type === 'content_block_delta' && raw.delta?.text) {
|
||||
return [createNormalizedMessage({ kind: 'stream_delta', content: raw.delta.text, sessionId, provider: PROVIDER })];
|
||||
}
|
||||
if (raw.type === 'content_block_stop') {
|
||||
return [createNormalizedMessage({ kind: 'stream_end', sessionId, provider: PROVIDER })];
|
||||
}
|
||||
|
||||
// ── History / full-message events ────────────────────────────────────────
|
||||
const messages = [];
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('claude');
|
||||
|
||||
// User message
|
||||
if (raw.message?.role === 'user' && raw.message?.content) {
|
||||
if (Array.isArray(raw.message.content)) {
|
||||
// Handle tool_result parts
|
||||
for (const part of raw.message.content) {
|
||||
if (part.type === 'tool_result') {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_tr_${part.tool_use_id}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: part.tool_use_id,
|
||||
content: typeof part.content === 'string' ? part.content : JSON.stringify(part.content),
|
||||
isError: Boolean(part.is_error),
|
||||
subagentTools: raw.subagentTools,
|
||||
toolUseResult: raw.toolUseResult,
|
||||
}));
|
||||
} else if (part.type === 'text') {
|
||||
// Regular text parts from user
|
||||
const text = part.text || '';
|
||||
if (text && !isInternalContent(text)) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_text`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content: text,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no text parts were found, check if it's a pure user message
|
||||
if (messages.length === 0) {
|
||||
const textParts = raw.message.content
|
||||
.filter(p => p.type === 'text')
|
||||
.map(p => p.text)
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
if (textParts && !isInternalContent(textParts)) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_text`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content: textParts,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else if (typeof raw.message.content === 'string') {
|
||||
const text = raw.message.content;
|
||||
if (text && !isInternalContent(text)) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content: text,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
// Thinking message
|
||||
if (raw.type === 'thinking' && raw.message?.content) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: raw.message.content,
|
||||
}));
|
||||
return messages;
|
||||
}
|
||||
|
||||
// Tool use result (codex-style in Claude)
|
||||
if (raw.type === 'tool_use' && raw.toolName) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: raw.toolName,
|
||||
toolInput: raw.toolInput,
|
||||
toolId: raw.toolCallId || baseId,
|
||||
}));
|
||||
return messages;
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_result') {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: raw.toolCallId || '',
|
||||
content: raw.output || '',
|
||||
isError: false,
|
||||
}));
|
||||
return messages;
|
||||
}
|
||||
|
||||
// Assistant message
|
||||
if (raw.message?.role === 'assistant' && raw.message?.content) {
|
||||
if (Array.isArray(raw.message.content)) {
|
||||
let partIndex = 0;
|
||||
for (const part of raw.message.content) {
|
||||
if (part.type === 'text' && part.text) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIndex}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'assistant',
|
||||
content: part.text,
|
||||
}));
|
||||
} else if (part.type === 'tool_use') {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIndex}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: part.name,
|
||||
toolInput: part.input,
|
||||
toolId: part.id,
|
||||
}));
|
||||
} else if (part.type === 'thinking' && part.thinking) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIndex}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: part.thinking,
|
||||
}));
|
||||
}
|
||||
partIndex++;
|
||||
}
|
||||
} else if (typeof raw.message.content === 'string') {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'assistant',
|
||||
content: raw.message.content,
|
||||
}));
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import('../types.js').ProviderAdapter}
|
||||
*/
|
||||
export const claudeAdapter = {
|
||||
normalizeMessage,
|
||||
|
||||
/**
|
||||
* Fetch session history from JSONL files, returning normalized messages.
|
||||
*/
|
||||
async fetchHistory(sessionId, opts = {}) {
|
||||
const { projectName, limit = null, offset = 0 } = opts;
|
||||
if (!projectName) {
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await getSessionMessages(projectName, sessionId, limit, offset);
|
||||
} catch (error) {
|
||||
console.warn(`[ClaudeAdapter] Failed to load session ${sessionId}:`, error.message);
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
|
||||
// getSessionMessages returns either an array (no limit) or { messages, total, hasMore }
|
||||
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
|
||||
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
|
||||
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
|
||||
|
||||
// First pass: collect tool results for attachment to tool_use messages
|
||||
const toolResultMap = new Map();
|
||||
for (const raw of rawMessages) {
|
||||
if (raw.message?.role === 'user' && Array.isArray(raw.message?.content)) {
|
||||
for (const part of raw.message.content) {
|
||||
if (part.type === 'tool_result') {
|
||||
toolResultMap.set(part.tool_use_id, {
|
||||
content: part.content,
|
||||
isError: Boolean(part.is_error),
|
||||
timestamp: raw.timestamp,
|
||||
subagentTools: raw.subagentTools,
|
||||
toolUseResult: raw.toolUseResult,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: normalize all messages
|
||||
const normalized = [];
|
||||
for (const raw of rawMessages) {
|
||||
const entries = normalizeMessage(raw, sessionId);
|
||||
normalized.push(...entries);
|
||||
}
|
||||
|
||||
// Attach tool results to their corresponding tool_use messages
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
|
||||
const tr = toolResultMap.get(msg.toolId);
|
||||
msg.toolResult = {
|
||||
content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
|
||||
isError: tr.isError,
|
||||
toolUseResult: tr.toolUseResult,
|
||||
};
|
||||
msg.subagentTools = tr.subagentTools;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages: normalized,
|
||||
total,
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* Claude Provider Status
|
||||
*
|
||||
* Checks whether Claude Code CLI is installed and whether the user
|
||||
* has valid authentication credentials.
|
||||
*
|
||||
* @module providers/claude/status
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'child_process';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
/**
|
||||
* Check if Claude Code CLI is installed and available.
|
||||
* Uses CLAUDE_CLI_PATH env var if set, otherwise looks for 'claude' in PATH.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function checkInstalled() {
|
||||
const cliPath = process.env.CLAUDE_CLI_PATH || 'claude';
|
||||
try {
|
||||
execFileSync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full status check: installation + authentication.
|
||||
* @returns {Promise<import('../types.js').ProviderStatus>}
|
||||
*/
|
||||
export async function checkStatus() {
|
||||
const installed = checkInstalled();
|
||||
|
||||
if (!installed) {
|
||||
return {
|
||||
installed,
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Claude Code CLI is not installed'
|
||||
};
|
||||
}
|
||||
|
||||
const credentialsResult = await checkCredentials();
|
||||
|
||||
if (credentialsResult.authenticated) {
|
||||
return {
|
||||
installed,
|
||||
authenticated: true,
|
||||
email: credentialsResult.email || 'Authenticated',
|
||||
method: credentialsResult.method || null,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
installed,
|
||||
authenticated: false,
|
||||
email: credentialsResult.email || null,
|
||||
method: credentialsResult.method || null,
|
||||
error: credentialsResult.error || 'Not authenticated'
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal helpers ───────────────────────────────────────────────────────
|
||||
|
||||
async function loadSettingsEnv() {
|
||||
try {
|
||||
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
||||
const content = await fs.readFile(settingsPath, 'utf8');
|
||||
const settings = JSON.parse(content);
|
||||
|
||||
if (settings?.env && typeof settings.env === 'object') {
|
||||
return settings.env;
|
||||
}
|
||||
} catch {
|
||||
// Ignore missing or malformed settings.
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks Claude authentication credentials.
|
||||
*
|
||||
* Priority 1: ANTHROPIC_API_KEY environment variable
|
||||
* Priority 1b: ~/.claude/settings.json env values
|
||||
* Priority 2: ~/.claude/.credentials.json OAuth tokens
|
||||
*/
|
||||
async function checkCredentials() {
|
||||
if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim()) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||
}
|
||||
|
||||
const settingsEnv = await loadSettingsEnv();
|
||||
|
||||
if (typeof settingsEnv.ANTHROPIC_API_KEY === 'string' && settingsEnv.ANTHROPIC_API_KEY.trim()) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||
}
|
||||
|
||||
if (typeof settingsEnv.ANTHROPIC_AUTH_TOKEN === 'string' && settingsEnv.ANTHROPIC_AUTH_TOKEN.trim()) {
|
||||
return { authenticated: true, email: 'Configured via settings.json', method: 'api_key' };
|
||||
}
|
||||
|
||||
try {
|
||||
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
||||
const content = await fs.readFile(credPath, 'utf8');
|
||||
const creds = JSON.parse(content);
|
||||
|
||||
const oauth = creds.claudeAiOauth;
|
||||
if (oauth && oauth.accessToken) {
|
||||
const isExpired = oauth.expiresAt && Date.now() >= oauth.expiresAt;
|
||||
if (!isExpired) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: creds.email || creds.user || null,
|
||||
method: 'credentials_file'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: creds.email || creds.user || null,
|
||||
method: 'credentials_file',
|
||||
error: 'OAuth token has expired. Please re-authenticate with claude login'
|
||||
};
|
||||
}
|
||||
|
||||
return { authenticated: false, email: null, method: null };
|
||||
} catch {
|
||||
return { authenticated: false, email: null, method: null };
|
||||
}
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
/**
|
||||
* Codex (OpenAI) provider adapter.
|
||||
*
|
||||
* Normalizes Codex SDK session history into NormalizedMessage format.
|
||||
* @module adapters/codex
|
||||
*/
|
||||
|
||||
import { getCodexSessionMessages } from '../../projects.js';
|
||||
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
||||
|
||||
const PROVIDER = 'codex';
|
||||
|
||||
/**
|
||||
* Normalize a raw Codex JSONL message into NormalizedMessage(s).
|
||||
* @param {object} raw - A single parsed message from Codex JSONL
|
||||
* @param {string} sessionId
|
||||
* @returns {import('../types.js').NormalizedMessage[]}
|
||||
*/
|
||||
function normalizeCodexHistoryEntry(raw, sessionId) {
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('codex');
|
||||
|
||||
// User message
|
||||
if (raw.message?.role === 'user') {
|
||||
const content = typeof raw.message.content === 'string'
|
||||
? raw.message.content
|
||||
: Array.isArray(raw.message.content)
|
||||
? raw.message.content.map(p => typeof p === 'string' ? p : p?.text || '').filter(Boolean).join('\n')
|
||||
: String(raw.message.content || '');
|
||||
if (!content.trim()) return [];
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content,
|
||||
})];
|
||||
}
|
||||
|
||||
// Assistant message
|
||||
if (raw.message?.role === 'assistant') {
|
||||
const content = typeof raw.message.content === 'string'
|
||||
? raw.message.content
|
||||
: Array.isArray(raw.message.content)
|
||||
? raw.message.content.map(p => typeof p === 'string' ? p : p?.text || '').filter(Boolean).join('\n')
|
||||
: '';
|
||||
if (!content.trim()) return [];
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'assistant',
|
||||
content,
|
||||
})];
|
||||
}
|
||||
|
||||
// Thinking/reasoning
|
||||
if (raw.type === 'thinking' || raw.isReasoning) {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: raw.message?.content || '',
|
||||
})];
|
||||
}
|
||||
|
||||
// Tool use
|
||||
if (raw.type === 'tool_use' || raw.toolName) {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: raw.toolName || 'Unknown',
|
||||
toolInput: raw.toolInput,
|
||||
toolId: raw.toolCallId || baseId,
|
||||
})];
|
||||
}
|
||||
|
||||
// Tool result
|
||||
if (raw.type === 'tool_result') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: raw.toolCallId || '',
|
||||
content: raw.output || '',
|
||||
isError: Boolean(raw.isError),
|
||||
})];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw Codex event (history JSONL or transformed SDK event) into NormalizedMessage(s).
|
||||
* @param {object} raw - A history entry (has raw.message.role) or transformed SDK event (has raw.type)
|
||||
* @param {string} sessionId
|
||||
* @returns {import('../types.js').NormalizedMessage[]}
|
||||
*/
|
||||
export function normalizeMessage(raw, sessionId) {
|
||||
// History format: has message.role
|
||||
if (raw.message?.role) {
|
||||
return normalizeCodexHistoryEntry(raw, sessionId);
|
||||
}
|
||||
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('codex');
|
||||
|
||||
// SDK event format (output of transformCodexEvent)
|
||||
if (raw.type === 'item') {
|
||||
switch (raw.itemType) {
|
||||
case 'agent_message':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'text', role: 'assistant', content: raw.message?.content || '',
|
||||
})];
|
||||
case 'reasoning':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'thinking', content: raw.message?.content || '',
|
||||
})];
|
||||
case 'command_execution':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'tool_use', toolName: 'Bash', toolInput: { command: raw.command },
|
||||
toolId: baseId,
|
||||
output: raw.output, exitCode: raw.exitCode, status: raw.status,
|
||||
})];
|
||||
case 'file_change':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'tool_use', toolName: 'FileChanges', toolInput: raw.changes,
|
||||
toolId: baseId, status: raw.status,
|
||||
})];
|
||||
case 'mcp_tool_call':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'tool_use', toolName: raw.tool || 'MCP', toolInput: raw.arguments,
|
||||
toolId: baseId, server: raw.server, result: raw.result,
|
||||
error: raw.error, status: raw.status,
|
||||
})];
|
||||
case 'web_search':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'tool_use', toolName: 'WebSearch', toolInput: { query: raw.query },
|
||||
toolId: baseId,
|
||||
})];
|
||||
case 'todo_list':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'tool_use', toolName: 'TodoList', toolInput: { items: raw.items },
|
||||
toolId: baseId,
|
||||
})];
|
||||
case 'error':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'error', content: raw.message?.content || 'Unknown error',
|
||||
})];
|
||||
default:
|
||||
// Unknown item type — pass through as generic tool_use
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'tool_use', toolName: raw.itemType || 'Unknown',
|
||||
toolInput: raw.item || raw, toolId: baseId,
|
||||
})];
|
||||
}
|
||||
}
|
||||
|
||||
if (raw.type === 'turn_complete') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'complete',
|
||||
})];
|
||||
}
|
||||
if (raw.type === 'turn_failed') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'error', content: raw.error?.message || 'Turn failed',
|
||||
})];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import('../types.js').ProviderAdapter}
|
||||
*/
|
||||
export const codexAdapter = {
|
||||
normalizeMessage,
|
||||
/**
|
||||
* Fetch session history from Codex JSONL files.
|
||||
*/
|
||||
async fetchHistory(sessionId, opts = {}) {
|
||||
const { limit = null, offset = 0 } = opts;
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await getCodexSessionMessages(sessionId, limit, offset);
|
||||
} catch (error) {
|
||||
console.warn(`[CodexAdapter] Failed to load session ${sessionId}:`, error.message);
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
|
||||
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
|
||||
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
|
||||
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
|
||||
const tokenUsage = result.tokenUsage || null;
|
||||
|
||||
const normalized = [];
|
||||
for (const raw of rawMessages) {
|
||||
const entries = normalizeCodexHistoryEntry(raw, sessionId);
|
||||
normalized.push(...entries);
|
||||
}
|
||||
|
||||
// Attach tool results to tool_use messages
|
||||
const toolResultMap = new Map();
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_result' && msg.toolId) {
|
||||
toolResultMap.set(msg.toolId, msg);
|
||||
}
|
||||
}
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
|
||||
const tr = toolResultMap.get(msg.toolId);
|
||||
msg.toolResult = { content: tr.content, isError: tr.isError };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages: normalized,
|
||||
total,
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
tokenUsage,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* Codex Provider Status
|
||||
*
|
||||
* Checks whether the user has valid Codex authentication credentials.
|
||||
* Codex uses an SDK that makes direct API calls (no external binary),
|
||||
* so installation check always returns true if the server is running.
|
||||
*
|
||||
* @module providers/codex/status
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
/**
|
||||
* Check if Codex is installed.
|
||||
* Codex SDK is bundled with this application — no external binary needed.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function checkInstalled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full status check: installation + authentication.
|
||||
* @returns {Promise<import('../types.js').ProviderStatus>}
|
||||
*/
|
||||
export async function checkStatus() {
|
||||
const installed = checkInstalled();
|
||||
const result = await checkCredentials();
|
||||
|
||||
return {
|
||||
installed,
|
||||
authenticated: result.authenticated,
|
||||
email: result.email || null,
|
||||
error: result.error || null
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal helpers ───────────────────────────────────────────────────────
|
||||
|
||||
async function checkCredentials() {
|
||||
try {
|
||||
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
|
||||
const content = await fs.readFile(authPath, 'utf8');
|
||||
const auth = JSON.parse(content);
|
||||
|
||||
const tokens = auth.tokens || {};
|
||||
|
||||
if (tokens.id_token || tokens.access_token) {
|
||||
let email = 'Authenticated';
|
||||
if (tokens.id_token) {
|
||||
try {
|
||||
const parts = tokens.id_token.split('.');
|
||||
if (parts.length >= 2) {
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
||||
email = payload.email || payload.user || 'Authenticated';
|
||||
}
|
||||
} catch {
|
||||
email = 'Authenticated';
|
||||
}
|
||||
}
|
||||
|
||||
return { authenticated: true, email };
|
||||
}
|
||||
|
||||
if (auth.OPENAI_API_KEY) {
|
||||
return { authenticated: true, email: 'API Key Auth' };
|
||||
}
|
||||
|
||||
return { authenticated: false, email: null, error: 'No valid tokens found' };
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return { authenticated: false, email: null, error: 'Codex not configured' };
|
||||
}
|
||||
return { authenticated: false, email: null, error: error.message };
|
||||
}
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
/**
|
||||
* Cursor provider adapter.
|
||||
*
|
||||
* Normalizes Cursor CLI session history into NormalizedMessage format.
|
||||
* @module adapters/cursor
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import crypto from 'crypto';
|
||||
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
||||
|
||||
const PROVIDER = 'cursor';
|
||||
|
||||
/**
|
||||
* Load raw blobs from Cursor's SQLite store.db, parse the DAG structure,
|
||||
* and return sorted message blobs in chronological order.
|
||||
* @param {string} sessionId
|
||||
* @param {string} projectPath - Absolute project path (used to compute cwdId hash)
|
||||
* @returns {Promise<Array<{id: string, sequence: number, rowid: number, content: object}>>}
|
||||
*/
|
||||
async function loadCursorBlobs(sessionId, projectPath) {
|
||||
// Lazy-import better-sqlite3 so the module doesn't fail if it's unavailable
|
||||
const { default: Database } = await import('better-sqlite3');
|
||||
|
||||
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
|
||||
const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');
|
||||
|
||||
const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
|
||||
|
||||
try {
|
||||
const allBlobs = db.prepare('SELECT rowid, id, data FROM blobs').all();
|
||||
|
||||
const blobMap = new Map();
|
||||
const parentRefs = new Map();
|
||||
const childRefs = new Map();
|
||||
const jsonBlobs = [];
|
||||
|
||||
for (const blob of allBlobs) {
|
||||
blobMap.set(blob.id, blob);
|
||||
|
||||
if (blob.data && blob.data[0] === 0x7B) {
|
||||
try {
|
||||
const parsed = JSON.parse(blob.data.toString('utf8'));
|
||||
jsonBlobs.push({ ...blob, parsed });
|
||||
} catch {
|
||||
// skip unparseable blobs
|
||||
}
|
||||
} else if (blob.data) {
|
||||
const parents = [];
|
||||
let i = 0;
|
||||
while (i < blob.data.length - 33) {
|
||||
if (blob.data[i] === 0x0A && blob.data[i + 1] === 0x20) {
|
||||
const parentHash = blob.data.slice(i + 2, i + 34).toString('hex');
|
||||
if (blobMap.has(parentHash)) {
|
||||
parents.push(parentHash);
|
||||
}
|
||||
i += 34;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (parents.length > 0) {
|
||||
parentRefs.set(blob.id, parents);
|
||||
for (const parentId of parents) {
|
||||
if (!childRefs.has(parentId)) childRefs.set(parentId, []);
|
||||
childRefs.get(parentId).push(blob.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Topological sort (DFS)
|
||||
const visited = new Set();
|
||||
const sorted = [];
|
||||
function visit(nodeId) {
|
||||
if (visited.has(nodeId)) return;
|
||||
visited.add(nodeId);
|
||||
for (const pid of (parentRefs.get(nodeId) || [])) visit(pid);
|
||||
const b = blobMap.get(nodeId);
|
||||
if (b) sorted.push(b);
|
||||
}
|
||||
for (const blob of allBlobs) {
|
||||
if (!parentRefs.has(blob.id)) visit(blob.id);
|
||||
}
|
||||
for (const blob of allBlobs) visit(blob.id);
|
||||
|
||||
// Order JSON blobs by DAG appearance
|
||||
const messageOrder = new Map();
|
||||
let orderIndex = 0;
|
||||
for (const blob of sorted) {
|
||||
if (blob.data && blob.data[0] !== 0x7B) {
|
||||
for (const jb of jsonBlobs) {
|
||||
try {
|
||||
const idBytes = Buffer.from(jb.id, 'hex');
|
||||
if (blob.data.includes(idBytes) && !messageOrder.has(jb.id)) {
|
||||
messageOrder.set(jb.id, orderIndex++);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sortedJsonBlobs = jsonBlobs.sort((a, b) => {
|
||||
const oa = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
|
||||
const ob = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
|
||||
return oa !== ob ? oa - ob : a.rowid - b.rowid;
|
||||
});
|
||||
|
||||
const messages = [];
|
||||
for (let idx = 0; idx < sortedJsonBlobs.length; idx++) {
|
||||
const blob = sortedJsonBlobs[idx];
|
||||
const parsed = blob.parsed;
|
||||
if (!parsed) continue;
|
||||
const role = parsed?.role || parsed?.message?.role;
|
||||
if (role === 'system') continue;
|
||||
messages.push({
|
||||
id: blob.id,
|
||||
sequence: idx + 1,
|
||||
rowid: blob.rowid,
|
||||
content: parsed,
|
||||
});
|
||||
}
|
||||
|
||||
return messages;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a realtime NDJSON event from Cursor CLI into NormalizedMessage(s).
|
||||
* History uses normalizeCursorBlobs (SQLite DAG), this handles streaming NDJSON.
|
||||
* @param {object|string} raw - A parsed NDJSON event or a raw text line
|
||||
* @param {string} sessionId
|
||||
* @returns {import('../types.js').NormalizedMessage[]}
|
||||
*/
|
||||
export function normalizeMessage(raw, sessionId) {
|
||||
// Structured assistant message with content array
|
||||
if (raw && typeof raw === 'object' && raw.type === 'assistant' && raw.message?.content?.[0]?.text) {
|
||||
return [createNormalizedMessage({ kind: 'stream_delta', content: raw.message.content[0].text, sessionId, provider: PROVIDER })];
|
||||
}
|
||||
// Plain string line (non-JSON output)
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
return [createNormalizedMessage({ kind: 'stream_delta', content: raw, sessionId, provider: PROVIDER })];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import('../types.js').ProviderAdapter}
|
||||
*/
|
||||
export const cursorAdapter = {
|
||||
normalizeMessage,
|
||||
/**
|
||||
* Fetch session history for Cursor from SQLite store.db.
|
||||
*/
|
||||
async fetchHistory(sessionId, opts = {}) {
|
||||
const { projectPath = '', limit = null, offset = 0 } = opts;
|
||||
|
||||
try {
|
||||
const blobs = await loadCursorBlobs(sessionId, projectPath);
|
||||
const allNormalized = cursorAdapter.normalizeCursorBlobs(blobs, sessionId);
|
||||
|
||||
// Apply pagination
|
||||
if (limit !== null && limit > 0) {
|
||||
const start = offset;
|
||||
const page = allNormalized.slice(start, start + limit);
|
||||
return {
|
||||
messages: page,
|
||||
total: allNormalized.length,
|
||||
hasMore: start + limit < allNormalized.length,
|
||||
offset,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
messages: allNormalized,
|
||||
total: allNormalized.length,
|
||||
hasMore: false,
|
||||
offset: 0,
|
||||
limit: null,
|
||||
};
|
||||
} catch (error) {
|
||||
// DB doesn't exist or is unreadable — return empty
|
||||
console.warn(`[CursorAdapter] Failed to load session ${sessionId}:`, error.message);
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Normalize raw Cursor blob messages into NormalizedMessage[].
|
||||
* @param {any[]} blobs - Raw cursor blobs from store.db ({id, sequence, rowid, content})
|
||||
* @param {string} sessionId
|
||||
* @returns {import('../types.js').NormalizedMessage[]}
|
||||
*/
|
||||
normalizeCursorBlobs(blobs, sessionId) {
|
||||
const messages = [];
|
||||
const toolUseMap = new Map();
|
||||
|
||||
// Use a fixed base timestamp so messages have stable, monotonically-increasing
|
||||
// timestamps based on their sequence number rather than wall-clock time.
|
||||
const baseTime = Date.now();
|
||||
|
||||
for (let i = 0; i < blobs.length; i++) {
|
||||
const blob = blobs[i];
|
||||
const content = blob.content;
|
||||
const ts = new Date(baseTime + (blob.sequence ?? i) * 100).toISOString();
|
||||
const baseId = blob.id || generateMessageId('cursor');
|
||||
|
||||
try {
|
||||
if (!content?.role || !content?.content) {
|
||||
// Try nested message format
|
||||
if (content?.message?.role && content?.message?.content) {
|
||||
if (content.message.role === 'system') continue;
|
||||
const role = content.message.role === 'user' ? 'user' : 'assistant';
|
||||
let text = '';
|
||||
if (Array.isArray(content.message.content)) {
|
||||
text = content.message.content
|
||||
.map(p => typeof p === 'string' ? p : p?.text || '')
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
} else if (typeof content.message.content === 'string') {
|
||||
text = content.message.content;
|
||||
}
|
||||
if (text?.trim()) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role,
|
||||
content: text,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
}));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.role === 'system') continue;
|
||||
|
||||
// Tool results
|
||||
if (content.role === 'tool') {
|
||||
const toolItems = Array.isArray(content.content) ? content.content : [];
|
||||
for (const item of toolItems) {
|
||||
if (item?.type !== 'tool-result') continue;
|
||||
const toolCallId = item.toolCallId || content.id;
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_tr`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: toolCallId,
|
||||
content: item.result || '',
|
||||
isError: false,
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = content.role === 'user' ? 'user' : 'assistant';
|
||||
|
||||
if (Array.isArray(content.content)) {
|
||||
for (let partIdx = 0; partIdx < content.content.length; partIdx++) {
|
||||
const part = content.content[partIdx];
|
||||
|
||||
if (part?.type === 'text' && part?.text) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role,
|
||||
content: part.text,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
}));
|
||||
} else if (part?.type === 'reasoning' && part?.text) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: part.text,
|
||||
}));
|
||||
} else if (part?.type === 'tool-call' || part?.type === 'tool_use') {
|
||||
const toolName = (part.toolName || part.name || 'Unknown Tool') === 'ApplyPatch'
|
||||
? 'Edit' : (part.toolName || part.name || 'Unknown Tool');
|
||||
const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`;
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName,
|
||||
toolInput: part.args || part.input,
|
||||
toolId,
|
||||
}));
|
||||
toolUseMap.set(toolId, messages[messages.length - 1]);
|
||||
}
|
||||
}
|
||||
} else if (typeof content.content === 'string' && content.content.trim()) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role,
|
||||
content: content.content,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error normalizing cursor blob:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Attach tool results to tool_use messages
|
||||
for (const msg of messages) {
|
||||
if (msg.kind === 'tool_result' && msg.toolId && toolUseMap.has(msg.toolId)) {
|
||||
const toolUse = toolUseMap.get(msg.toolId);
|
||||
toolUse.toolResult = {
|
||||
content: msg.content,
|
||||
isError: msg.isError,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by sequence/rowid
|
||||
messages.sort((a, b) => {
|
||||
if (a.sequence !== undefined && b.sequence !== undefined) return a.sequence - b.sequence;
|
||||
if (a.rowid !== undefined && b.rowid !== undefined) return a.rowid - b.rowid;
|
||||
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
|
||||
});
|
||||
|
||||
return messages;
|
||||
},
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
/**
|
||||
* Cursor Provider Status
|
||||
*
|
||||
* Checks whether cursor-agent CLI is installed and whether the user
|
||||
* is logged in.
|
||||
*
|
||||
* @module providers/cursor/status
|
||||
*/
|
||||
|
||||
import { execFileSync, spawn } from 'child_process';
|
||||
|
||||
/**
|
||||
* Check if cursor-agent CLI is installed.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function checkInstalled() {
|
||||
try {
|
||||
execFileSync('cursor-agent', ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full status check: installation + authentication.
|
||||
* @returns {Promise<import('../types.js').ProviderStatus>}
|
||||
*/
|
||||
export async function checkStatus() {
|
||||
const installed = checkInstalled();
|
||||
|
||||
if (!installed) {
|
||||
return {
|
||||
installed,
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Cursor CLI is not installed'
|
||||
};
|
||||
}
|
||||
|
||||
const result = await checkCursorLogin();
|
||||
|
||||
return {
|
||||
installed,
|
||||
authenticated: result.authenticated,
|
||||
email: result.email || null,
|
||||
error: result.error || null
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function checkCursorLogin() {
|
||||
return new Promise((resolve) => {
|
||||
let processCompleted = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!processCompleted) {
|
||||
processCompleted = true;
|
||||
if (childProcess) {
|
||||
childProcess.kill();
|
||||
}
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Command timeout'
|
||||
});
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
let childProcess;
|
||||
try {
|
||||
childProcess = spawn('cursor-agent', ['status']);
|
||||
} catch {
|
||||
clearTimeout(timeout);
|
||||
processCompleted = true;
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Cursor CLI not found or not installed'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
childProcess.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
childProcess.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
childProcess.on('close', (code) => {
|
||||
if (processCompleted) return;
|
||||
processCompleted = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (code === 0) {
|
||||
const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
|
||||
|
||||
if (emailMatch) {
|
||||
resolve({ authenticated: true, email: emailMatch[1] });
|
||||
} else if (stdout.includes('Logged in')) {
|
||||
resolve({ authenticated: true, email: 'Logged in' });
|
||||
} else {
|
||||
resolve({ authenticated: false, email: null, error: 'Not logged in' });
|
||||
}
|
||||
} else {
|
||||
resolve({ authenticated: false, email: null, error: stderr || 'Not logged in' });
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.on('error', () => {
|
||||
if (processCompleted) return;
|
||||
processCompleted = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Cursor CLI not found or not installed'
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
/**
|
||||
* Gemini provider adapter.
|
||||
*
|
||||
* Normalizes Gemini CLI session history into NormalizedMessage format.
|
||||
* @module adapters/gemini
|
||||
*/
|
||||
|
||||
import sessionManager from '../../sessionManager.js';
|
||||
import { getGeminiCliSessionMessages } from '../../projects.js';
|
||||
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
||||
|
||||
const PROVIDER = 'gemini';
|
||||
|
||||
/**
|
||||
* Normalize a realtime NDJSON event from Gemini CLI into NormalizedMessage(s).
|
||||
* Handles: message (delta/final), tool_use, tool_result, result, error.
|
||||
* @param {object} raw - A parsed NDJSON event
|
||||
* @param {string} sessionId
|
||||
* @returns {import('../types.js').NormalizedMessage[]}
|
||||
*/
|
||||
export function normalizeMessage(raw, sessionId) {
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('gemini');
|
||||
|
||||
if (raw.type === 'message' && raw.role === 'assistant') {
|
||||
const content = raw.content || '';
|
||||
const msgs = [];
|
||||
if (content) {
|
||||
msgs.push(createNormalizedMessage({ id: baseId, sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_delta', content }));
|
||||
}
|
||||
// If not a delta, also send stream_end
|
||||
if (raw.delta !== true) {
|
||||
msgs.push(createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' }));
|
||||
}
|
||||
return msgs;
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_use') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'tool_use', toolName: raw.tool_name, toolInput: raw.parameters || {},
|
||||
toolId: raw.tool_id || baseId,
|
||||
})];
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_result') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'tool_result', toolId: raw.tool_id || '',
|
||||
content: raw.output === undefined ? '' : String(raw.output),
|
||||
isError: raw.status === 'error',
|
||||
})];
|
||||
}
|
||||
|
||||
if (raw.type === 'result') {
|
||||
const msgs = [createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' })];
|
||||
if (raw.stats?.total_tokens) {
|
||||
msgs.push(createNormalizedMessage({
|
||||
sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'status', text: 'Complete', tokens: raw.stats.total_tokens, canInterrupt: false,
|
||||
}));
|
||||
}
|
||||
return msgs;
|
||||
}
|
||||
|
||||
if (raw.type === 'error') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'error', content: raw.error || raw.message || 'Unknown Gemini streaming error',
|
||||
})];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import('../types.js').ProviderAdapter}
|
||||
*/
|
||||
export const geminiAdapter = {
|
||||
normalizeMessage,
|
||||
/**
|
||||
* Fetch session history for Gemini.
|
||||
* First tries in-memory session manager, then falls back to CLI sessions on disk.
|
||||
*/
|
||||
async fetchHistory(sessionId, opts = {}) {
|
||||
let rawMessages;
|
||||
try {
|
||||
rawMessages = sessionManager.getSessionMessages(sessionId);
|
||||
|
||||
// Fallback to Gemini CLI sessions on disk
|
||||
if (rawMessages.length === 0) {
|
||||
rawMessages = await getGeminiCliSessionMessages(sessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[GeminiAdapter] Failed to load session ${sessionId}:`, error.message);
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
|
||||
const normalized = [];
|
||||
for (let i = 0; i < rawMessages.length; i++) {
|
||||
const raw = rawMessages[i];
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('gemini');
|
||||
|
||||
// sessionManager format: { type: 'message', message: { role, content }, timestamp }
|
||||
// CLI format: { role: 'user'|'gemini'|'assistant', content: string|array }
|
||||
const role = raw.message?.role || raw.role;
|
||||
const content = raw.message?.content || raw.content;
|
||||
|
||||
if (!role || !content) continue;
|
||||
|
||||
const normalizedRole = (role === 'user') ? 'user' : 'assistant';
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
for (let partIdx = 0; partIdx < content.length; partIdx++) {
|
||||
const part = content[partIdx];
|
||||
if (part.type === 'text' && part.text) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: normalizedRole,
|
||||
content: part.text,
|
||||
}));
|
||||
} else if (part.type === 'tool_use') {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: part.name,
|
||||
toolInput: part.input,
|
||||
toolId: part.id || generateMessageId('gemini_tool'),
|
||||
}));
|
||||
} else if (part.type === 'tool_result') {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: part.tool_use_id || '',
|
||||
content: part.content === undefined ? '' : String(part.content),
|
||||
isError: Boolean(part.is_error),
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else if (typeof content === 'string' && content.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: normalizedRole,
|
||||
content,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Attach tool results to tool_use messages
|
||||
const toolResultMap = new Map();
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_result' && msg.toolId) {
|
||||
toolResultMap.set(msg.toolId, msg);
|
||||
}
|
||||
}
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
|
||||
const tr = toolResultMap.get(msg.toolId);
|
||||
msg.toolResult = { content: tr.content, isError: tr.isError };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages: normalized,
|
||||
total: normalized.length,
|
||||
hasMore: false,
|
||||
offset: 0,
|
||||
limit: null,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* Gemini Provider Status
|
||||
*
|
||||
* Checks whether Gemini CLI is installed and whether the user
|
||||
* has valid authentication credentials.
|
||||
*
|
||||
* @module providers/gemini/status
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'child_process';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
/**
|
||||
* Check if Gemini CLI is installed.
|
||||
* Uses GEMINI_PATH env var if set, otherwise looks for 'gemini' in PATH.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function checkInstalled() {
|
||||
const cliPath = process.env.GEMINI_PATH || 'gemini';
|
||||
try {
|
||||
execFileSync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full status check: installation + authentication.
|
||||
* @returns {Promise<import('../types.js').ProviderStatus>}
|
||||
*/
|
||||
export async function checkStatus() {
|
||||
const installed = checkInstalled();
|
||||
|
||||
if (!installed) {
|
||||
return {
|
||||
installed,
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Gemini CLI is not installed'
|
||||
};
|
||||
}
|
||||
|
||||
const result = await checkCredentials();
|
||||
|
||||
return {
|
||||
installed,
|
||||
authenticated: result.authenticated,
|
||||
email: result.email || null,
|
||||
error: result.error || null
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal helpers ───────────────────────────────────────────────────────
|
||||
|
||||
async function checkCredentials() {
|
||||
if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) {
|
||||
return { authenticated: true, email: 'API Key Auth' };
|
||||
}
|
||||
|
||||
try {
|
||||
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
|
||||
const content = await fs.readFile(credsPath, 'utf8');
|
||||
const creds = JSON.parse(content);
|
||||
|
||||
if (creds.access_token) {
|
||||
let email = 'OAuth Session';
|
||||
|
||||
try {
|
||||
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${creds.access_token}`);
|
||||
if (tokenRes.ok) {
|
||||
const tokenInfo = await tokenRes.json();
|
||||
if (tokenInfo.email) {
|
||||
email = tokenInfo.email;
|
||||
}
|
||||
} else if (!creds.refresh_token) {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Access token invalid and no refresh token found'
|
||||
};
|
||||
} else {
|
||||
// Token might be expired but we have a refresh token, so CLI will refresh it
|
||||
email = await getActiveAccountEmail() || email;
|
||||
}
|
||||
} catch {
|
||||
// Network error, fallback to checking local accounts file
|
||||
email = await getActiveAccountEmail() || email;
|
||||
}
|
||||
|
||||
return { authenticated: true, email };
|
||||
}
|
||||
|
||||
return { authenticated: false, email: null, error: 'No valid tokens found in oauth_creds' };
|
||||
} catch {
|
||||
return { authenticated: false, email: null, error: 'Gemini CLI not configured' };
|
||||
}
|
||||
}
|
||||
|
||||
async function getActiveAccountEmail() {
|
||||
try {
|
||||
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
|
||||
const accContent = await fs.readFile(accPath, 'utf8');
|
||||
const accounts = JSON.parse(accContent);
|
||||
return accounts.active || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Provider Registry
|
||||
*
|
||||
* Centralizes provider adapter and status checker lookup. All code that needs
|
||||
* a provider adapter or status checker should go through this registry instead
|
||||
* of importing individual modules directly.
|
||||
*
|
||||
* @module providers/registry
|
||||
*/
|
||||
|
||||
import { claudeAdapter } from './claude/adapter.js';
|
||||
import { cursorAdapter } from './cursor/adapter.js';
|
||||
import { codexAdapter } from './codex/adapter.js';
|
||||
import { geminiAdapter } from './gemini/adapter.js';
|
||||
|
||||
import * as claudeStatus from './claude/status.js';
|
||||
import * as cursorStatus from './cursor/status.js';
|
||||
import * as codexStatus from './codex/status.js';
|
||||
import * as geminiStatus from './gemini/status.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('./types.js').ProviderAdapter} ProviderAdapter
|
||||
* @typedef {import('./types.js').SessionProvider} SessionProvider
|
||||
*/
|
||||
|
||||
/** @type {Map<string, ProviderAdapter>} */
|
||||
const providers = new Map();
|
||||
|
||||
/** @type {Map<string, { checkInstalled: () => boolean, checkStatus: () => Promise<import('./types.js').ProviderStatus> }>} */
|
||||
const statusCheckers = new Map();
|
||||
|
||||
// Register built-in providers
|
||||
providers.set('claude', claudeAdapter);
|
||||
providers.set('cursor', cursorAdapter);
|
||||
providers.set('codex', codexAdapter);
|
||||
providers.set('gemini', geminiAdapter);
|
||||
|
||||
statusCheckers.set('claude', claudeStatus);
|
||||
statusCheckers.set('cursor', cursorStatus);
|
||||
statusCheckers.set('codex', codexStatus);
|
||||
statusCheckers.set('gemini', geminiStatus);
|
||||
|
||||
/**
|
||||
* Get a provider adapter by name.
|
||||
* @param {string} name - Provider name (e.g., 'claude', 'cursor', 'codex', 'gemini')
|
||||
* @returns {ProviderAdapter | undefined}
|
||||
*/
|
||||
export function getProvider(name) {
|
||||
return providers.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a provider status checker by name.
|
||||
* @param {string} name - Provider name
|
||||
* @returns {{ checkInstalled: () => boolean, checkStatus: () => Promise<import('./types.js').ProviderStatus> } | undefined}
|
||||
*/
|
||||
export function getStatusChecker(name) {
|
||||
return statusCheckers.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered provider names.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getAllProviders() {
|
||||
return Array.from(providers.keys());
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* Provider Types & Interface
|
||||
*
|
||||
* Defines the normalized message format and the provider adapter interface.
|
||||
* All providers normalize their native formats into NormalizedMessage
|
||||
* before sending over REST or WebSocket.
|
||||
*
|
||||
* @module providers/types
|
||||
*/
|
||||
|
||||
// ─── Session Provider ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @typedef {'claude' | 'cursor' | 'codex' | 'gemini'} SessionProvider
|
||||
*/
|
||||
|
||||
// ─── Message Kind ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @typedef {'text' | 'tool_use' | 'tool_result' | 'thinking' | 'stream_delta' | 'stream_end'
|
||||
* | 'error' | 'complete' | 'status' | 'permission_request' | 'permission_cancelled'
|
||||
* | 'session_created' | 'interactive_prompt' | 'task_notification'} MessageKind
|
||||
*/
|
||||
|
||||
// ─── NormalizedMessage ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @typedef {Object} NormalizedMessage
|
||||
* @property {string} id - Unique message id (for dedup between server + realtime)
|
||||
* @property {string} sessionId
|
||||
* @property {string} timestamp - ISO 8601
|
||||
* @property {SessionProvider} provider
|
||||
* @property {MessageKind} kind
|
||||
*
|
||||
* Additional fields depending on kind:
|
||||
* - text: role ('user'|'assistant'), content, images?
|
||||
* - tool_use: toolName, toolInput, toolId
|
||||
* - tool_result: toolId, content, isError
|
||||
* - thinking: content
|
||||
* - stream_delta: content
|
||||
* - stream_end: (no extra fields)
|
||||
* - error: content
|
||||
* - complete: (no extra fields)
|
||||
* - status: text, tokens?, canInterrupt?
|
||||
* - permission_request: requestId, toolName, input, context?
|
||||
* - permission_cancelled: requestId
|
||||
* - session_created: newSessionId
|
||||
* - interactive_prompt: content
|
||||
* - task_notification: status, summary
|
||||
*/
|
||||
|
||||
// ─── Fetch History ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @typedef {Object} FetchHistoryOptions
|
||||
* @property {string} [projectName] - Project name (required for Claude)
|
||||
* @property {string} [projectPath] - Absolute project path (required for Cursor cwdId hash)
|
||||
* @property {number|null} [limit] - Page size (null = all messages)
|
||||
* @property {number} [offset] - Pagination offset (default: 0)
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} FetchHistoryResult
|
||||
* @property {NormalizedMessage[]} messages - Normalized messages
|
||||
* @property {number} total - Total number of messages in the session
|
||||
* @property {boolean} hasMore - Whether more messages exist before the current page
|
||||
* @property {number} offset - Current offset
|
||||
* @property {number|null} limit - Page size used
|
||||
* @property {object} [tokenUsage] - Token usage data (provider-specific)
|
||||
*/
|
||||
|
||||
// ─── Provider Status ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of a provider status check (installation + authentication).
|
||||
*
|
||||
* @typedef {Object} ProviderStatus
|
||||
* @property {boolean} installed - Whether the provider's CLI/SDK is available
|
||||
* @property {boolean} authenticated - Whether valid credentials exist
|
||||
* @property {string|null} email - User email or auth method identifier
|
||||
* @property {string|null} [method] - Auth method (e.g. 'api_key', 'credentials_file')
|
||||
* @property {string|null} [error] - Error message if not installed or not authenticated
|
||||
*/
|
||||
|
||||
// ─── Provider Adapter Interface ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every provider adapter MUST implement this interface.
|
||||
*
|
||||
* @typedef {Object} ProviderAdapter
|
||||
*
|
||||
* @property {(sessionId: string, opts?: FetchHistoryOptions) => Promise<FetchHistoryResult>} fetchHistory
|
||||
* Read persisted session messages from disk/database and return them as NormalizedMessage[].
|
||||
* The backend calls this from the unified GET /api/sessions/:id/messages endpoint.
|
||||
*
|
||||
* Provider implementations:
|
||||
* - Claude: reads ~/.claude/projects/{projectName}/*.jsonl
|
||||
* - Cursor: reads from SQLite store.db (via normalizeCursorBlobs helper)
|
||||
* - Codex: reads ~/.codex/sessions/*.jsonl
|
||||
* - Gemini: reads from in-memory sessionManager or ~/.gemini/tmp/ JSON files
|
||||
*
|
||||
* @property {(raw: any, sessionId: string) => NormalizedMessage[]} normalizeMessage
|
||||
* Normalize a provider-specific event (JSONL entry or live SDK event) into NormalizedMessage[].
|
||||
* Used by provider files to convert both history and realtime events.
|
||||
*/
|
||||
|
||||
// ─── Runtime Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate a unique message ID.
|
||||
* Uses crypto.randomUUID() to avoid collisions across server restarts and workers.
|
||||
* @param {string} [prefix='msg'] - Optional prefix
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generateMessageId(prefix = 'msg') {
|
||||
return `${prefix}_${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a NormalizedMessage with common fields pre-filled.
|
||||
* @param {Partial<NormalizedMessage> & {kind: MessageKind, provider: SessionProvider}} fields
|
||||
* @returns {NormalizedMessage}
|
||||
*/
|
||||
export function createNormalizedMessage(fields) {
|
||||
return {
|
||||
...fields,
|
||||
id: fields.id || generateMessageId(fields.kind),
|
||||
sessionId: fields.sessionId || '',
|
||||
timestamp: fields.timestamp || new Date().toISOString(),
|
||||
provider: fields.provider,
|
||||
};
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Shared provider utilities.
|
||||
*
|
||||
* @module providers/utils
|
||||
*/
|
||||
|
||||
/**
|
||||
* Prefixes that indicate internal/system content which should be hidden from the UI.
|
||||
* @type {readonly string[]}
|
||||
*/
|
||||
export const INTERNAL_CONTENT_PREFIXES = Object.freeze([
|
||||
'<command-name>',
|
||||
'<command-message>',
|
||||
'<command-args>',
|
||||
'<local-command-stdout>',
|
||||
'<system-reminder>',
|
||||
'Caveat:',
|
||||
'This session is being continued from a previous',
|
||||
'[Request interrupted',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if user text content is internal/system that should be skipped.
|
||||
* @param {string} content
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isInternalContent(content) {
|
||||
return INTERNAL_CONTENT_PREFIXES.some(prefix => content.startsWith(prefix));
|
||||
}
|
||||
@@ -4,8 +4,7 @@ import path from 'path';
|
||||
import os from 'os';
|
||||
import { promises as fs } from 'fs';
|
||||
import crypto from 'crypto';
|
||||
import { userDb, apiKeysDb, githubTokensDb } from '../database/db.js';
|
||||
import { addProjectManually } from '../projects.js';
|
||||
import { userDb, apiKeysDb, githubTokensDb, projectsDb } from '../modules/database/index.js';
|
||||
import { queryClaudeSDK } from '../claude-sdk.js';
|
||||
import { spawnCursor } from '../cursor-cli.js';
|
||||
import { queryCodex } from '../openai-codex.js';
|
||||
@@ -13,6 +12,7 @@ import { spawnGemini } from '../gemini-cli.js';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
|
||||
import { IS_PLATFORM } from '../constants/config.js';
|
||||
import { normalizeProjectPath } from '../shared/utils.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -890,7 +890,7 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
finalProjectPath = await cloneGitHubRepo(githubUrl.trim(), tokenToUse, targetPath);
|
||||
} else {
|
||||
// Use existing project path
|
||||
finalProjectPath = path.resolve(projectPath);
|
||||
finalProjectPath = normalizeProjectPath(path.resolve(projectPath));
|
||||
|
||||
// Verify the path exists
|
||||
try {
|
||||
@@ -900,19 +900,14 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Register the project (or use existing registration)
|
||||
let project;
|
||||
try {
|
||||
project = await addProjectManually(finalProjectPath);
|
||||
console.log('📦 Project registered:', project);
|
||||
} catch (error) {
|
||||
// If project already exists, that's fine - continue with the existing registration
|
||||
if (error.message && error.message.includes('Project already configured')) {
|
||||
console.log('📦 Using existing project registration for:', finalProjectPath);
|
||||
project = { path: finalProjectPath };
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
finalProjectPath = normalizeProjectPath(finalProjectPath);
|
||||
|
||||
// Register project path in DB (or reuse existing active registration)
|
||||
const registrationResult = projectsDb.createProjectPath(finalProjectPath, null);
|
||||
if (registrationResult.outcome === 'active_conflict') {
|
||||
console.log('Project registration already exists for:', finalProjectPath);
|
||||
} else {
|
||||
console.log('Project registered:', registrationResult.project);
|
||||
}
|
||||
|
||||
// Set up writer based on streaming mode
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import express from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { userDb, db } from '../database/db.js';
|
||||
import { userDb } from '../modules/database/index.js';
|
||||
import { getConnection } from '../modules/database/connection.js';
|
||||
import { generateToken, authenticateToken } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
const db = getConnection();
|
||||
|
||||
// Check auth status and setup requirements
|
||||
router.get('/status', async (req, res) => {
|
||||
@@ -132,4 +134,4 @@ router.post('/logout', authenticateToken, (req, res) => {
|
||||
res.json({ success: true, message: 'Logged out successfully' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
export default router;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* CLI Auth Routes
|
||||
*
|
||||
* Thin router that delegates to per-provider status checkers
|
||||
* registered in the provider registry.
|
||||
*
|
||||
* @module routes/cli-auth
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { getAllProviders, getStatusChecker } from '../providers/registry.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
for (const provider of getAllProviders()) {
|
||||
router.get(`/${provider}/status`, async (req, res) => {
|
||||
try {
|
||||
const checker = getStatusChecker(provider);
|
||||
res.json(await checker.checkStatus());
|
||||
} catch (error) {
|
||||
console.error(`Error checking ${provider} status:`, error);
|
||||
res.status(500).json({ authenticated: false, error: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -1,329 +0,0 @@
|
||||
import express from 'express';
|
||||
import { spawn } from 'child_process';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import TOML from '@iarna/toml';
|
||||
import { getCodexSessions, deleteCodexSession } from '../projects.js';
|
||||
import { applyCustomSessionNames, sessionNamesDb } from '../database/db.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function createCliResponder(res) {
|
||||
let responded = false;
|
||||
return (status, payload) => {
|
||||
if (responded || res.headersSent) {
|
||||
return;
|
||||
}
|
||||
responded = true;
|
||||
res.status(status).json(payload);
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/config', async (req, res) => {
|
||||
try {
|
||||
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
|
||||
const content = await fs.readFile(configPath, 'utf8');
|
||||
const config = TOML.parse(content);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
config: {
|
||||
model: config.model || null,
|
||||
mcpServers: config.mcp_servers || {},
|
||||
approvalMode: config.approval_mode || 'suggest'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
res.json({
|
||||
success: true,
|
||||
config: {
|
||||
model: null,
|
||||
mcpServers: {},
|
||||
approvalMode: 'suggest'
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error('Error reading Codex config:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/sessions', async (req, res) => {
|
||||
try {
|
||||
const { projectPath } = req.query;
|
||||
|
||||
if (!projectPath) {
|
||||
return res.status(400).json({ success: false, error: 'projectPath query parameter required' });
|
||||
}
|
||||
|
||||
const sessions = await getCodexSessions(projectPath);
|
||||
applyCustomSessionNames(sessions, 'codex');
|
||||
res.json({ success: true, sessions });
|
||||
} catch (error) {
|
||||
console.error('Error fetching Codex sessions:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/sessions/:sessionId', async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
await deleteCodexSession(sessionId);
|
||||
sessionNamesDb.deleteName(sessionId, 'codex');
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error(`Error deleting Codex session ${req.params.sessionId}:`, error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// MCP Server Management Routes
|
||||
|
||||
router.get('/mcp/cli/list', async (req, res) => {
|
||||
try {
|
||||
const respond = createCliResponder(res);
|
||||
const proc = spawn('codex', ['mcp', 'list'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
|
||||
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
respond(200, { success: true, output: stdout, servers: parseCodexListOutput(stdout) });
|
||||
} else {
|
||||
respond(500, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
const isMissing = error?.code === 'ENOENT';
|
||||
respond(isMissing ? 503 : 500, {
|
||||
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
|
||||
details: error.message,
|
||||
code: error.code
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/mcp/cli/add', async (req, res) => {
|
||||
try {
|
||||
const { name, command, args = [], env = {} } = req.body;
|
||||
|
||||
if (!name || !command) {
|
||||
return res.status(400).json({ error: 'name and command are required' });
|
||||
}
|
||||
|
||||
// Build: codex mcp add <name> [-e KEY=VAL]... -- <command> [args...]
|
||||
let cliArgs = ['mcp', 'add', name];
|
||||
|
||||
Object.entries(env).forEach(([key, value]) => {
|
||||
cliArgs.push('-e', `${key}=${value}`);
|
||||
});
|
||||
|
||||
cliArgs.push('--', command);
|
||||
|
||||
if (args && args.length > 0) {
|
||||
cliArgs.push(...args);
|
||||
}
|
||||
|
||||
const respond = createCliResponder(res);
|
||||
const proc = spawn('codex', cliArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
|
||||
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
respond(200, { success: true, output: stdout, message: `MCP server "${name}" added successfully` });
|
||||
} else {
|
||||
respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
const isMissing = error?.code === 'ENOENT';
|
||||
respond(isMissing ? 503 : 500, {
|
||||
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
|
||||
details: error.message,
|
||||
code: error.code
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/mcp/cli/remove/:name', async (req, res) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
const respond = createCliResponder(res);
|
||||
const proc = spawn('codex', ['mcp', 'remove', name], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
|
||||
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
respond(200, { success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
|
||||
} else {
|
||||
respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
const isMissing = error?.code === 'ENOENT';
|
||||
respond(isMissing ? 503 : 500, {
|
||||
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
|
||||
details: error.message,
|
||||
code: error.code
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/mcp/cli/get/:name', async (req, res) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
const respond = createCliResponder(res);
|
||||
const proc = spawn('codex', ['mcp', 'get', name], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
|
||||
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
respond(200, { success: true, output: stdout, server: parseCodexGetOutput(stdout) });
|
||||
} else {
|
||||
respond(404, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
const isMissing = error?.code === 'ENOENT';
|
||||
respond(isMissing ? 503 : 500, {
|
||||
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
|
||||
details: error.message,
|
||||
code: error.code
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/mcp/config/read', async (req, res) => {
|
||||
try {
|
||||
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
|
||||
|
||||
let configData = null;
|
||||
|
||||
try {
|
||||
const fileContent = await fs.readFile(configPath, 'utf8');
|
||||
configData = TOML.parse(fileContent);
|
||||
} catch (error) {
|
||||
// Config file doesn't exist
|
||||
}
|
||||
|
||||
if (!configData) {
|
||||
return res.json({ success: true, configPath, servers: [] }); }
|
||||
|
||||
const servers = [];
|
||||
|
||||
if (configData.mcp_servers && typeof configData.mcp_servers === 'object') {
|
||||
for (const [name, config] of Object.entries(configData.mcp_servers)) {
|
||||
servers.push({
|
||||
id: name,
|
||||
name: name,
|
||||
type: 'stdio',
|
||||
scope: 'user',
|
||||
config: {
|
||||
command: config.command || '',
|
||||
args: config.args || [],
|
||||
env: config.env || {}
|
||||
},
|
||||
raw: config
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, configPath, servers });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to read Codex configuration', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
function parseCodexListOutput(output) {
|
||||
const servers = [];
|
||||
const lines = output.split('\n').filter(line => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes(':')) {
|
||||
const colonIndex = line.indexOf(':');
|
||||
const name = line.substring(0, colonIndex).trim();
|
||||
|
||||
if (!name) continue;
|
||||
|
||||
const rest = line.substring(colonIndex + 1).trim();
|
||||
let description = rest;
|
||||
let status = 'unknown';
|
||||
|
||||
if (rest.includes('✓') || rest.includes('✗')) {
|
||||
const statusMatch = rest.match(/(.*?)\s*-\s*([✓✗].*)$/);
|
||||
if (statusMatch) {
|
||||
description = statusMatch[1].trim();
|
||||
status = statusMatch[2].includes('✓') ? 'connected' : 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
servers.push({ name, type: 'stdio', status, description });
|
||||
}
|
||||
}
|
||||
|
||||
return servers;
|
||||
}
|
||||
|
||||
function parseCodexGetOutput(output) {
|
||||
try {
|
||||
const jsonMatch = output.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
return JSON.parse(jsonMatch[0]);
|
||||
}
|
||||
|
||||
const server = { raw_output: output };
|
||||
const lines = output.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes('Name:')) server.name = line.split(':')[1]?.trim();
|
||||
else if (line.includes('Type:')) server.type = line.split(':')[1]?.trim();
|
||||
else if (line.includes('Command:')) server.command = line.split(':')[1]?.trim();
|
||||
}
|
||||
|
||||
return server;
|
||||
} catch (error) {
|
||||
return { raw_output: output, parse_error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -320,7 +320,7 @@ Custom commands can be created in:
|
||||
packageName,
|
||||
uptime: uptimeFormatted,
|
||||
uptimeSeconds: Math.floor(uptime),
|
||||
model: context?.model || 'claude-sonnet-4.5',
|
||||
model: context?.model || CLAUDE_MODELS.DEFAULT,
|
||||
provider: context?.provider || 'claude',
|
||||
nodeVersion: process.version,
|
||||
platform: process.platform
|
||||
@@ -451,55 +451,6 @@ router.post('/list', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/commands/load
|
||||
* Load a specific command file and return its content and metadata
|
||||
*/
|
||||
router.post('/load', async (req, res) => {
|
||||
try {
|
||||
const { commandPath } = req.body;
|
||||
|
||||
if (!commandPath) {
|
||||
return res.status(400).json({
|
||||
error: 'Command path is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Security: Prevent path traversal
|
||||
const resolvedPath = path.resolve(commandPath);
|
||||
if (!resolvedPath.startsWith(path.resolve(os.homedir())) &&
|
||||
!resolvedPath.includes('.claude/commands')) {
|
||||
return res.status(403).json({
|
||||
error: 'Access denied',
|
||||
message: 'Command must be in .claude/commands directory'
|
||||
});
|
||||
}
|
||||
|
||||
// Read and parse the command file
|
||||
const content = await fs.readFile(commandPath, 'utf8');
|
||||
const { data: metadata, content: commandContent } = parseFrontmatter(content);
|
||||
|
||||
res.json({
|
||||
path: commandPath,
|
||||
metadata,
|
||||
content: commandContent
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return res.status(404).json({
|
||||
error: 'Command not found',
|
||||
message: `Command file not found: ${req.body.commandPath}`
|
||||
});
|
||||
}
|
||||
|
||||
console.error('Error loading command:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to load command',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/commands/execute
|
||||
* Execute a command with argument replacement
|
||||
|
||||
@@ -2,563 +2,51 @@ import express from 'express';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import Database from 'better-sqlite3';
|
||||
import crypto from 'crypto';
|
||||
import { CURSOR_MODELS } from '../../shared/modelConstants.js';
|
||||
import { applyCustomSessionNames } from '../database/db.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/cursor/config - Read Cursor CLI configuration
|
||||
// GET /api/cursor/config - Read Cursor CLI configuration.
|
||||
router.get('/config', async (req, res) => {
|
||||
try {
|
||||
const configPath = path.join(os.homedir(), '.cursor', 'cli-config.json');
|
||||
|
||||
|
||||
try {
|
||||
const configContent = await fs.readFile(configPath, 'utf8');
|
||||
const config = JSON.parse(configContent);
|
||||
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
config: config,
|
||||
path: configPath
|
||||
config,
|
||||
path: configPath,
|
||||
});
|
||||
} catch (error) {
|
||||
// Config doesn't exist or is invalid
|
||||
// Config doesn't exist or is invalid, so return the UI default shape.
|
||||
console.log('Cursor config not found or invalid:', error.message);
|
||||
|
||||
// Return default config
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
config: {
|
||||
version: 1,
|
||||
model: {
|
||||
modelId: CURSOR_MODELS.DEFAULT,
|
||||
displayName: "GPT-5"
|
||||
displayName: 'GPT-5',
|
||||
},
|
||||
permissions: {
|
||||
allow: [],
|
||||
deny: []
|
||||
}
|
||||
deny: [],
|
||||
},
|
||||
},
|
||||
isDefault: true
|
||||
isDefault: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading Cursor config:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to read Cursor configuration',
|
||||
details: error.message
|
||||
res.status(500).json({
|
||||
error: 'Failed to read Cursor configuration',
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/cursor/config - Update Cursor CLI configuration
|
||||
router.post('/config', async (req, res) => {
|
||||
try {
|
||||
const { permissions, model } = req.body;
|
||||
const configPath = path.join(os.homedir(), '.cursor', 'cli-config.json');
|
||||
|
||||
// Read existing config or create default
|
||||
let config = {
|
||||
version: 1,
|
||||
editor: {
|
||||
vimMode: false
|
||||
},
|
||||
hasChangedDefaultModel: false,
|
||||
privacyCache: {
|
||||
ghostMode: false,
|
||||
privacyMode: 3,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const existing = await fs.readFile(configPath, 'utf8');
|
||||
config = JSON.parse(existing);
|
||||
} catch (error) {
|
||||
// Config doesn't exist, use defaults
|
||||
console.log('Creating new Cursor config');
|
||||
}
|
||||
|
||||
// Update permissions if provided
|
||||
if (permissions) {
|
||||
config.permissions = {
|
||||
allow: permissions.allow || [],
|
||||
deny: permissions.deny || []
|
||||
};
|
||||
}
|
||||
|
||||
// Update model if provided
|
||||
if (model) {
|
||||
config.model = model;
|
||||
config.hasChangedDefaultModel = true;
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
const configDir = path.dirname(configPath);
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
|
||||
// Write updated config
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
config: config,
|
||||
message: 'Cursor configuration updated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating Cursor config:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to update Cursor configuration',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/cursor/mcp - Read Cursor MCP servers configuration
|
||||
router.get('/mcp', async (req, res) => {
|
||||
try {
|
||||
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
|
||||
|
||||
try {
|
||||
const mcpContent = await fs.readFile(mcpPath, 'utf8');
|
||||
const mcpConfig = JSON.parse(mcpContent);
|
||||
|
||||
// Convert to UI-friendly format
|
||||
const servers = [];
|
||||
if (mcpConfig.mcpServers && typeof mcpConfig.mcpServers === 'object') {
|
||||
for (const [name, config] of Object.entries(mcpConfig.mcpServers)) {
|
||||
const server = {
|
||||
id: name,
|
||||
name: name,
|
||||
type: 'stdio',
|
||||
scope: 'cursor',
|
||||
config: {},
|
||||
raw: config
|
||||
};
|
||||
|
||||
// Determine transport type and extract config
|
||||
if (config.command) {
|
||||
server.type = 'stdio';
|
||||
server.config.command = config.command;
|
||||
server.config.args = config.args || [];
|
||||
server.config.env = config.env || {};
|
||||
} else if (config.url) {
|
||||
server.type = config.transport || 'http';
|
||||
server.config.url = config.url;
|
||||
server.config.headers = config.headers || {};
|
||||
}
|
||||
|
||||
servers.push(server);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
servers: servers,
|
||||
path: mcpPath
|
||||
});
|
||||
} catch (error) {
|
||||
// MCP config doesn't exist
|
||||
console.log('Cursor MCP config not found:', error.message);
|
||||
res.json({
|
||||
success: true,
|
||||
servers: [],
|
||||
isDefault: true
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading Cursor MCP config:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to read Cursor MCP configuration',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/cursor/mcp/add - Add MCP server to Cursor configuration
|
||||
router.post('/mcp/add', async (req, res) => {
|
||||
try {
|
||||
const { name, type = 'stdio', command, args = [], url, headers = {}, env = {} } = req.body;
|
||||
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
|
||||
|
||||
console.log(`➕ Adding MCP server to Cursor config: ${name}`);
|
||||
|
||||
// Read existing config or create new
|
||||
let mcpConfig = { mcpServers: {} };
|
||||
|
||||
try {
|
||||
const existing = await fs.readFile(mcpPath, 'utf8');
|
||||
mcpConfig = JSON.parse(existing);
|
||||
if (!mcpConfig.mcpServers) {
|
||||
mcpConfig.mcpServers = {};
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Creating new Cursor MCP config');
|
||||
}
|
||||
|
||||
// Build server config based on type
|
||||
let serverConfig = {};
|
||||
|
||||
if (type === 'stdio') {
|
||||
serverConfig = {
|
||||
command: command,
|
||||
args: args,
|
||||
env: env
|
||||
};
|
||||
} else if (type === 'http' || type === 'sse') {
|
||||
serverConfig = {
|
||||
url: url,
|
||||
transport: type,
|
||||
headers: headers
|
||||
};
|
||||
}
|
||||
|
||||
// Add server to config
|
||||
mcpConfig.mcpServers[name] = serverConfig;
|
||||
|
||||
// Ensure directory exists
|
||||
const mcpDir = path.dirname(mcpPath);
|
||||
await fs.mkdir(mcpDir, { recursive: true });
|
||||
|
||||
// Write updated config
|
||||
await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `MCP server "${name}" added to Cursor configuration`,
|
||||
config: mcpConfig
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error adding MCP server to Cursor:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to add MCP server',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/cursor/mcp/:name - Remove MCP server from Cursor configuration
|
||||
router.delete('/mcp/:name', async (req, res) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
|
||||
|
||||
console.log(`🗑️ Removing MCP server from Cursor config: ${name}`);
|
||||
|
||||
// Read existing config
|
||||
let mcpConfig = { mcpServers: {} };
|
||||
|
||||
try {
|
||||
const existing = await fs.readFile(mcpPath, 'utf8');
|
||||
mcpConfig = JSON.parse(existing);
|
||||
} catch (error) {
|
||||
return res.status(404).json({
|
||||
error: 'Cursor MCP configuration not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if server exists
|
||||
if (!mcpConfig.mcpServers || !mcpConfig.mcpServers[name]) {
|
||||
return res.status(404).json({
|
||||
error: `MCP server "${name}" not found in Cursor configuration`
|
||||
});
|
||||
}
|
||||
|
||||
// Remove server from config
|
||||
delete mcpConfig.mcpServers[name];
|
||||
|
||||
// Write updated config
|
||||
await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `MCP server "${name}" removed from Cursor configuration`,
|
||||
config: mcpConfig
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error removing MCP server from Cursor:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to remove MCP server',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/cursor/mcp/add-json - Add MCP server using JSON format
|
||||
router.post('/mcp/add-json', async (req, res) => {
|
||||
try {
|
||||
const { name, jsonConfig } = req.body;
|
||||
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
|
||||
|
||||
console.log(`➕ Adding MCP server to Cursor config via JSON: ${name}`);
|
||||
|
||||
// Validate and parse JSON config
|
||||
let parsedConfig;
|
||||
try {
|
||||
parsedConfig = typeof jsonConfig === 'string' ? JSON.parse(jsonConfig) : jsonConfig;
|
||||
} catch (parseError) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid JSON configuration',
|
||||
details: parseError.message
|
||||
});
|
||||
}
|
||||
|
||||
// Read existing config or create new
|
||||
let mcpConfig = { mcpServers: {} };
|
||||
|
||||
try {
|
||||
const existing = await fs.readFile(mcpPath, 'utf8');
|
||||
mcpConfig = JSON.parse(existing);
|
||||
if (!mcpConfig.mcpServers) {
|
||||
mcpConfig.mcpServers = {};
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Creating new Cursor MCP config');
|
||||
}
|
||||
|
||||
// Add server to config
|
||||
mcpConfig.mcpServers[name] = parsedConfig;
|
||||
|
||||
// Ensure directory exists
|
||||
const mcpDir = path.dirname(mcpPath);
|
||||
await fs.mkdir(mcpDir, { recursive: true });
|
||||
|
||||
// Write updated config
|
||||
await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `MCP server "${name}" added to Cursor configuration via JSON`,
|
||||
config: mcpConfig
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error adding MCP server to Cursor via JSON:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to add MCP server',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/cursor/sessions - Get Cursor sessions from SQLite database
|
||||
router.get('/sessions', async (req, res) => {
|
||||
try {
|
||||
const { projectPath } = req.query;
|
||||
|
||||
// Calculate cwdID hash for the project path (Cursor uses MD5 hash)
|
||||
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
|
||||
const cursorChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
|
||||
|
||||
|
||||
// Check if the directory exists
|
||||
try {
|
||||
await fs.access(cursorChatsPath);
|
||||
} catch (error) {
|
||||
// No sessions for this project
|
||||
return res.json({
|
||||
success: true,
|
||||
sessions: [],
|
||||
cwdId: cwdId,
|
||||
path: cursorChatsPath
|
||||
});
|
||||
}
|
||||
|
||||
// List all session directories
|
||||
const sessionDirs = await fs.readdir(cursorChatsPath);
|
||||
const sessions = [];
|
||||
|
||||
for (const sessionId of sessionDirs) {
|
||||
const sessionPath = path.join(cursorChatsPath, sessionId);
|
||||
const storeDbPath = path.join(sessionPath, 'store.db');
|
||||
let dbStatMtimeMs = null;
|
||||
|
||||
try {
|
||||
// Check if store.db exists
|
||||
await fs.access(storeDbPath);
|
||||
|
||||
// Capture store.db mtime as a reliable fallback timestamp (last activity)
|
||||
try {
|
||||
const stat = await fs.stat(storeDbPath);
|
||||
dbStatMtimeMs = stat.mtimeMs;
|
||||
} catch (_) {}
|
||||
|
||||
// Open SQLite database
|
||||
const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
|
||||
|
||||
// Get metadata from meta table
|
||||
const metaRows = db.prepare('SELECT key, value FROM meta').all();
|
||||
|
||||
let sessionData = {
|
||||
id: sessionId,
|
||||
name: 'Untitled Session',
|
||||
createdAt: null,
|
||||
mode: null,
|
||||
projectPath: projectPath,
|
||||
lastMessage: null,
|
||||
messageCount: 0
|
||||
};
|
||||
|
||||
// Parse meta table entries
|
||||
for (const row of metaRows) {
|
||||
if (row.value) {
|
||||
try {
|
||||
// Try to decode as hex-encoded JSON
|
||||
const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/);
|
||||
if (hexMatch) {
|
||||
const jsonStr = Buffer.from(row.value, 'hex').toString('utf8');
|
||||
const data = JSON.parse(jsonStr);
|
||||
|
||||
if (row.key === 'agent') {
|
||||
sessionData.name = data.name || sessionData.name;
|
||||
// Normalize createdAt to ISO string in milliseconds
|
||||
let createdAt = data.createdAt;
|
||||
if (typeof createdAt === 'number') {
|
||||
if (createdAt < 1e12) {
|
||||
createdAt = createdAt * 1000; // seconds -> ms
|
||||
}
|
||||
sessionData.createdAt = new Date(createdAt).toISOString();
|
||||
} else if (typeof createdAt === 'string') {
|
||||
const n = Number(createdAt);
|
||||
if (!Number.isNaN(n)) {
|
||||
const ms = n < 1e12 ? n * 1000 : n;
|
||||
sessionData.createdAt = new Date(ms).toISOString();
|
||||
} else {
|
||||
// Assume it's already an ISO/date string
|
||||
const d = new Date(createdAt);
|
||||
sessionData.createdAt = isNaN(d.getTime()) ? null : d.toISOString();
|
||||
}
|
||||
} else {
|
||||
sessionData.createdAt = sessionData.createdAt || null;
|
||||
}
|
||||
sessionData.mode = data.mode;
|
||||
sessionData.agentId = data.agentId;
|
||||
sessionData.latestRootBlobId = data.latestRootBlobId;
|
||||
}
|
||||
} else {
|
||||
// If not hex, use raw value for simple keys
|
||||
if (row.key === 'name') {
|
||||
sessionData.name = row.value.toString();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`Could not parse meta value for key ${row.key}:`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get message count from JSON blobs only (actual messages, not DAG structure)
|
||||
try {
|
||||
const blobCount = db.prepare(`SELECT COUNT(*) as count FROM blobs WHERE substr(data, 1, 1) = X'7B'`).get();
|
||||
sessionData.messageCount = blobCount.count;
|
||||
|
||||
// Get the most recent JSON blob for preview (actual message, not DAG structure)
|
||||
const lastBlob = db.prepare(`SELECT data FROM blobs WHERE substr(data, 1, 1) = X'7B' ORDER BY rowid DESC LIMIT 1`).get();
|
||||
|
||||
if (lastBlob && lastBlob.data) {
|
||||
try {
|
||||
// Try to extract readable preview from blob (may contain binary with embedded JSON)
|
||||
const raw = lastBlob.data.toString('utf8');
|
||||
let preview = '';
|
||||
// Attempt direct JSON parse
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed?.content) {
|
||||
if (Array.isArray(parsed.content)) {
|
||||
const firstText = parsed.content.find(p => p?.type === 'text' && p.text)?.text || '';
|
||||
preview = firstText;
|
||||
} else if (typeof parsed.content === 'string') {
|
||||
preview = parsed.content;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
if (!preview) {
|
||||
// Strip non-printable and try to find JSON chunk
|
||||
const cleaned = raw.replace(/[^\x09\x0A\x0D\x20-\x7E]/g, '');
|
||||
const s = cleaned;
|
||||
const start = s.indexOf('{');
|
||||
const end = s.lastIndexOf('}');
|
||||
if (start !== -1 && end > start) {
|
||||
const jsonStr = s.slice(start, end + 1);
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
if (parsed?.content) {
|
||||
if (Array.isArray(parsed.content)) {
|
||||
const firstText = parsed.content.find(p => p?.type === 'text' && p.text)?.text || '';
|
||||
preview = firstText;
|
||||
} else if (typeof parsed.content === 'string') {
|
||||
preview = parsed.content;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
preview = s;
|
||||
}
|
||||
} else {
|
||||
preview = s;
|
||||
}
|
||||
}
|
||||
if (preview && preview.length > 0) {
|
||||
sessionData.lastMessage = preview.substring(0, 100) + (preview.length > 100 ? '...' : '');
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Could not parse blob data:', e.message);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Could not read blobs:', e.message);
|
||||
}
|
||||
|
||||
db.close();
|
||||
|
||||
// Finalize createdAt: use parsed meta value when valid, else fall back to store.db mtime
|
||||
if (!sessionData.createdAt) {
|
||||
if (dbStatMtimeMs && Number.isFinite(dbStatMtimeMs)) {
|
||||
sessionData.createdAt = new Date(dbStatMtimeMs).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
sessions.push(sessionData);
|
||||
|
||||
} catch (error) {
|
||||
console.log(`Could not read session ${sessionId}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: ensure createdAt is a valid ISO string (use session directory mtime as last resort)
|
||||
for (const s of sessions) {
|
||||
if (!s.createdAt) {
|
||||
try {
|
||||
const sessionDir = path.join(cursorChatsPath, s.id);
|
||||
const st = await fs.stat(sessionDir);
|
||||
s.createdAt = new Date(st.mtimeMs).toISOString();
|
||||
} catch {
|
||||
s.createdAt = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort sessions by creation date (newest first)
|
||||
sessions.sort((a, b) => {
|
||||
if (!a.createdAt) return 1;
|
||||
if (!b.createdAt) return -1;
|
||||
return new Date(b.createdAt) - new Date(a.createdAt);
|
||||
});
|
||||
|
||||
applyCustomSessionNames(sessions, 'cursor');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
sessions: sessions,
|
||||
cwdId: cwdId,
|
||||
path: cursorChatsPath
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error reading Cursor sessions:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to read Cursor sessions',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
export default router;
|
||||
export default router;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import express from 'express';
|
||||
|
||||
import sessionManager from '../sessionManager.js';
|
||||
import { sessionNamesDb } from '../database/db.js';
|
||||
import { sessionsDb } from '../modules/database/index.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -13,7 +14,7 @@ router.delete('/sessions/:sessionId', async (req, res) => {
|
||||
}
|
||||
|
||||
await sessionManager.deleteSession(sessionId);
|
||||
sessionNamesDb.deleteName(sessionId, 'gemini');
|
||||
sessionsDb.deleteSessionById(sessionId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error(`Error deleting Gemini session ${req.params.sessionId}:`, error);
|
||||
|
||||
@@ -2,7 +2,7 @@ import express from 'express';
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
import { extractProjectDirectory } from '../projects.js';
|
||||
import { projectsDb } from '../modules/database/index.js';
|
||||
import { queryClaudeSDK } from '../claude-sdk.js';
|
||||
import { spawnCursor } from '../cursor-cli.js';
|
||||
|
||||
@@ -101,14 +101,19 @@ function validateProjectPath(projectPath) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Helper function to get the actual project path from the encoded project name
|
||||
async function getActualProjectPath(projectName) {
|
||||
let projectPath;
|
||||
try {
|
||||
projectPath = await extractProjectDirectory(projectName);
|
||||
} catch (error) {
|
||||
console.error(`Error extracting project directory for ${projectName}:`, error);
|
||||
throw new Error(`Unable to resolve project path for "${projectName}"`);
|
||||
/**
|
||||
* Resolve the absolute project directory for a given DB `projectId`.
|
||||
*
|
||||
* After the projectName → projectId migration, every git endpoint receives
|
||||
* the DB primary key (`project` query/body param). The legacy filesystem
|
||||
* resolver that walked Claude's JSONL history is no longer used here; the
|
||||
* path comes straight from the `projects` table and is then sanity-checked
|
||||
* by `validateProjectPath` before any `git` command runs against it.
|
||||
*/
|
||||
async function getActualProjectPath(projectId) {
|
||||
const projectPath = await projectsDb.getProjectPathById(projectId);
|
||||
if (!projectPath) {
|
||||
throw new Error(`Unable to resolve project path for "${projectId}"`);
|
||||
}
|
||||
return validateProjectPath(projectPath);
|
||||
}
|
||||
@@ -292,7 +297,7 @@ router.get('/status', async (req, res) => {
|
||||
const { project } = req.query;
|
||||
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Project name is required' });
|
||||
return res.status(400).json({ error: 'Project id is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -355,7 +360,7 @@ router.get('/diff', async (req, res) => {
|
||||
const { project, file } = req.query;
|
||||
|
||||
if (!project || !file) {
|
||||
return res.status(400).json({ error: 'Project name and file path are required' });
|
||||
return res.status(400).json({ error: 'Project id and file path are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -438,7 +443,7 @@ router.get('/file-with-diff', async (req, res) => {
|
||||
const { project, file } = req.query;
|
||||
|
||||
if (!project || !file) {
|
||||
return res.status(400).json({ error: 'Project name and file path are required' });
|
||||
return res.status(400).json({ error: 'Project id and file path are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -518,7 +523,7 @@ router.post('/initial-commit', async (req, res) => {
|
||||
const { project } = req.body;
|
||||
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Project name is required' });
|
||||
return res.status(400).json({ error: 'Project id is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -593,7 +598,7 @@ router.post('/revert-local-commit', async (req, res) => {
|
||||
const { project } = req.body;
|
||||
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Project name is required' });
|
||||
return res.status(400).json({ error: 'Project id is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -640,7 +645,7 @@ router.get('/branches', async (req, res) => {
|
||||
const { project } = req.query;
|
||||
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Project name is required' });
|
||||
return res.status(400).json({ error: 'Project id is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -684,7 +689,7 @@ router.post('/checkout', async (req, res) => {
|
||||
const { project, branch } = req.body;
|
||||
|
||||
if (!project || !branch) {
|
||||
return res.status(400).json({ error: 'Project name and branch are required' });
|
||||
return res.status(400).json({ error: 'Project id and branch are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -706,7 +711,7 @@ router.post('/create-branch', async (req, res) => {
|
||||
const { project, branch } = req.body;
|
||||
|
||||
if (!project || !branch) {
|
||||
return res.status(400).json({ error: 'Project name and branch name are required' });
|
||||
return res.status(400).json({ error: 'Project id and branch name are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -728,7 +733,7 @@ router.post('/delete-branch', async (req, res) => {
|
||||
const { project, branch } = req.body;
|
||||
|
||||
if (!project || !branch) {
|
||||
return res.status(400).json({ error: 'Project name and branch name are required' });
|
||||
return res.status(400).json({ error: 'Project id and branch name are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -754,7 +759,7 @@ router.get('/commits', async (req, res) => {
|
||||
const { project, limit = 10 } = req.query;
|
||||
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Project name is required' });
|
||||
return res.status(400).json({ error: 'Project id is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -811,7 +816,7 @@ router.get('/commit-diff', async (req, res) => {
|
||||
const { project, commit } = req.query;
|
||||
|
||||
if (!project || !commit) {
|
||||
return res.status(400).json({ error: 'Project name and commit hash are required' });
|
||||
return res.status(400).json({ error: 'Project id and commit hash are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -843,7 +848,7 @@ router.post('/generate-commit-message', async (req, res) => {
|
||||
const { project, files, provider = 'claude' } = req.body;
|
||||
|
||||
if (!project || !files || files.length === 0) {
|
||||
return res.status(400).json({ error: 'Project name and files are required' });
|
||||
return res.status(400).json({ error: 'Project id and files are required' });
|
||||
}
|
||||
|
||||
// Validate provider
|
||||
@@ -1048,7 +1053,7 @@ router.get('/remote-status', async (req, res) => {
|
||||
const { project } = req.query;
|
||||
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Project name is required' });
|
||||
return res.status(400).json({ error: 'Project id is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1126,7 +1131,7 @@ router.post('/fetch', async (req, res) => {
|
||||
const { project } = req.body;
|
||||
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Project name is required' });
|
||||
return res.status(400).json({ error: 'Project id is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1167,7 +1172,7 @@ router.post('/pull', async (req, res) => {
|
||||
const { project } = req.body;
|
||||
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Project name is required' });
|
||||
return res.status(400).json({ error: 'Project id is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1235,7 +1240,7 @@ router.post('/push', async (req, res) => {
|
||||
const { project } = req.body;
|
||||
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Project name is required' });
|
||||
return res.status(400).json({ error: 'Project id is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1306,7 +1311,7 @@ router.post('/publish', async (req, res) => {
|
||||
const { project, branch } = req.body;
|
||||
|
||||
if (!project || !branch) {
|
||||
return res.status(400).json({ error: 'Project name and branch are required' });
|
||||
return res.status(400).json({ error: 'Project id and branch are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1385,7 +1390,7 @@ router.post('/discard', async (req, res) => {
|
||||
const { project, file } = req.body;
|
||||
|
||||
if (!project || !file) {
|
||||
return res.status(400).json({ error: 'Project name and file path are required' });
|
||||
return res.status(400).json({ error: 'Project id and file path are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1439,7 +1444,7 @@ router.post('/delete-untracked', async (req, res) => {
|
||||
const { project, file } = req.body;
|
||||
|
||||
if (!project || !file) {
|
||||
return res.status(400).json({ error: 'Project name and file path are required' });
|
||||
return res.status(400).json({ error: 'Project id and file path are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { detectTaskMasterMCPServer, getAllMCPServers } from '../utils/mcp-detector.js';
|
||||
import { detectTaskMasterMCPServer } from '../utils/mcp-detector.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -28,21 +28,4 @@ router.get('/taskmaster-server', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/mcp-utils/all-servers
|
||||
* Get all configured MCP servers
|
||||
*/
|
||||
router.get('/all-servers', async (req, res) => {
|
||||
try {
|
||||
const result = await getAllMCPServers();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('MCP servers detection error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to get MCP servers',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
export default router;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user