Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 79 additions & 9 deletions src/app/api/oauth/cursor/auto-import/route.js
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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") {
Expand All @@ -31,24 +54,71 @@ 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.",
});
}

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;
}
}
}

Expand Down
184 changes: 184 additions & 0 deletions tests/unit/oauth-cursor-auto-import.test.js
Original file line number Diff line number Diff line change
@@ -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");
});
});