shots capture: signed-in web apps (shots.capture.auth request with ${cred:}/${env:} references or storageState), shots login saves a demo sign-in to .storebound/auth.json (0600), expired-session warning, secrets and cookies masked
This commit is contained in:
parent
3949ff1531
commit
e1e4e05237
15 changed files with 545 additions and 9 deletions
72
cli/shots/login.ts
Normal file
72
cli/shots/login.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue