#!/usr/bin/env node /** * Capture a Playwright storageState for https://coulomb.social after you log in. * * Default: headed browser — you complete login, then press Enter in this terminal. * Optional: --from-password-files reads EMAIL/PASSWORD from XDG auth dir (OpenBao-exported). * * Never prints secret values. Writes mode-0600 storage-state.json only. */ import { chromium } from "playwright"; import { mkdirSync, writeFileSync, chmodSync, existsSync, readFileSync, statSync, } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; const BASE_URL = process.env.COULOMB_SOCIAL_URL || "https://coulomb.social"; const AUTH_DIR = process.env.COULOMB_SOCIAL_AUTH_DIR || join(homedir(), ".config", "coulomb-social", "auth"); const STATE_PATH = join(AUTH_DIR, "storage-state.json"); const EMAIL_PATH = join(AUTH_DIR, "email"); const PASSWORD_PATH = join(AUTH_DIR, "password"); const fromPasswordFiles = process.argv.includes("--from-password-files"); const headless = process.argv.includes("--headless"); function ensureAuthDir() { mkdirSync(AUTH_DIR, { recursive: true, mode: 0o700 }); try { chmodSync(AUTH_DIR, 0o700); } catch { /* best effort */ } } function readSecretFile(path) { if (!existsSync(path)) { throw new Error(`Missing secret file: ${path}`); } const mode = statSync(path).mode & 0o777; if (mode & 0o077) { console.warn( `warning: ${path} is group/world-readable (mode ${mode.toString(8)}); chmod 600 recommended`, ); } return readFileSync(path, "utf8").trim(); } async function waitForUserContinue() { const rl = createInterface({ input, output }); console.log(""); console.log(">>> Log in as your coulomb.social user in the browser window."); console.log(">>> Navigate to an authenticated page you want designlang to see."); console.log(">>> Then return here and press Enter to save the session."); console.log(""); await rl.question("Press Enter when logged in… "); rl.close(); } async function tryPasswordLogin(page, email, password) { // Best-effort Bubble-style login. Interactive capture is preferred when MFA/CAPTCHA appears. await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: 60_000 }); await page.waitForTimeout(1500); const emailBox = (await page.$('input[type="email"]')) || (await page.$('input[name*="email" i]')) || (await page.$('input[placeholder*="email" i]')) || (await page.$('input[placeholder*="Email" i]')); const passBox = (await page.$('input[type="password"]')) || (await page.$('input[name*="password" i]')); if (!emailBox || !passBox) { throw new Error( "Could not find email/password fields automatically. Re-run without --from-password-files and log in interactively.", ); } await emailBox.fill(email); await passBox.fill(password); const submit = (await page.$('button[type="submit"]')) || (await page.$('input[type="submit"]')) || (await page.$('button:has-text("Log in")')) || (await page.$('button:has-text("Login")')) || (await page.$('button:has-text("Sign in")')); if (submit) { await submit.click(); } else { await passBox.press("Enter"); } await page.waitForTimeout(4000); console.log("Password login attempt finished (no secrets printed)."); console.log("If MFA/CAPTCHA is required, re-run in interactive mode."); } async function main() { ensureAuthDir(); console.log(`Auth dir: ${AUTH_DIR}`); console.log(`Target: ${BASE_URL}`); console.log(`State → ${STATE_PATH}`); const browser = await chromium.launch({ headless: Boolean(headless && fromPasswordFiles), channel: process.env.COULOMB_SOCIAL_CHROME_CHANNEL || undefined, }); const context = await browser.newContext({ viewport: { width: 1280, height: 900 }, }); const page = await context.newPage(); try { if (fromPasswordFiles) { const email = readSecretFile(EMAIL_PATH); const password = readSecretFile(PASSWORD_PATH); await tryPasswordLogin(page, email, password); } else { await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: 60_000, }); await waitForUserContinue(); } const state = await context.storageState(); const cookieCount = state.cookies?.length ?? 0; const originCount = state.origins?.length ?? 0; const lsKeys = (state.origins || []).flatMap((o) => (o.localStorage || []).map((e) => e.name), ); // Bubble often keeps the same coulomb_* cookie *names* for guests and // members — names alone are not a login signal. Probe the live page: // authenticated app usually leaves the bare landing path. let probeUrl = page.url(); let probeTitle = await page.title().catch(() => ""); const path = (() => { try { return new URL(probeUrl).pathname; } catch { return ""; } })(); const looksLikeLanding = path === "/" || path === ""; if (looksLikeLanding && !fromPasswordFiles) { console.error(""); console.error( "WARNING: Still on the site root after capture — login may not have completed.", ); console.error(` url: ${probeUrl}`); console.error(` title: ${probeTitle}`); console.error( "Log in fully, open an app page (e.g. Research / Pages), then press Enter.", ); // Continue to save anyway — Bubble guest+member share cookie names; user // may still have a valid session that only reveals itself after navigation. } writeFileSync(STATE_PATH, JSON.stringify(state, null, 2) + "\n", { mode: 0o600, }); chmodSync(STATE_PATH, 0o600); console.log(""); console.log( `Saved storageState (${cookieCount} cookies, ${originCount} origins, localStorage keys: ${lsKeys.length}).`, ); console.log(` page at save: ${probeUrl} — ${probeTitle}`); console.log(`File: ${STATE_PATH}`); console.log("Next: ./scripts/run-design-extract.sh"); } finally { await browser.close(); } } main().catch((err) => { console.error(err.message || err); process.exit(1); });