upfynai-code 2.4.0 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/server/cli.js +63 -4
- package/server/constants/config.js +29 -3
- package/server/database/auth.db +0 -0
- package/server/index.js +240 -33
- package/server/mcp-server.js +2 -1
- package/server/middleware/auth.js +9 -3
- package/server/relay-client.js +158 -1
- package/server/routes/commands.js +1 -1
package/package.json
CHANGED
package/server/cli.js
CHANGED
|
@@ -325,12 +325,17 @@ async function launchInteractive() {
|
|
|
325
325
|
childEnv.ANTHROPIC_API_KEY = config.anthropicApiKey;
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
-
// 8. Start
|
|
328
|
+
// 8. Start the local web UI server in the background
|
|
329
|
+
const port = process.env.PORT || '3001';
|
|
330
|
+
process.env.VITE_IS_PLATFORM = 'true'; // local mode
|
|
331
|
+
startBackgroundServer(port);
|
|
332
|
+
|
|
333
|
+
// 9. Start background relay if config exists (for cloud mode)
|
|
329
334
|
if (config.relayKey && config.server) {
|
|
330
335
|
startBackgroundRelay(config);
|
|
331
336
|
}
|
|
332
337
|
|
|
333
|
-
//
|
|
338
|
+
// 10. Spawn Claude Code interactively
|
|
334
339
|
const child = spawn(claudeBin, [], {
|
|
335
340
|
stdio: 'inherit',
|
|
336
341
|
cwd: process.cwd(),
|
|
@@ -350,6 +355,29 @@ async function launchInteractive() {
|
|
|
350
355
|
});
|
|
351
356
|
}
|
|
352
357
|
|
|
358
|
+
// --- Background server for local mode ---
|
|
359
|
+
function startBackgroundServer(port) {
|
|
360
|
+
// Start the server in the background so the web UI is available
|
|
361
|
+
import('./index.js').then(() => {
|
|
362
|
+
// Server started successfully in background
|
|
363
|
+
}).catch(() => {
|
|
364
|
+
// Server failed to start — user still has Claude Code CLI
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// Open browser after a short delay
|
|
368
|
+
setTimeout(() => {
|
|
369
|
+
const url = `http://localhost:${port}`;
|
|
370
|
+
try {
|
|
371
|
+
const openCmd = process.platform === 'win32' ? 'start'
|
|
372
|
+
: process.platform === 'darwin' ? 'open'
|
|
373
|
+
: 'xdg-open';
|
|
374
|
+
execSync(`${openCmd} ${url}`, { stdio: 'ignore' });
|
|
375
|
+
} catch {
|
|
376
|
+
// Browser open failed — not critical
|
|
377
|
+
}
|
|
378
|
+
}, 2000);
|
|
379
|
+
}
|
|
380
|
+
|
|
353
381
|
// --- Background relay connection ---
|
|
354
382
|
function startBackgroundRelay(config) {
|
|
355
383
|
// Import and start relay in background (non-blocking)
|
|
@@ -366,17 +394,48 @@ function startBackgroundRelay(config) {
|
|
|
366
394
|
});
|
|
367
395
|
}
|
|
368
396
|
|
|
369
|
-
// Start the server
|
|
397
|
+
// Start the server (self-hosted local mode)
|
|
370
398
|
async function startServer() {
|
|
371
399
|
// Check for updates silently on startup
|
|
372
400
|
checkForUpdates(true);
|
|
373
401
|
|
|
374
|
-
// Show server banner
|
|
375
402
|
const port = process.env.PORT || '3001';
|
|
403
|
+
|
|
404
|
+
// Auto-detect local mode — set IS_PLATFORM flag
|
|
405
|
+
if (!process.env.RAILWAY_ENVIRONMENT && !process.env.VERCEL && !process.env.FORCE_HOSTED_MODE) {
|
|
406
|
+
process.env.VITE_IS_PLATFORM = 'true';
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Show server banner
|
|
376
410
|
showServerBanner(port, packageJson.version);
|
|
377
411
|
|
|
412
|
+
// Detect local agents
|
|
413
|
+
const claudeBin = findClaudeBinary();
|
|
414
|
+
if (claudeBin) {
|
|
415
|
+
console.log(` ${c.green('OK')} Claude Code detected: ${c.bright(claudeBin)}`);
|
|
416
|
+
} else {
|
|
417
|
+
console.log(` ${c.yellow('!')} Claude Code not found. Install: ${c.bright('npm i -g @anthropic-ai/claude-code')}`);
|
|
418
|
+
}
|
|
419
|
+
console.log('');
|
|
420
|
+
|
|
378
421
|
// Import and run the server
|
|
379
422
|
await import('./index.js');
|
|
423
|
+
|
|
424
|
+
// Auto-open browser after server starts (local mode only)
|
|
425
|
+
if (!process.env.RAILWAY_ENVIRONMENT && !process.env.VERCEL) {
|
|
426
|
+
const url = `http://localhost:${port}`;
|
|
427
|
+
setTimeout(() => {
|
|
428
|
+
try {
|
|
429
|
+
const openCmd = process.platform === 'win32' ? 'start'
|
|
430
|
+
: process.platform === 'darwin' ? 'open'
|
|
431
|
+
: 'xdg-open';
|
|
432
|
+
execSync(`${openCmd} ${url}`, { stdio: 'ignore' });
|
|
433
|
+
console.log(` ${c.green('OK')} Opened ${c.cyan(url)} in browser\n`);
|
|
434
|
+
} catch {
|
|
435
|
+
console.log(` ${c.dim('Open in browser:')} ${c.cyan(url)}\n`);
|
|
436
|
+
}
|
|
437
|
+
}, 1500);
|
|
438
|
+
}
|
|
380
439
|
}
|
|
381
440
|
|
|
382
441
|
// Parse CLI arguments
|
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Environment Flag: Is Platform
|
|
3
|
-
*
|
|
2
|
+
* Environment Flag: Is Platform (Self-Hosted / Local Mode)
|
|
3
|
+
*
|
|
4
|
+
* When true, the app runs in single-user local mode:
|
|
5
|
+
* - Skips JWT authentication (uses first DB user)
|
|
6
|
+
* - Claude Code SDK runs directly on the machine
|
|
7
|
+
* - No relay connection needed
|
|
8
|
+
*
|
|
9
|
+
* Auto-detected when:
|
|
10
|
+
* - VITE_IS_PLATFORM=true is set, OR
|
|
11
|
+
* - Running locally (not on Railway/Vercel/cloud)
|
|
4
12
|
*/
|
|
5
|
-
|
|
13
|
+
const isCloudEnv = !!(
|
|
14
|
+
process.env.RAILWAY_ENVIRONMENT ||
|
|
15
|
+
process.env.VERCEL ||
|
|
16
|
+
process.env.RENDER ||
|
|
17
|
+
process.env.FLY_APP_NAME ||
|
|
18
|
+
process.env.HEROKU_APP_NAME
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
export const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true' || (!isCloudEnv && !process.env.FORCE_HOSTED_MODE);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* True when running on a cloud provider (Railway, Vercel, etc.)
|
|
25
|
+
*/
|
|
26
|
+
export const IS_CLOUD = isCloudEnv;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* True when running locally (self-hosted mode)
|
|
30
|
+
*/
|
|
31
|
+
export const IS_LOCAL = IS_PLATFORM && !isCloudEnv;
|
package/server/database/auth.db
CHANGED
|
Binary file
|
package/server/index.js
CHANGED
|
@@ -70,9 +70,10 @@ import cliAuthRoutes from './routes/cli-auth.js';
|
|
|
70
70
|
import userRoutes from './routes/user.js';
|
|
71
71
|
import codexRoutes from './routes/codex.js';
|
|
72
72
|
import paymentRoutes from './routes/payments.js';
|
|
73
|
-
import { initializeDatabase, relayTokensDb, subscriptionDb, credentialsDb } from './database/db.js';
|
|
73
|
+
import { initializeDatabase, relayTokensDb, subscriptionDb, credentialsDb, userDb } from './database/db.js';
|
|
74
74
|
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
|
75
|
-
import { IS_PLATFORM } from './constants/config.js';
|
|
75
|
+
import { IS_PLATFORM, IS_LOCAL } from './constants/config.js';
|
|
76
|
+
import { execSync } from 'child_process';
|
|
76
77
|
|
|
77
78
|
// File system watchers for provider project/session folders
|
|
78
79
|
const PROVIDER_WATCH_PATHS = [
|
|
@@ -539,12 +540,70 @@ app.get('/api/relay/status', authenticateToken, (req, res) => {
|
|
|
539
540
|
});
|
|
540
541
|
});
|
|
541
542
|
|
|
543
|
+
/**
|
|
544
|
+
* Detect installed AI CLI agents on the local machine (server-side).
|
|
545
|
+
* Used in self-hosted/local mode where no relay is needed.
|
|
546
|
+
*/
|
|
547
|
+
let cachedLocalAgents = null;
|
|
548
|
+
let localAgentsCacheTime = 0;
|
|
549
|
+
function detectLocalAgents() {
|
|
550
|
+
// Cache for 60 seconds
|
|
551
|
+
if (cachedLocalAgents && Date.now() - localAgentsCacheTime < 60000) {
|
|
552
|
+
return cachedLocalAgents;
|
|
553
|
+
}
|
|
554
|
+
const isWindows = process.platform === 'win32';
|
|
555
|
+
const whichCmd = isWindows ? 'where' : 'which';
|
|
556
|
+
const agents = [
|
|
557
|
+
{ name: 'claude', binary: 'claude', label: 'Claude Code' },
|
|
558
|
+
{ name: 'codex', binary: 'codex', label: 'OpenAI Codex' },
|
|
559
|
+
{ name: 'cursor', binary: 'cursor-agent', label: 'Cursor Agent' },
|
|
560
|
+
];
|
|
561
|
+
const detected = {};
|
|
562
|
+
for (const agent of agents) {
|
|
563
|
+
try {
|
|
564
|
+
const result = execSync(`${whichCmd} ${agent.binary}`, { stdio: 'pipe', timeout: 5000 }).toString().trim();
|
|
565
|
+
detected[agent.name] = { installed: true, path: result.split('\n')[0].trim(), label: agent.label };
|
|
566
|
+
} catch {
|
|
567
|
+
detected[agent.name] = { installed: false, label: agent.label };
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
cachedLocalAgents = detected;
|
|
571
|
+
localAgentsCacheTime = Date.now();
|
|
572
|
+
return detected;
|
|
573
|
+
}
|
|
574
|
+
|
|
542
575
|
// Connection status — alias at path the frontend expects
|
|
543
576
|
app.get('/api/auth/connection-status', authenticateToken, (req, res) => {
|
|
544
577
|
const relay = relayConnections.get(Number(req.user.id));
|
|
578
|
+
const connected = !!(relay && relay.ws.readyState === 1);
|
|
579
|
+
|
|
580
|
+
// In local mode, always "connected" — SDK runs directly on this machine
|
|
581
|
+
if (IS_LOCAL) {
|
|
582
|
+
const agents = detectLocalAgents();
|
|
583
|
+
return res.json({
|
|
584
|
+
connected: true,
|
|
585
|
+
local: true,
|
|
586
|
+
connectedAt: Date.now(),
|
|
587
|
+
agents,
|
|
588
|
+
machine: {
|
|
589
|
+
hostname: os.hostname(),
|
|
590
|
+
platform: process.platform,
|
|
591
|
+
cwd: process.cwd(),
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
545
596
|
res.json({
|
|
546
|
-
connected
|
|
547
|
-
|
|
597
|
+
connected,
|
|
598
|
+
local: false,
|
|
599
|
+
connectedAt: relay?.connectedAt || null,
|
|
600
|
+
agents: connected ? (relay.agents || null) : null,
|
|
601
|
+
machine: connected ? {
|
|
602
|
+
hostname: relay.machine,
|
|
603
|
+
platform: relay.platform,
|
|
604
|
+
cwd: relay.cwd,
|
|
605
|
+
version: relay.version,
|
|
606
|
+
} : null
|
|
548
607
|
});
|
|
549
608
|
});
|
|
550
609
|
|
|
@@ -1128,35 +1187,51 @@ function handleChatConnection(ws, request) {
|
|
|
1128
1187
|
}
|
|
1129
1188
|
if (sid) lockedSessionsForThisWs.add(sid);
|
|
1130
1189
|
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1190
|
+
// Check if user has active relay → route to local machine
|
|
1191
|
+
if (hasActiveRelay(wsUser?.userId)) {
|
|
1192
|
+
await routeViaRelay(wsUser.userId, 'claude-query', data, writer, {
|
|
1193
|
+
response: 'claude-response',
|
|
1194
|
+
complete: 'claude-complete',
|
|
1195
|
+
error: 'claude-error'
|
|
1196
|
+
});
|
|
1197
|
+
} else {
|
|
1198
|
+
// Fall back to server-side SDK
|
|
1199
|
+
const userAnthropicKey = wsUser?.userId
|
|
1200
|
+
? await getUserProviderKey(wsUser.userId, 'anthropic_key')
|
|
1201
|
+
: null;
|
|
1202
|
+
|
|
1203
|
+
await withUserApiKey('ANTHROPIC_API_KEY', userAnthropicKey, () =>
|
|
1204
|
+
queryClaudeSDK(data.command, data.options, writer)
|
|
1205
|
+
);
|
|
1206
|
+
}
|
|
1142
1207
|
} else if (data.type === 'cursor-command') {
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1208
|
+
// Check if user has active relay → route to local machine
|
|
1209
|
+
if (hasActiveRelay(wsUser?.userId)) {
|
|
1210
|
+
await routeViaRelay(wsUser.userId, 'cursor-query', data, writer, {
|
|
1211
|
+
response: 'cursor-response',
|
|
1212
|
+
complete: 'cursor-complete',
|
|
1213
|
+
error: 'cursor-error'
|
|
1214
|
+
});
|
|
1215
|
+
} else {
|
|
1216
|
+
await spawnCursor(data.command, data.options, writer);
|
|
1217
|
+
}
|
|
1147
1218
|
} else if (data.type === 'codex-command') {
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1219
|
+
// Check if user has active relay → route to local machine
|
|
1220
|
+
if (hasActiveRelay(wsUser?.userId)) {
|
|
1221
|
+
await routeViaRelay(wsUser.userId, 'codex-query', data, writer, {
|
|
1222
|
+
response: 'codex-response',
|
|
1223
|
+
complete: 'codex-complete',
|
|
1224
|
+
error: 'codex-error'
|
|
1225
|
+
});
|
|
1226
|
+
} else {
|
|
1227
|
+
const userOpenaiKey = wsUser?.userId
|
|
1228
|
+
? await getUserProviderKey(wsUser.userId, 'openai_key')
|
|
1229
|
+
: null;
|
|
1156
1230
|
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1231
|
+
await withUserApiKey('OPENAI_API_KEY', userOpenaiKey, () =>
|
|
1232
|
+
queryCodex(data.command, data.options, writer)
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1160
1235
|
} else if (data.type === 'openrouter-command') {
|
|
1161
1236
|
console.log('[DEBUG] OpenRouter message:', data.command?.slice(0, 60) || '[empty]');
|
|
1162
1237
|
console.log('🤖 Model:', data.options?.model || OPENROUTER_MODELS.DEFAULT);
|
|
@@ -1288,12 +1363,20 @@ async function handleRelayConnection(ws, token, request) {
|
|
|
1288
1363
|
const userId = Number(tokenData.user_id);
|
|
1289
1364
|
const username = tokenData.username;
|
|
1290
1365
|
|
|
1291
|
-
// Extract optional
|
|
1366
|
+
// Extract optional headers from relay handshake
|
|
1292
1367
|
const anthropicApiKey = request?.headers?.['x-anthropic-api-key'] || null;
|
|
1368
|
+
const relayVersion = request?.headers?.['x-upfyn-version'] || null;
|
|
1369
|
+
const relayMachine = request?.headers?.['x-upfyn-machine'] || null;
|
|
1370
|
+
const relayPlatform = request?.headers?.['x-upfyn-platform'] || null;
|
|
1371
|
+
const relayCwd = request?.headers?.['x-upfyn-cwd'] || null;
|
|
1293
1372
|
|
|
1294
1373
|
// Store relay connection with API key in memory only (use Number() for consistent Map key type)
|
|
1295
1374
|
// API key is held per-user in the relay connection, NOT in process.env
|
|
1296
|
-
relayConnections.set(userId, {
|
|
1375
|
+
relayConnections.set(userId, {
|
|
1376
|
+
ws, user: tokenData, connectedAt: Date.now(), anthropicApiKey,
|
|
1377
|
+
version: relayVersion, machine: relayMachine, platform: relayPlatform, cwd: relayCwd,
|
|
1378
|
+
agents: null // populated when client sends agent-capabilities
|
|
1379
|
+
});
|
|
1297
1380
|
|
|
1298
1381
|
ws.send(JSON.stringify({
|
|
1299
1382
|
type: 'relay-connected',
|
|
@@ -1344,6 +1427,29 @@ async function handleRelayConnection(ws, token, request) {
|
|
|
1344
1427
|
return;
|
|
1345
1428
|
}
|
|
1346
1429
|
|
|
1430
|
+
// Agent capabilities report from relay client
|
|
1431
|
+
if (data.type === 'agent-capabilities') {
|
|
1432
|
+
const relay = relayConnections.get(userId);
|
|
1433
|
+
if (relay) {
|
|
1434
|
+
relay.agents = data.agents || {};
|
|
1435
|
+
relay.machine = data.machine || relay.machine;
|
|
1436
|
+
}
|
|
1437
|
+
// Broadcast agent info to browser clients
|
|
1438
|
+
for (const client of connectedClients) {
|
|
1439
|
+
try {
|
|
1440
|
+
if (client.readyState === 1) {
|
|
1441
|
+
client.send(JSON.stringify({
|
|
1442
|
+
type: 'relay-agents',
|
|
1443
|
+
userId,
|
|
1444
|
+
agents: data.agents || {},
|
|
1445
|
+
machine: data.machine || {}
|
|
1446
|
+
}));
|
|
1447
|
+
}
|
|
1448
|
+
} catch (e) { /* ignore */ }
|
|
1449
|
+
}
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1347
1453
|
// Heartbeat
|
|
1348
1454
|
if (data.type === 'ping') {
|
|
1349
1455
|
ws.send(JSON.stringify({ type: 'pong' }));
|
|
@@ -1394,7 +1500,7 @@ function sendRelayCommand(userId, action, payload, onStream = null, timeoutMs =
|
|
|
1394
1500
|
return new Promise((resolve, reject) => {
|
|
1395
1501
|
const relay = relayConnections.get(userId);
|
|
1396
1502
|
if (!relay || relay.ws.readyState !== 1) {
|
|
1397
|
-
reject(new Error('No relay connection. Run "
|
|
1503
|
+
reject(new Error('No relay connection. Run "uc connect" on your local machine.'));
|
|
1398
1504
|
return;
|
|
1399
1505
|
}
|
|
1400
1506
|
|
|
@@ -1415,6 +1521,95 @@ function sendRelayCommand(userId, action, payload, onStream = null, timeoutMs =
|
|
|
1415
1521
|
});
|
|
1416
1522
|
}
|
|
1417
1523
|
|
|
1524
|
+
/**
|
|
1525
|
+
* Check if a user has an active relay connection
|
|
1526
|
+
*/
|
|
1527
|
+
function hasActiveRelay(userId) {
|
|
1528
|
+
if (!userId) return false;
|
|
1529
|
+
const relay = relayConnections.get(Number(userId));
|
|
1530
|
+
return relay && relay.ws.readyState === 1;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/**
|
|
1534
|
+
* Route a chat command through the user's relay connection to their local machine.
|
|
1535
|
+
* Translates relay-stream/relay-complete events into the format the frontend expects.
|
|
1536
|
+
*
|
|
1537
|
+
* @param {number} userId - User ID
|
|
1538
|
+
* @param {string} action - Relay action (claude-query, codex-query, cursor-query)
|
|
1539
|
+
* @param {object} data - Original command data from the browser
|
|
1540
|
+
* @param {object} writer - WebSocket writer to send events to browser
|
|
1541
|
+
* @param {object} eventMap - Maps relay stream data types to chat event types
|
|
1542
|
+
*/
|
|
1543
|
+
async function routeViaRelay(userId, action, data, writer, eventMap = {}) {
|
|
1544
|
+
const sessionId = data.options?.sessionId || `relay-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1545
|
+
|
|
1546
|
+
// Send session-created so the frontend can track this query
|
|
1547
|
+
writer.send({ type: 'session-created', sessionId });
|
|
1548
|
+
|
|
1549
|
+
// Determine event types from the provider
|
|
1550
|
+
const responseType = eventMap.response || 'claude-response';
|
|
1551
|
+
const completeType = eventMap.complete || 'claude-complete';
|
|
1552
|
+
const errorType = eventMap.error || 'claude-error';
|
|
1553
|
+
|
|
1554
|
+
let fullContent = '';
|
|
1555
|
+
|
|
1556
|
+
try {
|
|
1557
|
+
const result = await sendRelayCommand(
|
|
1558
|
+
Number(userId),
|
|
1559
|
+
action,
|
|
1560
|
+
{
|
|
1561
|
+
command: data.command,
|
|
1562
|
+
options: data.options || {}
|
|
1563
|
+
},
|
|
1564
|
+
// onStream callback — translates relay events to chat events
|
|
1565
|
+
(streamData) => {
|
|
1566
|
+
if (streamData.type === 'claude-response' || streamData.type === 'codex-response' || streamData.type === 'cursor-response') {
|
|
1567
|
+
fullContent += streamData.content || '';
|
|
1568
|
+
writer.send({
|
|
1569
|
+
type: responseType,
|
|
1570
|
+
data: {
|
|
1571
|
+
type: 'assistant',
|
|
1572
|
+
message: {
|
|
1573
|
+
type: 'text',
|
|
1574
|
+
text: streamData.content || ''
|
|
1575
|
+
}
|
|
1576
|
+
},
|
|
1577
|
+
sessionId
|
|
1578
|
+
});
|
|
1579
|
+
} else if (streamData.type === 'claude-error' || streamData.type === 'codex-error' || streamData.type === 'cursor-error') {
|
|
1580
|
+
writer.send({
|
|
1581
|
+
type: responseType,
|
|
1582
|
+
data: {
|
|
1583
|
+
type: 'assistant',
|
|
1584
|
+
message: {
|
|
1585
|
+
type: 'text',
|
|
1586
|
+
text: streamData.content || ''
|
|
1587
|
+
}
|
|
1588
|
+
},
|
|
1589
|
+
sessionId
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
},
|
|
1593
|
+
600000 // 10 minute timeout for AI queries
|
|
1594
|
+
);
|
|
1595
|
+
|
|
1596
|
+
// Send completion event
|
|
1597
|
+
writer.send({
|
|
1598
|
+
type: completeType,
|
|
1599
|
+
sessionId,
|
|
1600
|
+
exitCode: result?.exitCode ?? 0,
|
|
1601
|
+
isNewSession: !data.options?.sessionId,
|
|
1602
|
+
viaRelay: true
|
|
1603
|
+
});
|
|
1604
|
+
} catch (error) {
|
|
1605
|
+
writer.send({
|
|
1606
|
+
type: errorType,
|
|
1607
|
+
error: error.message,
|
|
1608
|
+
sessionId
|
|
1609
|
+
});
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1418
1613
|
// Handle shell WebSocket connections
|
|
1419
1614
|
function handleShellConnection(ws) {
|
|
1420
1615
|
if (!pty) {
|
|
@@ -2274,6 +2469,18 @@ async function startServer() {
|
|
|
2274
2469
|
// Initialize authentication database
|
|
2275
2470
|
await initializeDatabase();
|
|
2276
2471
|
|
|
2472
|
+
// In local mode, ensure a default user exists (no signup needed)
|
|
2473
|
+
if (IS_LOCAL) {
|
|
2474
|
+
const hasUsers = await userDb.hasUsers();
|
|
2475
|
+
if (!hasUsers) {
|
|
2476
|
+
const localUsername = os.userInfo().username || 'local';
|
|
2477
|
+
const dummyHash = crypto.randomBytes(32).toString('hex');
|
|
2478
|
+
await userDb.createUser(localUsername, dummyHash);
|
|
2479
|
+
console.log(`${c.ok('[LOCAL]')} Created local user: ${c.bright(localUsername)}`);
|
|
2480
|
+
}
|
|
2481
|
+
console.log(`${c.info('[MODE]')} Running in ${c.bright('LOCAL')} mode (no login required)`);
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2277
2484
|
// Check if running in production mode (dist folder exists OR NODE_ENV/RAILWAY set)
|
|
2278
2485
|
const distIndexPath = path.join(__dirname, '../dist/index.html');
|
|
2279
2486
|
const isProduction = fs.existsSync(distIndexPath) || process.env.NODE_ENV === 'production' || !!process.env.RAILWAY_ENVIRONMENT;
|
package/server/mcp-server.js
CHANGED
|
@@ -31,7 +31,8 @@ import crypto from 'crypto';
|
|
|
31
31
|
import jwt from 'jsonwebtoken';
|
|
32
32
|
import { userDb, apiKeysDb, relayTokensDb } from './database/db.js';
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
import { IS_PLATFORM } from './constants/config.js';
|
|
35
|
+
const JWT_SECRET = process.env.JWT_SECRET?.trim() || (IS_PLATFORM ? crypto.randomBytes(32).toString('hex') : (() => { throw new Error('JWT_SECRET required'); })());
|
|
35
36
|
|
|
36
37
|
// In-memory canvas state (Excalidraw elements, synced via WebSocket with browser clients)
|
|
37
38
|
let canvasElements = [];
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import jwt from 'jsonwebtoken';
|
|
2
|
+
import crypto from 'crypto';
|
|
2
3
|
import { userDb, relayTokensDb } from '../database/db.js';
|
|
3
4
|
import { IS_PLATFORM } from '../constants/config.js';
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
let JWT_SECRET = process.env.JWT_SECRET?.trim();
|
|
6
7
|
if (!JWT_SECRET) {
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
if (IS_PLATFORM) {
|
|
9
|
+
// In local/self-hosted mode, generate a random secret (auth is bypassed anyway)
|
|
10
|
+
JWT_SECRET = crypto.randomBytes(32).toString('hex');
|
|
11
|
+
} else {
|
|
12
|
+
console.error('[SECURITY] JWT_SECRET environment variable is required. Server cannot start without it.');
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
9
15
|
}
|
|
10
16
|
|
|
11
17
|
// Optional static API key middleware
|
package/server/relay-client.js
CHANGED
|
@@ -14,7 +14,7 @@ import WebSocket from 'ws';
|
|
|
14
14
|
import os from 'os';
|
|
15
15
|
import fs from 'fs';
|
|
16
16
|
import path from 'path';
|
|
17
|
-
import { spawn } from 'child_process';
|
|
17
|
+
import { spawn, execSync } from 'child_process';
|
|
18
18
|
import { promises as fsPromises } from 'fs';
|
|
19
19
|
import crypto from 'crypto';
|
|
20
20
|
import {
|
|
@@ -137,6 +137,100 @@ async function handleRelayCommand(data, ws) {
|
|
|
137
137
|
break;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
case 'codex-query': {
|
|
141
|
+
const { command, options } = data;
|
|
142
|
+
logRelayEvent('>', `Codex query: ${command?.slice(0, 60)}...`, 'cyan');
|
|
143
|
+
|
|
144
|
+
const codexArgs = ['--quiet'];
|
|
145
|
+
if (options?.projectPath || options?.cwd) {
|
|
146
|
+
codexArgs.push('--cwd', options.projectPath || options.cwd);
|
|
147
|
+
}
|
|
148
|
+
if (options?.model) codexArgs.push('--model', options.model);
|
|
149
|
+
|
|
150
|
+
const codexProc = spawn('codex', [...codexArgs, command || ''], {
|
|
151
|
+
shell: true,
|
|
152
|
+
cwd: options?.projectPath || options?.cwd || os.homedir(),
|
|
153
|
+
env: process.env,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
codexProc.stdout.on('data', (chunk) => {
|
|
157
|
+
ws.send(JSON.stringify({
|
|
158
|
+
type: 'relay-stream',
|
|
159
|
+
requestId,
|
|
160
|
+
data: { type: 'codex-response', content: chunk.toString() }
|
|
161
|
+
}));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
codexProc.stderr.on('data', (chunk) => {
|
|
165
|
+
ws.send(JSON.stringify({
|
|
166
|
+
type: 'relay-stream',
|
|
167
|
+
requestId,
|
|
168
|
+
data: { type: 'codex-error', content: chunk.toString() }
|
|
169
|
+
}));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
codexProc.on('close', (code) => {
|
|
173
|
+
ws.send(JSON.stringify({
|
|
174
|
+
type: 'relay-complete',
|
|
175
|
+
requestId,
|
|
176
|
+
exitCode: code
|
|
177
|
+
}));
|
|
178
|
+
});
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
case 'cursor-query': {
|
|
183
|
+
const { command, options } = data;
|
|
184
|
+
logRelayEvent('>', `Cursor query: ${command?.slice(0, 60)}...`, 'cyan');
|
|
185
|
+
|
|
186
|
+
const cursorArgs = [];
|
|
187
|
+
if (options?.projectPath || options?.cwd) {
|
|
188
|
+
cursorArgs.push('--cwd', options.projectPath || options.cwd);
|
|
189
|
+
}
|
|
190
|
+
if (options?.model) cursorArgs.push('--model', options.model);
|
|
191
|
+
|
|
192
|
+
const cursorProc = spawn('cursor-agent', [...cursorArgs, command || ''], {
|
|
193
|
+
shell: true,
|
|
194
|
+
cwd: options?.projectPath || options?.cwd || os.homedir(),
|
|
195
|
+
env: process.env,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
cursorProc.stdout.on('data', (chunk) => {
|
|
199
|
+
ws.send(JSON.stringify({
|
|
200
|
+
type: 'relay-stream',
|
|
201
|
+
requestId,
|
|
202
|
+
data: { type: 'cursor-response', content: chunk.toString() }
|
|
203
|
+
}));
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
cursorProc.stderr.on('data', (chunk) => {
|
|
207
|
+
ws.send(JSON.stringify({
|
|
208
|
+
type: 'relay-stream',
|
|
209
|
+
requestId,
|
|
210
|
+
data: { type: 'cursor-error', content: chunk.toString() }
|
|
211
|
+
}));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
cursorProc.on('close', (code) => {
|
|
215
|
+
ws.send(JSON.stringify({
|
|
216
|
+
type: 'relay-complete',
|
|
217
|
+
requestId,
|
|
218
|
+
exitCode: code
|
|
219
|
+
}));
|
|
220
|
+
});
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
case 'detect-agents': {
|
|
225
|
+
const agents = detectInstalledAgents();
|
|
226
|
+
ws.send(JSON.stringify({
|
|
227
|
+
type: 'relay-response',
|
|
228
|
+
requestId,
|
|
229
|
+
data: { agents }
|
|
230
|
+
}));
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
|
|
140
234
|
case 'shell-command': {
|
|
141
235
|
const { command: cmd, cwd } = data;
|
|
142
236
|
// Block dangerous shell patterns
|
|
@@ -228,6 +322,39 @@ async function buildFileTree(dirPath, maxDepth, currentDepth = 0) {
|
|
|
228
322
|
}
|
|
229
323
|
}
|
|
230
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Detect which AI CLI agents are installed on this machine
|
|
327
|
+
* Returns an object with agent names and their availability
|
|
328
|
+
*/
|
|
329
|
+
function detectInstalledAgents() {
|
|
330
|
+
const isWindows = process.platform === 'win32';
|
|
331
|
+
const whichCmd = isWindows ? 'where' : 'which';
|
|
332
|
+
|
|
333
|
+
const agents = [
|
|
334
|
+
{ name: 'claude', binary: 'claude', label: 'Claude Code' },
|
|
335
|
+
{ name: 'codex', binary: 'codex', label: 'OpenAI Codex' },
|
|
336
|
+
{ name: 'cursor', binary: 'cursor-agent', label: 'Cursor Agent' },
|
|
337
|
+
];
|
|
338
|
+
|
|
339
|
+
const detected = {};
|
|
340
|
+
for (const agent of agents) {
|
|
341
|
+
try {
|
|
342
|
+
const result = execSync(`${whichCmd} ${agent.binary}`, { stdio: 'pipe', timeout: 5000 }).toString().trim();
|
|
343
|
+
detected[agent.name] = {
|
|
344
|
+
installed: true,
|
|
345
|
+
path: result.split('\n')[0].trim(),
|
|
346
|
+
label: agent.label,
|
|
347
|
+
};
|
|
348
|
+
} catch {
|
|
349
|
+
detected[agent.name] = {
|
|
350
|
+
installed: false,
|
|
351
|
+
label: agent.label,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return detected;
|
|
356
|
+
}
|
|
357
|
+
|
|
231
358
|
/**
|
|
232
359
|
* Create WebSocket connection with optional API key in handshake
|
|
233
360
|
*/
|
|
@@ -239,6 +366,8 @@ function createRelayConnection(wsUrl, config = {}) {
|
|
|
239
366
|
}
|
|
240
367
|
headers['x-upfyn-version'] = VERSION;
|
|
241
368
|
headers['x-upfyn-machine'] = os.hostname();
|
|
369
|
+
headers['x-upfyn-platform'] = process.platform;
|
|
370
|
+
headers['x-upfyn-cwd'] = process.cwd();
|
|
242
371
|
|
|
243
372
|
return new WebSocket(wsUrl, { headers });
|
|
244
373
|
}
|
|
@@ -302,6 +431,34 @@ export async function connectToServer(options = {}) {
|
|
|
302
431
|
const nameMatch = data.message?.match(/Connected as (.+?)\./);
|
|
303
432
|
const username = nameMatch ? nameMatch[1] : 'Unknown';
|
|
304
433
|
showConnectionBanner(username, serverUrl);
|
|
434
|
+
|
|
435
|
+
// Detect and report installed agents
|
|
436
|
+
const agents = detectInstalledAgents();
|
|
437
|
+
const installed = Object.entries(agents)
|
|
438
|
+
.filter(([, info]) => info.installed)
|
|
439
|
+
.map(([name, info]) => info.label);
|
|
440
|
+
const missing = Object.entries(agents)
|
|
441
|
+
.filter(([, info]) => !info.installed)
|
|
442
|
+
.map(([name, info]) => info.label);
|
|
443
|
+
|
|
444
|
+
if (installed.length > 0) {
|
|
445
|
+
logRelayEvent('+', `Agents found: ${installed.join(', ')}`, 'green');
|
|
446
|
+
}
|
|
447
|
+
if (missing.length > 0) {
|
|
448
|
+
logRelayEvent('~', `Not found: ${missing.join(', ')} (install to enable)`, 'yellow');
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Send agent capabilities to server
|
|
452
|
+
ws.send(JSON.stringify({
|
|
453
|
+
type: 'agent-capabilities',
|
|
454
|
+
agents,
|
|
455
|
+
machine: {
|
|
456
|
+
hostname: os.hostname(),
|
|
457
|
+
platform: process.platform,
|
|
458
|
+
cwd: process.cwd(),
|
|
459
|
+
}
|
|
460
|
+
}));
|
|
461
|
+
|
|
305
462
|
logRelayEvent('*', 'Relay active -- waiting for commands...', 'green');
|
|
306
463
|
return;
|
|
307
464
|
}
|
|
@@ -293,7 +293,7 @@ Custom commands can be created in:
|
|
|
293
293
|
// Read version from package.json
|
|
294
294
|
const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json');
|
|
295
295
|
let version = 'unknown';
|
|
296
|
-
let packageName = '
|
|
296
|
+
let packageName = 'upfynai-code';
|
|
297
297
|
|
|
298
298
|
try {
|
|
299
299
|
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
|