|
/** |
|
* Switch Cursor web session (Google accounts) |
|
* |
|
* Prerequisites: |
|
* npx -y playwright install chromium |
|
* Enable brave://inspect/#remote-debugging |
|
* |
|
* Run: |
|
* export CURSOR_ACCOUNTS='work@gmail.com,personal@gmail.com' |
|
* npx -y -p playwright -p tsx tsx .cursor/switch-cursor-account.ts |
|
*/ |
|
|
|
import { readFile } from 'node:fs/promises'; |
|
import path from 'node:path'; |
|
import os from 'node:os'; |
|
|
|
import { chromium, type BrowserContext, type Page } from 'playwright'; |
|
|
|
const AGENTS_URL = 'https://cursor.com/agents'; |
|
const DASHBOARD_URL = 'https://cursor.com/dashboard'; |
|
const DEFAULT_BRAVE_PATH = '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser'; |
|
|
|
const TIMEOUT_MS = 30_000; |
|
|
|
const BRAVE_ARGS_COMMON = [ |
|
'--no-first-run', |
|
'--no-default-browser-check', |
|
'--disable-infobars', |
|
'--disable-notifications', |
|
] as const; |
|
|
|
function defaultBraveUserDataDir(): string { |
|
const home = os.homedir(); |
|
if (process.platform === 'darwin') { |
|
return path.join(home, 'Library/Application Support/BraveSoftware/Brave-Browser'); |
|
} |
|
if (process.platform === 'win32') { |
|
return path.join(home, 'AppData', 'Local', 'BraveSoftware', 'Brave-Browser'); |
|
} |
|
return path.join(home, '.config', 'BraveSoftware', 'Brave-Browser'); |
|
} |
|
|
|
function parseAccountPair(): [string, string] { |
|
const raw = process.env.CURSOR_ACCOUNTS?.trim(); |
|
if (!raw) { |
|
throw new Error( |
|
'Set CURSOR_ACCOUNTS to exactly two comma-separated emails, e.g. CURSOR_ACCOUNTS=work@corp.com,personal@example.com', |
|
); |
|
} |
|
const parts = raw |
|
.split(',') |
|
.map((s) => s.trim().toLowerCase()) |
|
.filter(Boolean); |
|
if (parts.length !== 2) { |
|
throw new Error( |
|
'CURSOR_ACCOUNTS must list exactly two emails: work@corp.com,personal@gmail.com', |
|
); |
|
} |
|
return [parts[0], parts[1]]; |
|
} |
|
|
|
function resolveTargetEmail(current: string | null, pair: [string, string]): string { |
|
const [a, b] = pair; |
|
if (current === a) return b; |
|
if (current === b) return a; |
|
console.warn( |
|
`Session is not ${a} or ${b} (got: ${current ?? 'unknown'}) — signing in as ${a}.`, |
|
); |
|
return a; |
|
} |
|
|
|
function escapeRe(s: string): string { |
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
|
} |
|
|
|
async function openUserMenu(page: Page): Promise<void> { |
|
await page.getByRole('button', { name: 'User menu' }).click(); |
|
} |
|
|
|
/** |
|
* First match for email-shaped text in the menu. TLD segment is greedy, so adjacent |
|
* lowercase UI words (e.g. "upgrade" glued after .com) can be absorbed — strip those after. |
|
*/ |
|
const EMAIL_IN_TEXT = |
|
/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63})(?![a-zA-Z0-9._%+])/; |
|
|
|
/** Cursor user menu can concatenate tier/CTA labels with no separator after the email. */ |
|
const GLUED_MENU_WORD = /(upgrade|pro|free|plus|business)$/i; |
|
|
|
function looksLikeEmail(s: string): boolean { |
|
return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(s); |
|
} |
|
|
|
function stripGluedMenuWordsAfterEmail(email: string): string { |
|
let out = email; |
|
while (GLUED_MENU_WORD.test(out)) { |
|
const next = out.replace(GLUED_MENU_WORD, ''); |
|
if (!looksLikeEmail(next)) break; |
|
out = next; |
|
} |
|
return out; |
|
} |
|
|
|
async function readSignedInEmailFromMenu(page: Page): Promise<string | null> { |
|
const menu = page.getByRole('menu', { name: 'User menu' }); |
|
const raw = await menu.textContent().catch(() => null); |
|
if (!raw) return null; |
|
const after = raw.split(/signed in as/i)[1] ?? raw; |
|
const m = after.match(EMAIL_IN_TEXT); |
|
const extracted = m?.[1]?.toLowerCase() ?? null; |
|
if (!extracted) return null; |
|
return stripGluedMenuWordsAfterEmail(extracted); |
|
} |
|
|
|
async function assertSignedInAs(page: Page, email: string): Promise<void> { |
|
await page.goto(DASHBOARD_URL, { waitUntil: 'domcontentloaded', timeout: TIMEOUT_MS }); |
|
await openUserMenu(page); |
|
const seen = await readSignedInEmailFromMenu(page); |
|
if (seen !== email.toLowerCase()) { |
|
throw new Error( |
|
`Expected Signed in as ${email}, got ${seen ?? '(could not read menu)'}`, |
|
); |
|
} |
|
console.log(`OK — Signed in as ${email}`); |
|
} |
|
|
|
async function pickGooglePageAfterOAuthClick(page: Page): Promise<Page> { |
|
const popupPromise = page.waitForEvent('popup', { timeout: 5000 }); |
|
await page.getByRole('link', { name: /Continue with Google/i }).click(); |
|
try { |
|
const popup = await popupPromise; |
|
await popup.waitForLoadState('domcontentloaded'); |
|
return popup; |
|
} catch { |
|
await page.waitForURL(/accounts\.google\.com/, { timeout: TIMEOUT_MS }); |
|
return page; |
|
} |
|
} |
|
|
|
// --- CDP (attach to running Brave) --- |
|
|
|
type DevToolsPortFile = { |
|
port: number; |
|
httpBase: string; |
|
wsBrowser?: string; |
|
}; |
|
|
|
function parseDevToolsActivePort(raw: string): DevToolsPortFile | null { |
|
const lines = raw |
|
.split(/\r?\n/) |
|
.map((l) => l.trim()) |
|
.filter(Boolean); |
|
const port = Number.parseInt(lines[0] ?? '', 10); |
|
if (!Number.isFinite(port) || port <= 0 || port > 65535) return null; |
|
const httpBase = `http://127.0.0.1:${port}`; |
|
const line2 = lines[1]; |
|
const wsBrowser = |
|
line2?.startsWith('/') && line2.includes('devtools') |
|
? `ws://127.0.0.1:${port}${line2}` |
|
: undefined; |
|
return { port, httpBase, wsBrowser }; |
|
} |
|
|
|
async function readDevToolsActivePort(userDataDir: string): Promise<DevToolsPortFile | null> { |
|
try { |
|
const raw = await readFile(path.join(userDataDir, 'DevToolsActivePort'), 'utf8'); |
|
return parseDevToolsActivePort(raw); |
|
} catch { |
|
return null; |
|
} |
|
} |
|
|
|
function portFromHttpUrl(httpBase: string): number | null { |
|
try { |
|
const u = new URL(httpBase.includes('://') ? httpBase : `http://${httpBase}`); |
|
const p = Number(u.port || (u.protocol === 'https:' ? 443 : 80)); |
|
return Number.isFinite(p) && p > 0 ? p : null; |
|
} catch { |
|
return null; |
|
} |
|
} |
|
|
|
/** If HTTP /json/version fails but DevToolsActivePort matches, use browser WebSocket (Brave). */ |
|
function matchingWsFallback( |
|
file: DevToolsPortFile | null, |
|
httpPort: number | null, |
|
): string | undefined { |
|
if (file?.wsBrowser && httpPort != null && file.port === httpPort) { |
|
return file.wsBrowser; |
|
} |
|
return undefined; |
|
} |
|
|
|
async function normalizeCdpEndpoint(httpOrWs: string): Promise<string> { |
|
const trimmed = httpOrWs.trim(); |
|
if (trimmed.startsWith('ws://') || trimmed.startsWith('wss://')) { |
|
return trimmed; |
|
} |
|
|
|
const base = trimmed.replace(/\/$/, ''); |
|
const file = await readDevToolsActivePort(defaultBraveUserDataDir()); |
|
const port = portFromHttpUrl(base); |
|
|
|
const ac = new AbortController(); |
|
const timer = setTimeout(() => ac.abort(), 8000); |
|
let res: Response; |
|
try { |
|
res = await fetch(`${base}/json/version`, { signal: ac.signal }); |
|
} catch { |
|
const ws = matchingWsFallback(file, port); |
|
if (ws) return ws; |
|
throw new Error(cdpBadEndpointHint(base)); |
|
} finally { |
|
clearTimeout(timer); |
|
} |
|
|
|
if (!res.ok) { |
|
const ws = matchingWsFallback(file, port); |
|
if (ws) return ws; |
|
throw new Error(cdpBadEndpointHint(base)); |
|
} |
|
|
|
const j = (await res.json()) as { webSocketDebuggerUrl?: string }; |
|
return j.webSocketDebuggerUrl ?? base; |
|
} |
|
|
|
function cdpRefusedHint(endpoint: string): string { |
|
return [ |
|
`CDP connect failed (${endpoint}): nothing listening — open Brave and enable brave://inspect/#remote-debugging.`, |
|
'Or delete stale DevToolsActivePort if Brave is closed.', |
|
].join('\n'); |
|
} |
|
|
|
function cdpBadEndpointHint(tried: string): string { |
|
const profile = defaultBraveUserDataDir(); |
|
const port = portFromHttpUrl(tried); |
|
const lsof = |
|
port != null |
|
? `lsof -nP -iTCP:${port}` |
|
: 'lsof -nP -iTCP:9222'; |
|
return [ |
|
`CDP failed (${tried}): not a DevTools HTTP endpoint.`, |
|
`Check: ${lsof} • cat "${path.join(profile, 'DevToolsActivePort')}"`, |
|
'Or ensure Brave remote debugging matches that file.', |
|
].join('\n'); |
|
} |
|
|
|
function cdpConnectError(err: unknown, endpoint: string): Error { |
|
if (!(err instanceof Error)) return new Error(String(err)); |
|
const code = (err as NodeJS.ErrnoException).code; |
|
if (code === 'ECONNREFUSED' || /ECONNREFUSED/i.test(err.message)) { |
|
return new Error(cdpRefusedHint(endpoint)); |
|
} |
|
if (/404|does not look like a DevTools/i.test(err.message)) { |
|
return new Error(cdpBadEndpointHint(endpoint)); |
|
} |
|
return err; |
|
} |
|
|
|
async function connectBrowser(): Promise<{ |
|
context: BrowserContext; |
|
dispose: () => Promise<void>; |
|
}> { |
|
const devtools = await readDevToolsActivePort(defaultBraveUserDataDir()); |
|
if (devtools) { |
|
const cdpRaw = devtools.wsBrowser ?? devtools.httpBase; |
|
console.log( |
|
`CDP: ${cdpRaw}${devtools.wsBrowser ? ' (ws from DevToolsActivePort)' : ''}`, |
|
); |
|
const connectUrl = await normalizeCdpEndpoint(cdpRaw); |
|
try { |
|
const browser = await chromium.connectOverCDP(connectUrl); |
|
const context = browser.contexts()[0] ?? (await browser.newContext()); |
|
return { context, dispose: () => browser.close() }; |
|
} catch (err) { |
|
throw cdpConnectError(err, cdpRaw); |
|
} |
|
} |
|
|
|
const browser = await chromium.launch({ |
|
executablePath: DEFAULT_BRAVE_PATH, |
|
args: [ |
|
...BRAVE_ARGS_COMMON, |
|
'--disable-default-apps', |
|
'--disable-blink-features=AutomationControlled', |
|
], |
|
}); |
|
const context = await browser.newContext(); |
|
return { context, dispose: () => browser.close() }; |
|
} |
|
|
|
async function main(): Promise<void> { |
|
const pair = parseAccountPair(); |
|
|
|
const { context, dispose } = await connectBrowser(); |
|
const page = await context.newPage(); |
|
page.setDefaultTimeout(TIMEOUT_MS); |
|
|
|
try { |
|
console.log(`→ ${AGENTS_URL}`); |
|
await page.goto(AGENTS_URL, { waitUntil: 'domcontentloaded', timeout: TIMEOUT_MS }); |
|
|
|
await openUserMenu(page); |
|
const current = await readSignedInEmailFromMenu(page); |
|
console.log(`Signed in as: ${current ?? '(unknown)'}`); |
|
|
|
const target = resolveTargetEmail(current, pair); |
|
console.log(`Switching to: ${target}`); |
|
|
|
if (current === target) { |
|
console.log('Already on that account — nothing to do.'); |
|
return; |
|
} |
|
|
|
await page.getByRole('menuitem', { name: /log out/i }).click(); |
|
await page.waitForURL(/cursor\.com/, { timeout: TIMEOUT_MS }); |
|
|
|
console.log(`→ ${DASHBOARD_URL} (sign-in)`); |
|
await page.goto(DASHBOARD_URL, { waitUntil: 'domcontentloaded', timeout: TIMEOUT_MS }); |
|
|
|
const googlePage = await pickGooglePageAfterOAuthClick(page); |
|
const emailRe = new RegExp(escapeRe(target), 'i'); |
|
await googlePage.getByRole('link', { name: emailRe }).click(); |
|
|
|
await googlePage.waitForURL(/cursor\.com/, { timeout: TIMEOUT_MS }); |
|
|
|
const verifyPage = |
|
context.pages().find((p) => p.url().includes('cursor.com')) ?? googlePage; |
|
await assertSignedInAs(verifyPage, target); |
|
} finally { |
|
await dispose(); |
|
} |
|
} |
|
|
|
main().catch((err) => { |
|
console.error(err instanceof Error ? err.message : err); |
|
process.exit(1); |
|
}); |