import { NextResponse } from "next/server"; import { getProviderConnections, getModelAliases, getCombos, getApiKeys, createApiKey, updateProviderConnection, updateSettings } from "@/lib/localDb"; import { getConsistentMachineId } from "@/shared/utils/machineId"; import fs from "fs/promises"; import path from "path"; import os from "os"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; /** * POST /api/sync/cloud * Sync data with Cloud */ export async function POST(request) { try { const body = await request.json(); const { action } = body; // Always get machineId from server, don't trust client const machineId = await getConsistentMachineId(); switch (action) { case "enable": await updateSettings({ cloudEnabled: true }); // Auto create key if none exists const keys = await getApiKeys(); let createdKey = null; if (keys.length === 0) { createdKey = await createApiKey("Default Key", machineId); } return syncAndVerify(machineId, createdKey?.key, keys); case "sync": { const syncResult = await syncToCloud(machineId); if (syncResult.error) { return NextResponse.json(syncResult, { status: 502 }); } return NextResponse.json(syncResult); } case "disable": await updateSettings({ cloudEnabled: false }); return handleDisable(machineId, request); default: return NextResponse.json({ error: "Invalid action" }, { status: 400 }); } } catch (error) { console.log("Cloud sync error:", error); return NextResponse.json({ error: error.message }, { status: 500 }); } } /** * Sync data to Cloud (exported for reuse) * @param {string} machineId * @param {string|null} createdKey - Key created during enable */ export async function syncToCloud(machineId, createdKey = null) { // Get current data from db const providers = await getProviderConnections(); const modelAliases = await getModelAliases(); const combos = await getCombos(); const apiKeys = await getApiKeys(); // Send to Cloud const response = await fetch(`${CLOUD_URL}/sync/${machineId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ providers, modelAliases, combos, apiKeys }) }); if (!response.ok) { const errorText = await response.text(); console.log("Cloud sync failed:", errorText); return NextResponse.json({ error: "Cloud sync failed" }, { status: 502 }); } const result = await response.json(); // Update local db with tokens from Cloud (providers stored by ID) if (result.data && result.data.providers) { await updateLocalTokens(result.data.providers); } const responseData = { success: true, message: "Synced successfully", changes: result.changes }; if (createdKey) { responseData.createdKey = createdKey; } return responseData; } /** * Sync and verify connection with ping */ async function syncAndVerify(machineId, createdKey, existingKeys) { // Step 1: Sync data to cloud const syncResult = await syncToCloud(machineId, createdKey); if (syncResult.error) { return NextResponse.json(syncResult, { status: 502 }); } // Step 2: Verify connection by pinging the cloud const apiKey = createdKey || existingKeys[0]?.key; if (!apiKey) { return NextResponse.json({ ...syncResult, verified: false, verifyError: "No API key available" }); } try { const pingResponse = await fetch(`${CLOUD_URL}/${machineId}/v1/verify`, { method: "GET", headers: { "Authorization": `Bearer ${apiKey}`, "Content-Type": "application/json" } }); if (pingResponse.ok) { return NextResponse.json({ ...syncResult, verified: true }); } else { return NextResponse.json({ ...syncResult, verified: false, verifyError: `Ping failed: ${pingResponse.status}` }); } } catch (error) { return NextResponse.json({ ...syncResult, verified: false, verifyError: error.message }); } } /** * Disable Cloud - delete cache and update Claude CLI settings */ async function handleDisable(machineId, request) { const response = await fetch(`${CLOUD_URL}/sync/${machineId}`, { method: "DELETE" }); if (!response.ok) { const errorText = await response.text(); console.log("Cloud disable failed:", errorText); return NextResponse.json({ error: "Failed to disable cloud" }, { status: 502 }); } // Update Claude CLI settings to use local endpoint const host = request.headers.get("host") || "localhost:3000"; await updateClaudeSettingsToLocal(machineId, host); return NextResponse.json({ success: true, message: "Cloud disabled" }); } /** * Update Claude CLI settings to use local endpoint (only if currently using cloud) */ async function updateClaudeSettingsToLocal(machineId, host) { try { const settingsPath = path.join(os.homedir(), ".claude", "settings.json"); const cloudUrl = `${CLOUD_URL}/${machineId}`; const localUrl = `http://${host}`; // Read current settings let settings; try { const content = await fs.readFile(settingsPath, "utf-8"); settings = JSON.parse(content); } catch (error) { if (error.code === "ENOENT") { return; // No settings file, nothing to update } throw error; } // Check if ANTHROPIC_BASE_URL matches cloud URL const currentUrl = settings.env?.ANTHROPIC_BASE_URL; if (!currentUrl || currentUrl !== cloudUrl) { return; // Not using cloud URL, don't modify } // Update to local URL settings.env.ANTHROPIC_BASE_URL = localUrl; await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); console.log(`Updated Claude CLI settings: ${cloudUrl} → ${localUrl}`); } catch (error) { console.log("Failed to update Claude CLI settings:", error.message); } } /** * Update local db with data from Cloud * Simple logic: if Cloud is newer, sync entire provider * cloudProviders is object keyed by provider ID */ async function updateLocalTokens(cloudProviders) { const localProviders = await getProviderConnections(); for (const localProvider of localProviders) { const cloudProvider = cloudProviders[localProvider.id]; if (!cloudProvider) continue; const cloudUpdatedAt = new Date(cloudProvider.updatedAt || 0).getTime(); const localUpdatedAt = new Date(localProvider.updatedAt || 0).getTime(); // Simple logic: if Cloud is newer, sync entire provider if (cloudUpdatedAt > localUpdatedAt) { const updates = { // Tokens accessToken: cloudProvider.accessToken, refreshToken: cloudProvider.refreshToken, expiresAt: cloudProvider.expiresAt, expiresIn: cloudProvider.expiresIn, // Provider specific data providerSpecificData: cloudProvider.providerSpecificData || localProvider.providerSpecificData, // Status fields testStatus: cloudProvider.status || "active", lastError: cloudProvider.lastError, lastErrorAt: cloudProvider.lastErrorAt, errorCode: cloudProvider.errorCode, rateLimitedUntil: cloudProvider.rateLimitedUntil, // Metadata updatedAt: cloudProvider.updatedAt }; await updateProviderConnection(localProvider.id, updates); console.log(`Updated ${localProvider.provider} (${localProvider.id}) from Cloud (newer: ${new Date(cloudUpdatedAt).toISOString()})`); } else { console.log(`Skipped ${localProvider.provider} (${localProvider.id}) - Local is newer or equal`); } } }