Session probe lands on /vw_pages (Research). Use main designlang extract with --cookie-file — pack ignores auth. 39 artifacts under docs/design-extract; tolerate designlang late summary crash when cores exist.
194 lines
6.1 KiB
JavaScript
Executable file
194 lines
6.1 KiB
JavaScript
Executable file
#!/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);
|
|
});
|