storebound/cli/shots/login.ts

72 lines
5.1 KiB
TypeScript

// storebound shots login [--url <url>] [--out .storebound/auth.json] [--timeout 900]
// Opens a visible browser with a fresh, empty profile at shots.capture.baseUrl (or --url) so the owner signs in to a
// DEMO account by hand (storebound never types credentials). Press Enter in the terminal, or close the window, and the
// Playwright storage state (cookies + localStorage) is written to .storebound/auth.json (0600) for
// shots.capture.auth.storageState. Only counts are printed, never cookie values. The wait has a timeout (default
// 15 min) and a heartbeat line every 30 s.
import { chmodSync, mkdirSync, writeFileSync } from 'node:fs';
import { dirname, relative } from 'node:path';
import type { Ctx } from '../lib/ctx';
import { done, log, warn } from '../lib/log';
import { DEFAULT_STATE, safeUrl } from './auth';
import { closeBrowser, launch } from './browser';
export interface StorageState { cookies: { name: string; value: string; domain: string }[]; origins: { origin: string; localStorage: unknown[] }[] }
/** What the confirmation says (counts and domains, never values). */
export function stateSummary(s: StorageState): string {
const domains = [...new Set(s.cookies.map(c => c.domain.replace(/^\./, '')))].sort();
return `${s.cookies.length} cookie${s.cookies.length === 1 ? '' : 's'}${domains.length ? ` (${domains.join(', ')})` : ''}, ${s.origins.length} origin${s.origins.length === 1 ? '' : 's'} with local storage`;
}
export function writeState(file: string, s: StorageState): void {
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, JSON.stringify(s, null, 1) + '\n', { mode: 0o600 });
chmodSync(file, 0o600);
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const a = ctx.args;
const url = a.get('url') ?? config.shots?.capture?.baseUrl;
if (!url || !/^https?:\/\//.test(url)) throw new Error('shots login: --url <http(s) URL>, or shots.capture.baseUrl in the config');
const outRel = a.get('out') ?? config.shots?.capture?.auth?.storageState ?? DEFAULT_STATE;
if (!/^\.storebound[\\/]/.test(outRel)) throw new Error('shots login --out: a file in .storebound/ (gitignored), e.g. .storebound/auth.json');
const out = resolve(outRel);
const timeoutMs = a.num('timeout', 900) * 1000;
// hidden, for tests: headless (no window) and Enter on stdin still ends it
const headless = a.has('headless');
if (a.has('dry')) return done('shots', `would open ${safeUrl(url)} in a fresh browser profile; after you sign in to a demo account, Enter saves ${outRel} (0600)`);
const browser = await launch(root, { headless, args: ['--mute-audio'] });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let context: any;
let last: StorageState | null = null;
try {
context = await browser.newContext({ viewport: null, serviceWorkers: 'block' });
const page = await context.newPage();
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 }).catch((e: Error) => warn('shots', `could not open ${safeUrl(url)}: ${e.message.split('\n')[0]}`));
log('shots', `opened ${safeUrl(url)} in a fresh browser profile. Sign in to a DEMO account (fabricated data, never a real user's) by hand, then press Enter here (or close the window). Timeout ${Math.round(timeoutMs / 1000)} s.`);
const started = Date.now();
const snapshot = async (): Promise<void> => { try { last = await context.storageState() as StorageState; } catch { /* context gone */ } };
const reason = await new Promise<'enter' | 'closed' | 'timeout'>(res => {
const finish = (r: 'enter' | 'closed' | 'timeout'): void => { clearInterval(beat); clearInterval(snap); clearTimeout(t); process.stdin.off('data', onData); process.stdin.pause(); res(r); };
const onData = (b: Buffer): void => { if (b.toString().includes('\n')) finish('enter'); };
process.stdin.on('data', onData); process.stdin.resume();
const snap = setInterval(() => { void snapshot(); }, 2000);
const beat = setInterval(() => log('shots', `waiting for the sign-in (${Math.round((Date.now() - started) / 1000)} s of ${Math.round(timeoutMs / 1000)} s): press Enter when signed in`), 30_000);
const t = setTimeout(() => finish('timeout'), timeoutMs);
page.on('close', () => { if (!context.pages().length) finish('closed'); });
browser.on('disconnected', () => finish('closed'));
});
if (reason === 'timeout') throw new Error(`shots login: no Enter within ${Math.round(timeoutMs / 1000)} s; nothing saved (--timeout <seconds>)`);
if (reason === 'enter') await snapshot();
const state = last as StorageState | null;
if (!state) throw new Error('shots login: the browser closed before its sign-in state could be read; run it again and press Enter before closing the window');
writeState(out, state);
if (!state.cookies.length && !state.origins.length) warn('shots', 'no cookies and no local storage were saved: was the sign-in finished?');
done('shots', `saved ${relative(root, out) || out} (0600): ${stateSummary(state)}. Set shots.capture.auth = { storageState: "${outRel}" }`);
} finally {
await closeBrowser(browser);
}
}