chore: Refactor CursorAuthModal to handle manual instructions for Windows users.
This commit is contained in:
@@ -339,19 +339,74 @@ export default function ProfilePage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Local Mode Info */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="size-12 rounded-lg bg-green-500/10 text-green-500 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-2xl">computer</span>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="size-12 rounded-lg bg-green-500/10 text-green-500 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-2xl">computer</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Local Mode</h2>
|
||||
<p className="text-text-muted">Running on your machine</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Local Mode</h2>
|
||||
<p className="text-text-muted">Running on your machine</p>
|
||||
<div className="inline-flex p-1 rounded-lg bg-black/5 dark:bg-white/5">
|
||||
{["light", "dark", "system"].map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setTheme(option)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md font-medium transition-all",
|
||||
theme === option
|
||||
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">
|
||||
{option === "light" ? "light_mode" : option === "dark" ? "dark_mode" : "contrast"}
|
||||
</span>
|
||||
<span className="capitalize text-sm">{option}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-border">
|
||||
<p className="text-sm text-text-muted">
|
||||
All data is stored locally in the <code className="bg-sidebar px-1 rounded">~/.9router/db.json</code> file.
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 pt-4 border-t border-border">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-bg border border-border">
|
||||
<div>
|
||||
<p className="font-medium">Database Location</p>
|
||||
<p className="text-sm text-text-muted font-mono">~/.9router/db.json</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="download"
|
||||
onClick={handleExportDatabase}
|
||||
loading={dbLoading}
|
||||
>
|
||||
Download Backup
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon="upload"
|
||||
onClick={() => importFileRef.current?.click()}
|
||||
disabled={dbLoading}
|
||||
>
|
||||
Import Backup
|
||||
</Button>
|
||||
<input
|
||||
ref={importFileRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
onChange={handleImportDatabase}
|
||||
/>
|
||||
</div>
|
||||
{dbStatus.message && (
|
||||
<p className={`text-sm ${dbStatus.type === "error" ? "text-red-500" : "text-green-600 dark:text-green-400"}`}>
|
||||
{dbStatus.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -560,102 +615,6 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Theme Preferences */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-purple-500/10 text-purple-500">
|
||||
<span className="material-symbols-outlined text-[20px]">palette</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">Appearance</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Dark Mode</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
Switch between light and dark themes
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={isDark}
|
||||
onChange={() => setTheme(isDark ? "light" : "dark")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Theme Options */}
|
||||
<div className="pt-4 border-t border-border">
|
||||
<div className="inline-flex p-1 rounded-lg bg-black/5 dark:bg-white/5">
|
||||
{["light", "dark", "system"].map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setTheme(option)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-md font-medium transition-all",
|
||||
theme === option
|
||||
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">
|
||||
{option === "light" ? "light_mode" : option === "dark" ? "dark_mode" : "contrast"}
|
||||
</span>
|
||||
<span className="capitalize">{option}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Data Management */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-green-500/10 text-green-500">
|
||||
<span className="material-symbols-outlined text-[20px]">database</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">Data</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-bg border border-border">
|
||||
<div>
|
||||
<p className="font-medium">Database Location</p>
|
||||
<p className="text-sm text-text-muted font-mono">~/.9router/db.json</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="download"
|
||||
onClick={handleExportDatabase}
|
||||
loading={dbLoading}
|
||||
>
|
||||
Download Backup
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon="upload"
|
||||
onClick={() => importFileRef.current?.click()}
|
||||
disabled={dbLoading}
|
||||
>
|
||||
Import Backup
|
||||
</Button>
|
||||
<input
|
||||
ref={importFileRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
onChange={handleImportDatabase}
|
||||
/>
|
||||
</div>
|
||||
{dbStatus.message && (
|
||||
<p className={`text-sm ${dbStatus.type === "error" ? "text-red-500" : "text-green-600 dark:text-green-400"}`}>
|
||||
{dbStatus.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Observability Settings */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
|
||||
@@ -2,7 +2,11 @@ import { NextResponse } from "next/server";
|
||||
import { access, constants } from "fs/promises";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import Database from "better-sqlite3";
|
||||
import { execFile, execSync } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const ACCESS_TOKEN_KEYS = ["cursorAuth/accessToken", "cursorAuth/token"];
|
||||
const MACHINE_ID_KEYS = ["storage.serviceMachineId", "storage.machineId", "telemetry.machineId"];
|
||||
@@ -29,15 +33,14 @@ function getCandidatePaths(platform) {
|
||||
];
|
||||
}
|
||||
|
||||
// Linux
|
||||
return [
|
||||
join(home, ".config/Cursor/User/globalStorage/state.vscdb"),
|
||||
join(home, ".config/cursor/User/globalStorage/state.vscdb"),
|
||||
];
|
||||
}
|
||||
|
||||
/** Extract tokens from open db, with fuzzy fallback */
|
||||
function extractTokens(db, platform) {
|
||||
/** Extract tokens using better-sqlite3 (stream-based, no RAM limit) */
|
||||
function extractTokens(db) {
|
||||
const desiredKeys = [...ACCESS_TOKEN_KEYS, ...MACHINE_ID_KEYS];
|
||||
const rows = db.prepare(
|
||||
`SELECT key, value FROM itemTable WHERE key IN (${desiredKeys.map(() => "?").join(",")})`
|
||||
@@ -62,7 +65,7 @@ function extractTokens(db, platform) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fuzzy fallback for all platforms when exact keys miss
|
||||
// Fuzzy fallback
|
||||
if (!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%'"
|
||||
@@ -83,16 +86,56 @@ function extractTokens(db, platform) {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tokens via sqlite3 CLI (fallback for Windows when native addon fails)
|
||||
* Queries each key individually and parses output
|
||||
*/
|
||||
async function extractTokensViaCLI(dbPath) {
|
||||
const normalize = (raw) => {
|
||||
const value = raw.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return typeof parsed === "string" ? parsed : value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const query = async (sql) => {
|
||||
const { stdout } = await execFileAsync("sqlite3", [dbPath, sql], { timeout: 10000 });
|
||||
return stdout.trim();
|
||||
};
|
||||
|
||||
// Try each key in priority order
|
||||
let accessToken = null;
|
||||
for (const key of ACCESS_TOKEN_KEYS) {
|
||||
try {
|
||||
const raw = await query(`SELECT value FROM itemTable WHERE key='${key}' LIMIT 1`);
|
||||
if (raw) { accessToken = normalize(raw); break; }
|
||||
} catch { /* try next */ }
|
||||
}
|
||||
|
||||
let machineId = null;
|
||||
for (const key of MACHINE_ID_KEYS) {
|
||||
try {
|
||||
const raw = await query(`SELECT value FROM itemTable WHERE key='${key}' LIMIT 1`);
|
||||
if (raw) { machineId = normalize(raw); break; }
|
||||
} catch { /* try next */ }
|
||||
}
|
||||
|
||||
return { accessToken, machineId };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/oauth/cursor/auto-import
|
||||
* Auto-detect and extract Cursor tokens from local SQLite database
|
||||
* Auto-detect and extract Cursor tokens from local SQLite database.
|
||||
* Strategy: better-sqlite3 (native, fast) → sqlite3 CLI (fallback) → windowsManual
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const platform = process.platform;
|
||||
const candidates = getCandidatePaths(platform);
|
||||
|
||||
// Find first readable db path
|
||||
let dbPath = null;
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
@@ -111,44 +154,47 @@ export async function GET() {
|
||||
});
|
||||
}
|
||||
|
||||
let db;
|
||||
// Strategy 1: better-sqlite3 bundled → then global install fallback
|
||||
let Database = null;
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
found: false,
|
||||
error: `Found Cursor database at:\n${dbPath}\n\nBut could not open it: ${error.message}`,
|
||||
});
|
||||
const mod = await import("better-sqlite3");
|
||||
Database = mod.default;
|
||||
} catch {
|
||||
// Try loading from global node_modules (user ran: npm i better-sqlite3 -g)
|
||||
try {
|
||||
const globalRoot = execSync("npm root -g", { timeout: 5000 }).toString().trim();
|
||||
const requireGlobal = createRequire(join(globalRoot, "better-sqlite3", "package.json"));
|
||||
Database = requireGlobal("better-sqlite3");
|
||||
} catch { /* fall through to sqlite3 CLI strategy */ }
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = extractTokens(db, platform);
|
||||
db.close();
|
||||
if (Database) {
|
||||
let db;
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
const tokens = extractTokens(db);
|
||||
db.close();
|
||||
|
||||
if (!tokens.accessToken || !tokens.machineId) {
|
||||
return NextResponse.json({
|
||||
found: false,
|
||||
error: "Tokens not found in database. Please login to Cursor IDE first.",
|
||||
});
|
||||
if (tokens.accessToken && tokens.machineId) {
|
||||
return NextResponse.json({ found: true, accessToken: tokens.accessToken, machineId: tokens.machineId });
|
||||
}
|
||||
} catch {
|
||||
db?.close();
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
found: true,
|
||||
accessToken: tokens.accessToken,
|
||||
machineId: tokens.machineId,
|
||||
});
|
||||
} catch (error) {
|
||||
db?.close();
|
||||
return NextResponse.json({
|
||||
found: false,
|
||||
error: `Failed to read database: ${error.message}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Strategy 2: sqlite3 CLI (works on Windows if sqlite3 is installed)
|
||||
try {
|
||||
const tokens = await extractTokensViaCLI(dbPath);
|
||||
if (tokens.accessToken && tokens.machineId) {
|
||||
return NextResponse.json({ found: true, accessToken: tokens.accessToken, machineId: tokens.machineId });
|
||||
}
|
||||
} catch { /* sqlite3 CLI not available */ }
|
||||
|
||||
// Strategy 3: ask user to paste manually
|
||||
return NextResponse.json({ found: false, windowsManual: true, dbPath });
|
||||
} catch (error) {
|
||||
console.log("Cursor auto-import error:", error);
|
||||
return NextResponse.json(
|
||||
{ found: false, error: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
return NextResponse.json({ found: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user