diff --git a/src/app/api/oauth/cursor/auto-import/route.js b/src/app/api/oauth/cursor/auto-import/route.js index 2a9eec8..f4487a9 100644 --- a/src/app/api/oauth/cursor/auto-import/route.js +++ b/src/app/api/oauth/cursor/auto-import/route.js @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { access, constants } from "fs/promises"; import { homedir } from "os"; import { join } from "path"; import Database from "better-sqlite3"; @@ -12,9 +13,31 @@ export async function GET() { const platform = process.platform; let dbPath; - // Determine database path based on platform if (platform === "darwin") { - dbPath = join(homedir(), "Library/Application Support/Cursor/User/globalStorage/state.vscdb"); + // macOS: probe multiple locations (standard + Insiders) + const userHome = homedir(); + const candidateDbPaths = [ + join(userHome, "Library/Application Support/Cursor/User/globalStorage/state.vscdb"), + join(userHome, "Library/Application Support/Cursor - Insiders/User/globalStorage/state.vscdb"), + ]; + + for (const path of candidateDbPaths) { + try { + await access(path, constants.R_OK); + dbPath = path; + break; + } catch { + // Continue probing next candidate. + } + } + + if (!dbPath) { + return NextResponse.json({ + found: false, + error: + "Cursor database not found in known macOS locations. Make sure Cursor IDE is installed and opened at least once.", + }); + } } else if (platform === "linux") { dbPath = join(homedir(), ".config/Cursor/User/globalStorage/state.vscdb"); } else if (platform === "win32") { @@ -31,6 +54,12 @@ export async function GET() { try { db = new Database(dbPath, { readonly: true, fileMustExist: true }); } catch (error) { + if (platform === "darwin") { + return NextResponse.json({ + found: false, + error: `Found Cursor database at ${dbPath} but could not open it: ${error.message}`, + }); + } return NextResponse.json({ found: false, error: "Cursor database not found. Make sure Cursor IDE is installed and you are logged in.", @@ -38,17 +67,58 @@ export async function GET() { } try { - // Extract tokens from database + const accessTokenKeys = [ + "cursorAuth/accessToken", + "cursorAuth/token", + ]; + const machineIdKeys = [ + "storage.serviceMachineId", + "storage.machineId", + "telemetry.machineId", + ]; + const desiredKeys = [...accessTokenKeys, ...machineIdKeys]; + const rows = db.prepare( - "SELECT key, value FROM itemTable WHERE key IN (?, ?)" - ).all("cursorAuth/accessToken", "storage.serviceMachineId"); + `SELECT key, value FROM itemTable WHERE key IN (${desiredKeys.map(() => "?").join(",")})` + ).all(...desiredKeys); + + const normalizeValue = (value) => { + if (typeof value !== "string") return value; + try { + const parsed = JSON.parse(value); + return typeof parsed === "string" ? parsed : value; + } catch { + return value; + } + }; const tokens = {}; for (const row of rows) { - if (row.key === "cursorAuth/accessToken") { - tokens.accessToken = row.value; - } else if (row.key === "storage.serviceMachineId") { - tokens.machineId = row.value; + if (accessTokenKeys.includes(row.key) && !tokens.accessToken) { + tokens.accessToken = normalizeValue(row.value); + } else if (machineIdKeys.includes(row.key) && !tokens.machineId) { + tokens.machineId = normalizeValue(row.value); + } + } + + // Fuzzy fallback for newer/changed key names (macOS only, where the + // issue was originally reported; other platforms use exact keys). + if (platform === "darwin" && (!tokens.accessToken || !tokens.machineId)) { + const fallbackRows = db.prepare( + "SELECT key, value FROM itemTable WHERE key LIKE '%cursorAuth/%' OR key LIKE '%machineId%' OR key LIKE '%serviceMachineId%'" + ).all(); + + for (const row of fallbackRows) { + const key = row.key || ""; + const value = normalizeValue(row.value); + + if (!tokens.accessToken && key.toLowerCase().includes("accesstoken")) { + tokens.accessToken = value; + } + + if (!tokens.machineId && key.toLowerCase().includes("machineid")) { + tokens.machineId = value; + } } } diff --git a/tests/unit/oauth-cursor-auto-import.test.js b/tests/unit/oauth-cursor-auto-import.test.js new file mode 100644 index 0000000..c5abe0d --- /dev/null +++ b/tests/unit/oauth-cursor-auto-import.test.js @@ -0,0 +1,184 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import * as fsPromises from "fs/promises"; + +// Mock next/server +vi.mock("next/server", () => ({ + NextResponse: { + json: vi.fn((body, init) => ({ + status: init?.status || 200, + body, + json: async () => body, + })), + }, +})); + +// Mock os +vi.mock("os", () => ({ + default: { homedir: vi.fn(() => "/mock/home") }, + homedir: vi.fn(() => "/mock/home"), +})); + +// Mock fs/promises +vi.mock("fs/promises", () => ({ + access: vi.fn(), + constants: { R_OK: 4 }, +})); + +// Shared mock db instance +const mockDbInstance = { + prepare: vi.fn(), + close: vi.fn(), + __throwOnConstruct: false, +}; + +// Mock better-sqlite3 as a class so `new Database(...)` works +vi.mock("better-sqlite3", () => ({ + default: class MockDatabase { + constructor() { + if (mockDbInstance.__throwOnConstruct) { + throw new Error("SQLITE_CANTOPEN"); + } + return mockDbInstance; + } + }, +})); + +// We need to dynamically import after mocks are registered +let GET; + +describe("GET /api/oauth/cursor/auto-import", () => { + const originalPlatform = process.platform; + + beforeEach(async () => { + vi.clearAllMocks(); + mockDbInstance.__throwOnConstruct = false; + // Force darwin so macOS-specific logic is exercised + Object.defineProperty(process, "platform", { value: "darwin", writable: true }); + // Re-import to pick up fresh mocks each run + const mod = await import("../../src/app/api/oauth/cursor/auto-import/route.js"); + GET = mod.GET; + }); + + afterEach(() => { + Object.defineProperty(process, "platform", { value: originalPlatform, writable: true }); + }); + + // ── macOS path probing ──────────────────────────────────────────────── + + it("returns not-found when no macOS cursor db paths are accessible", async () => { + vi.mocked(fsPromises.access).mockRejectedValue(new Error("ENOENT")); + + const response = await GET(); + + expect(response.body.found).toBe(false); + expect(response.body.error).toContain("Cursor database not found in known macOS locations"); + }); + + it("returns descriptive error if macOS db file exists but cannot be opened", async () => { + vi.mocked(fsPromises.access).mockResolvedValue(); + mockDbInstance.__throwOnConstruct = true; + + const response = await GET(); + + expect(response.body.found).toBe(false); + expect(response.body.error).toContain("could not open it"); + expect(response.body.error).toContain("SQLITE_CANTOPEN"); + }); + + // ── Token extraction ────────────────────────────────────────────────── + + it("extracts tokens using exact keys", async () => { + vi.mocked(fsPromises.access).mockResolvedValue(); + mockDbInstance.prepare.mockReturnValue({ + all: vi.fn().mockReturnValue([ + { key: "cursorAuth/accessToken", value: "test-token" }, + { key: "storage.serviceMachineId", value: "test-machine-id" }, + ]), + }); + + const response = await GET(); + + expect(response.body.found).toBe(true); + expect(response.body.accessToken).toBe("test-token"); + expect(response.body.machineId).toBe("test-machine-id"); + expect(mockDbInstance.close).toHaveBeenCalled(); + }); + + it("unwraps JSON-encoded string values", async () => { + vi.mocked(fsPromises.access).mockResolvedValue(); + mockDbInstance.prepare.mockReturnValue({ + all: vi.fn().mockReturnValue([ + { key: "cursorAuth/accessToken", value: '"json-token"' }, + { key: "storage.serviceMachineId", value: '"json-machine-id"' }, + ]), + }); + + const response = await GET(); + + expect(response.body.found).toBe(true); + expect(response.body.accessToken).toBe("json-token"); + expect(response.body.machineId).toBe("json-machine-id"); + }); + + // ── Fuzzy fallback (macOS only) ─────────────────────────────────────── + + it("falls back to fuzzy key matching on macOS when exact keys are missing", async () => { + vi.mocked(fsPromises.access).mockResolvedValue(); + mockDbInstance.prepare.mockImplementation((query) => { + if (query.includes("IN (")) { + return { all: vi.fn().mockReturnValue([]) }; + } + // Fuzzy LIKE query + return { + all: vi.fn().mockReturnValue([ + { key: "cursorAuth/someOtherAccessTokenKey", value: "fallback-token" }, + { key: "storage.someMachineId", value: "fallback-machine" }, + ]), + }; + }); + + const response = await GET(); + + expect(response.body.found).toBe(true); + expect(response.body.accessToken).toBe("fallback-token"); + expect(response.body.machineId).toBe("fallback-machine"); + }); + + it("returns login-prompt error when tokens are missing even after fallback", async () => { + vi.mocked(fsPromises.access).mockResolvedValue(); + mockDbInstance.prepare.mockReturnValue({ + all: vi.fn().mockReturnValue([]), + }); + + const response = await GET(); + + expect(response.body.found).toBe(false); + expect(response.body.error).toContain("Please login to Cursor IDE first"); + }); + + // ── Backwards-compatible: linux/win32 keep original single-path logic ─ + + it("linux uses single hardcoded path and original error message", async () => { + Object.defineProperty(process, "platform", { value: "linux", writable: true }); + vi.mocked(fsPromises.access).mockRejectedValue(new Error("ENOENT")); + mockDbInstance.__throwOnConstruct = true; + + const response = await GET(); + + expect(response.body.found).toBe(false); + expect(response.body.error).toBe( + "Cursor database not found. Make sure Cursor IDE is installed and you are logged in." + ); + // fs/promises.access should NOT have been called (linux skips probing) + expect(fsPromises.access).not.toHaveBeenCalled(); + }); + + it("unsupported platform returns 400", async () => { + Object.defineProperty(process, "platform", { value: "freebsd", writable: true }); + + const response = await GET(); + + expect(response.status).toBe(400); + expect(response.body.error).toBe("Unsupported platform"); + }); +});