import { NextResponse } from "next/server"; import { getApiKeys, createApiKey, updateSettings } from "@/lib/localDb"; import { getConsistentMachineId } from "@/shared/utils/machineId"; import { syncToCloud, fetchWithTimeout, CLOUD_URL } from "@/lib/cloudSync"; import fs from "fs/promises"; import path from "path"; import os from "os"; import { cloudSyncActionSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; /** * GET /api/sync/cloud * Returns current cloud sync status for sidebar indicator */ export async function GET() { try { const { isCloudEnabled } = await import("@/lib/db/settings"); const enabled = await isCloudEnabled(); if (!enabled) { return NextResponse.json({ enabled: false }); } // Cloud is enabled — try to verify connection const machineId = await getConsistentMachineId(); const keys = await getApiKeys(); const apiKey = keys[0]?.key; if (!apiKey || !CLOUD_URL) { return NextResponse.json({ enabled: true, connected: false }); } try { const pingRes = await fetchWithTimeout( `${CLOUD_URL}/${machineId}/v1/verify`, { method: "GET", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, }, 5000 ); return NextResponse.json({ enabled: true, connected: pingRes.ok, lastSync: new Date().toISOString(), }); } catch { return NextResponse.json({ enabled: true, connected: false }); } } catch (error: any) { return NextResponse.json({ enabled: false, error: error.message }, { status: 500 }); } } /** * POST /api/sync/cloud * Sync data with Cloud */ export async function POST(request: any) { let rawBody; try { rawBody = await request.json(); } catch { return NextResponse.json( { error: { message: "Invalid request", details: [{ field: "body", message: "Invalid JSON body" }] } }, { status: 400 } ); } try { const validation = validateBody(cloudSyncActionSchema, rawBody); if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); } const { action } = validation.data; // Always get machineId from server, don't trust client const machineId = await getConsistentMachineId(); switch (action) { case "enable": { // Auto create key if none exists (before sync, so it's included in sync data) const keys = await getApiKeys(); let createdKey = null; if (keys.length === 0) { createdKey = await createApiKey("Default Key", machineId); } // Sync first — only enable if sync succeeds const enableResult = await syncAndVerify(machineId, createdKey?.key, keys); const enableBody = await enableResult.clone().json().catch(() => ({})); // Only persist cloudEnabled if sync succeeded (body.success exists) if (enableBody.success) { await updateSettings({ cloudEnabled: true }); } return enableResult; } case "sync": { const syncResult: any = 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: any) { console.log("Cloud sync error:", error); return NextResponse.json({ error: error.message }, { status: 500 }); } } /** * Sync and verify connection with ping (retry on verify) */ async function syncAndVerify(machineId: string, createdKey: any, existingKeys: any[]) { // Step 1: Sync data to cloud const syncResult: any = await syncToCloud(machineId, createdKey); if (syncResult.error) { return NextResponse.json( { error: `Cloud sync failed: ${syncResult.error}` }, { status: 502 } ); } // Build the cloud URL for the frontend to use const cloudUrl = CLOUD_URL ? `${CLOUD_URL}/${machineId}` : null; // Step 2: Verify connection by pinging the cloud (with retry) const apiKey = createdKey || existingKeys[0]?.key; if (!apiKey) { return NextResponse.json({ ...syncResult, cloudUrl, verified: false, verifyError: "No API key available", }); } // Retry verify up to 2 times with a delay (cloud may need a moment after sync) const MAX_VERIFY_ATTEMPTS = 2; const VERIFY_RETRY_DELAY_MS = 1500; let lastVerifyError = null; for (let attempt = 1; attempt <= MAX_VERIFY_ATTEMPTS; attempt++) { try { const pingResponse = await fetchWithTimeout( `${CLOUD_URL}/${machineId}/v1/verify`, { method: "GET", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, }, 5000 ); if (pingResponse.ok) { return NextResponse.json({ ...syncResult, cloudUrl, verified: true, }); } lastVerifyError = `Ping failed: ${pingResponse.status}`; } catch (error: any) { lastVerifyError = error?.name === "AbortError" ? "Verify timeout" : error.message; } // Wait before retry (except on last attempt) if (attempt < MAX_VERIFY_ATTEMPTS) { await new Promise((r) => setTimeout(r, VERIFY_RETRY_DELAY_MS)); } } // Sync succeeded but verify failed — still return success with warning return NextResponse.json({ ...syncResult, cloudUrl, verified: false, verifyError: lastVerifyError || "Verification failed after retries", }); } /** * Disable Cloud - delete cache and update Claude CLI settings */ async function handleDisable(machineId: string, request: any) { if (!CLOUD_URL) { return NextResponse.json({ error: "NEXT_PUBLIC_CLOUD_URL is not configured" }, { status: 500 }); } let response; try { response = await fetchWithTimeout(`${CLOUD_URL}/sync/${machineId}`, { method: "DELETE", }); } catch (error: any) { const isTimeout = error?.name === "AbortError"; return NextResponse.json( { error: isTimeout ? "Cloud disable timeout" : "Failed to reach cloud service", }, { status: 502 } ); } 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:20128"; 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: string, host: string) { 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: any) { 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: any) { console.log("Failed to update Claude CLI settings:", error.message); } }