diff --git a/README.md b/README.md index 1440390..8e5d05b 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Each skill contains: | Skill | Description | | --- | --- | | [typescript-sdk](./skills/typescript-sdk/SKILL.md) | Use when creating, modifying, or testing AI Agents built with the Inkeep TypeScript SDK (@inkeep/agents-sdk). | +| [pr-screenshots](./skills/pr-screenshots/SKILL.md) | Capture, annotate, and include screenshots in pull requests for UI changes. | --- diff --git a/skills/pr-screenshots/SKILL.md b/skills/pr-screenshots/SKILL.md new file mode 100644 index 0000000..10b8b9b --- /dev/null +++ b/skills/pr-screenshots/SKILL.md @@ -0,0 +1,188 @@ +--- +name: pr-screenshots +description: "Capture, annotate, and include screenshots in pull requests for UI changes. Use when creating or updating PRs that touch frontend components, pages, or any web-facing surface. Also use when asked to add before/after screenshots, visual diffs, or enrich PR descriptions. Triggers on: PR screenshots, before/after, visual diff, PR description, capture screenshot, PR images, enrich PR." +license: MIT +metadata: + author: "inkeep" + version: "1.0" +--- + +# PR Screenshots + +Capture, redact, annotate, and embed screenshots in GitHub PRs for UI changes. + +## When to use + +- Creating/updating PRs that touch frontend components, pages, or styles +- User asks for screenshots, before/after comparisons, or PR body enrichment +- Skip for backend-only, test-only, or non-visual changes + +## Prerequisites + +These scripts require the following npm packages. Install them as dev dependencies in your project: + +| Package | Purpose | Install | +|---|---|---| +| `playwright` | Browser automation for screenshot capture | `npm add -D playwright` | +| `sharp` | Image annotation (labels, borders, stitching) | `npm add -D sharp` | +| `tsx` | TypeScript runner for scripts | `npm add -D tsx` | + +After installing Playwright, download browser binaries: `npx playwright install chromium` + +## Workflow + +1. **Identify affected pages** from the PR diff +2. **Capture screenshots** — run `scripts/capture.ts` +3. **Validate no sensitive data** — run `scripts/validate-sensitive.ts` +4. **Annotate** — run `scripts/annotate.ts` (labels, borders, side-by-side) +5. **Upload & embed** — update PR body with images + +--- + +## Step 1: Identify Affected Pages + +Analyze the PR diff to determine which UI routes are impacted. Map changed component/page files to their corresponding URLs. If the diff only touches backend code, tests, or non-visual files, skip screenshot capture. + +--- + +## Step 2: Capture Screenshots + +### Environment setup + +| Environment | Base URL | Notes | +|---|---|---| +| **Local dev** | `http://localhost:3000` (or your dev server port) | Start your dev server first | +| **Preview deployment** | Your preview URL (e.g., Vercel, Netlify, etc.) | Available after PR push | +| **Playwright server** | Connect via `--connect ws://localhost:3001` | See "Reusable server" below | + +### Capture command + +```bash +# Local dev +npx tsx scripts/capture.ts \ + --base-url http://localhost:3000 \ + --routes "/dashboard,/settings" \ + --output-dir ./pr-screenshots + +# Preview deployment +npx tsx scripts/capture.ts \ + --base-url https://your-preview-url.example.com \ + --routes "/dashboard,/settings" \ + --output-dir ./pr-screenshots + +# With Playwright server (reuses browser across captures) +npx tsx scripts/capture.ts \ + --connect ws://localhost:3001 \ + --base-url http://localhost:3000 \ + --routes "/dashboard,/settings" \ + --output-dir ./pr-screenshots +``` + +### All capture options + +| Option | Default | Description | +|---|---|---| +| `--base-url ` | *required* | Target URL (local dev or preview) | +| `--routes ` | *required* | Comma-separated route paths | +| `--output-dir ` | `./pr-screenshots` | Where to save PNGs and DOM text | +| `--viewport ` | `1280x800` | Browser viewport size | +| `--connect ` | — | Connect to existing Playwright server | +| `--mask-selectors ` | — | Additional CSS selectors to blur | +| `--wait ` | `2000` | Wait after page load before capture | +| `--full-page` | `false` | Capture full scrollable page | +| `--auth-cookie ` | — | Session cookie for authenticated pages | + +### Reusable Playwright server + +Start a server once, reuse across multiple captures: + +```bash +# Terminal 1: start server +npx tsx scripts/capture.ts --serve --port 3001 + +# Terminal 2+: connect and capture +npx tsx scripts/capture.ts \ + --connect ws://localhost:3001 --base-url http://localhost:3000 \ + --routes "/..." --output-dir ./pr-screenshots +``` + +--- + +## Step 3: Validate Sensitive Data + +**Always run before uploading to GitHub.** + +```bash +npx tsx scripts/validate-sensitive.ts \ + --dir ./pr-screenshots +``` + +The script checks `.dom-text.txt` files (saved by capture) for: +- API keys (`sk-`, `sk-ant-`, `AKIA`, `sk_live_`) +- Tokens (Bearer, JWT, GitHub PATs) +- PEM private keys +- Connection strings with credentials + +Exit code 1 = sensitive data found. Re-capture with additional `--mask-selectors` or fix the source before proceeding. + +### Pre-capture masking (automatic) + +The capture script automatically masks these before taking screenshots: + +| Selector / Pattern | What it catches | +|---|---| +| `input[type="password"]` | Password fields | +| Text matching `sk-`, `Bearer`, `eyJ`, `ghp_`, PEM headers | In-page tokens/keys | + +Add more with `--mask-selectors "selector1,selector2"`. + +--- + +## Step 4: Annotate Images + +```bash +# Add "Before" label with red border +npx tsx scripts/annotate.ts \ + --input before.png --label "Before" --border "#ef4444" --output before-labeled.png + +# Add "After" label with green border +npx tsx scripts/annotate.ts \ + --input after.png --label "After" --border "#22c55e" --output after-labeled.png + +# Side-by-side comparison +npx tsx scripts/annotate.ts \ + --stitch before.png after.png --labels "Before,After" --output comparison.png +``` + +--- + +## Step 5: Upload & Embed in PR + +### Upload images to GitHub + +Images in PR markdown need permanent URLs. Use one of: + +**Option A — PR comment with image** (simplest): +```bash +# GitHub renders attached images with permanent CDN URLs +gh pr comment {pr-number} --body "![Before](./pr-screenshots/before-labeled.png)" +``` + +**Option B — Update PR body directly**: +```bash +gh pr edit {pr-number} --body "$(cat pr-body.md)" +``` + +### PR body templates + +Use the templates in [references/pr-templates.md](references/pr-templates.md) for consistent formatting. Include: + +1. **Visual Changes** section with before/after screenshots +2. **Test URLs** section with links to preview deployment pages +3. **Summary** of what changed and why + +--- + +## Additional Resources + +- [references/pr-templates.md](references/pr-templates.md) — PR body markdown templates diff --git a/skills/pr-screenshots/references/pr-templates.md b/skills/pr-screenshots/references/pr-templates.md new file mode 100644 index 0000000..4a8dbca --- /dev/null +++ b/skills/pr-screenshots/references/pr-templates.md @@ -0,0 +1,153 @@ +# PR Body Templates + +Markdown templates for enriching PR descriptions with screenshots and preview links. + +## Template 1: Visual Changes (Before/After) + +Use for PRs that change UI appearance or behavior. + +```markdown +### Visual Changes + +| Before | After | +|--------|-------| +| ![Before - {page name}]({before-image-url}) | ![After - {page name}]({after-image-url}) | + +> Screenshots captured from {environment} +``` + +## Template 2: Visual Changes (Side-by-Side Comparison) + +Use when the before/after comparison is generated as a single stitched image. + +```markdown +### Visual Changes + +![{page name} - Before vs After]({comparison-image-url}) +``` + +## Template 3: Test URLs + +Include links to preview deployment pages for manual testing. + +```markdown +### Test URLs + +Test these pages on the preview deployment: + +- [{Page name}]({preview-url}/{route}) — {what to verify} +- [{Page name}]({preview-url}/{route}) — {what to verify} +``` + +## Template 4: Combined (Recommended) + +Full PR body template with all sections. + +```markdown +### Changes + +- {Change 1} +- {Change 2} +- {Change 3} + +### Visual Changes + +| Before | After | +|--------|-------| +| ![Before - {page}]({url}) | ![After - {page}]({url}) | + +### Test URLs + +- [{Page name}]({preview-url}) — {what to test} +- [{Page name}]({preview-url}) — {what to test} + +### Test Plan + +- [ ] {Test case 1} +- [ ] {Test case 2} +``` + +## Template 5: Video Demo + +Use when a screen recording is more appropriate than static screenshots (e.g., interaction flows, animations, drag-and-drop behavior). + +```markdown +### Demo + +
+Screen recording + +https://github.com/user-attachments/assets/{video-id} + +
+``` + +To upload a video: +1. Record with QuickTime or `screencapture -v recording.mov` (macOS) +2. Drag the `.mov` file into the GitHub PR comment editor +3. GitHub generates a permanent URL automatically + +## Template 6: Multiple Pages Affected + +Use when a change affects several different pages. + +```markdown +### Visual Changes + +#### {Page 1 name} +| Before | After | +|--------|-------| +| ![Before]({url}) | ![After]({url}) | + +#### {Page 2 name} +| Before | After | +|--------|-------| +| ![Before]({url}) | ![After]({url}) | + +### Test URLs + +| Page | URL | What to verify | +|------|-----|----------------| +| {Page 1} | [{link text}]({url}) | {verification steps} | +| {Page 2} | [{link text}]({url}) | {verification steps} | +``` + +## Image Upload Methods + +### Method A: Drag and drop (simplest) + +1. Edit the PR description on GitHub +2. Drag a PNG/GIF/MOV file into the text area +3. GitHub uploads it and inserts a markdown image link +4. Save + +### Method B: gh CLI comment + +```bash +# Post a comment with an image reference +gh pr comment {pr-number} --body "### Screenshot +![Description](image-url)" +``` + +### Method C: Update PR body programmatically + +```bash +# Read current PR body, append visual changes section +CURRENT_BODY=$(gh pr view {pr-number} --json body -q '.body') +NEW_BODY="${CURRENT_BODY} + +### Visual Changes +| Before | After | +|--------|-------| +| ![Before](url1) | ![After](url2) |" + +gh pr edit {pr-number} --body "$NEW_BODY" +``` + +## Notes + +- GitHub image URLs from drag-and-drop are permanent CDN links +- GitHub supports PNG, GIF, JPG, and MOV/MP4 uploads +- Maximum file size: 10MB for images, 100MB for videos (on paid plans) +- Always add descriptive alt text for accessibility +- Use `
` tags for large images or videos to keep the PR body scannable diff --git a/skills/pr-screenshots/scripts/annotate.ts b/skills/pr-screenshots/scripts/annotate.ts new file mode 100644 index 0000000..4fae22d --- /dev/null +++ b/skills/pr-screenshots/scripts/annotate.ts @@ -0,0 +1,180 @@ +/** + * PR Screenshot Annotation Script + * + * Adds labels, colored borders, and creates side-by-side comparisons. + * + * Label mode: + * npx tsx annotate.ts --input before.png --label "Before" --border "#ef4444" --output labeled.png + * + * Stitch mode: + * npx tsx annotate.ts --stitch before.png after.png --labels "Before,After" --output comparison.png + */ + +import sharp from 'sharp'; + +function getArg(name: string): string | undefined { + const idx = process.argv.indexOf(`--${name}`); + return idx !== -1 && idx + 1 < process.argv.length ? process.argv[idx + 1] : undefined; +} + +function getMultiArg(name: string): string[] { + const idx = process.argv.indexOf(`--${name}`); + if (idx === -1) return []; + const values: string[] = []; + for (let i = idx + 1; i < process.argv.length; i++) { + if (process.argv[i].startsWith('--')) break; + values.push(process.argv[i]); + } + return values; +} + +function escapeXml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +async function addLabel(inputPath: string, label: string, borderColor: string, outputPath: string) { + const metadata = await sharp(inputPath).metadata(); + const width = metadata.width || 800; + + const borderWidth = 3; + const labelHeight = 36; + const fontSize = 16; + const escapedLabel = escapeXml(label); + + const labelSvg = Buffer.from(` + + + + ${escapedLabel} + + + `); + + await sharp(inputPath) + .extend({ + top: labelHeight, + bottom: borderWidth, + left: borderWidth, + right: borderWidth, + background: borderColor, + }) + .composite([ + { + input: labelSvg, + top: 0, + left: borderWidth, + }, + ]) + .png() + .toFile(outputPath); + + console.log(`Labeled: ${outputPath}`); +} + +async function stitchImages(inputPaths: string[], labels: string[], outputPath: string) { + if (inputPaths.length !== 2) { + throw new Error('Stitch requires exactly 2 images'); + } + + const gap = 16; + const labelHeight = 36; + const fontSize = 16; + const colors = ['#ef4444', '#22c55e']; + + const images = await Promise.all( + inputPaths.map(async (p) => { + const meta = await sharp(p).metadata(); + return { path: p, width: meta.width || 800, height: meta.height || 600 }; + }) + ); + + const maxHeight = Math.max(...images.map((i) => i.height)); + const totalWidth = images.reduce((sum, i) => sum + i.width, 0) + gap; + + const labelSvgs = images.map((img, i) => { + const escapedLabel = escapeXml(labels[i] || (i === 0 ? 'Before' : 'After')); + return Buffer.from(` + + + + ${escapedLabel} + + + `); + }); + + const imageBuffers = await Promise.all(inputPaths.map((p) => sharp(p).toBuffer())); + + let xOffset = 0; + const composites: sharp.OverlayOptions[] = []; + + for (let i = 0; i < images.length; i++) { + composites.push({ + input: labelSvgs[i], + top: 0, + left: xOffset, + }); + composites.push({ + input: imageBuffers[i], + top: labelHeight, + left: xOffset, + }); + xOffset += images[i].width + gap; + } + + await sharp({ + create: { + width: totalWidth, + height: maxHeight + labelHeight, + channels: 4, + background: { r: 245, g: 245, b: 245, alpha: 1 }, + }, + }) + .composite(composites) + .png() + .toFile(outputPath); + + console.log(`Stitched: ${outputPath}`); +} + +async function main() { + const inputPath = getArg('input'); + const label = getArg('label'); + const borderColor = getArg('border') || '#6b7280'; + const outputPath = getArg('output'); + const stitchPaths = getMultiArg('stitch'); + const labelsStr = getArg('labels'); + + if (stitchPaths.length === 2 && outputPath) { + const labels = labelsStr ? labelsStr.split(',').map((l) => l.trim()) : ['Before', 'After']; + await stitchImages(stitchPaths, labels, outputPath); + } else if (inputPath && outputPath) { + await addLabel(inputPath, label || 'Screenshot', borderColor, outputPath); + } else { + console.error('Usage:'); + console.error( + ' Label: npx tsx annotate.ts --input --label --border --output ' + ); + console.error( + ' Stitch: npx tsx annotate.ts --stitch --labels "Before,After" --output ' + ); + process.exit(1); + } +} + +main().catch((err) => { + console.error('Annotate failed:', err); + process.exit(1); +}); diff --git a/skills/pr-screenshots/scripts/capture.ts b/skills/pr-screenshots/scripts/capture.ts new file mode 100644 index 0000000..81867c3 --- /dev/null +++ b/skills/pr-screenshots/scripts/capture.ts @@ -0,0 +1,210 @@ +/** + * PR Screenshot Capture Script + * + * Captures screenshots of UI pages with automatic sensitive data masking. + * Supports local dev servers, preview deployments, and reusable Playwright servers. + * + * Usage: + * npx tsx scripts/capture.ts \ + * --base-url http://localhost:3000 \ + * --routes "/dashboard,/settings" \ + * --output-dir ./pr-screenshots + * + * Playwright server mode: + * npx tsx scripts/capture.ts --serve --port 3001 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { type Browser, chromium } from 'playwright'; + +function getArg(name: string): string | undefined { + const idx = process.argv.indexOf(`--${name}`); + return idx !== -1 && idx + 1 < process.argv.length ? process.argv[idx + 1] : undefined; +} + +function hasFlag(name: string): boolean { + return process.argv.includes(`--${name}`); +} + +const MASKING_CSS = ` + input[type="password"] { + -webkit-text-security: disc !important; + color: transparent !important; + text-shadow: 0 0 8px rgba(0,0,0,0.5) !important; + } +`; + +const MASKING_JS = `(() => { + // Mask password inputs + document.querySelectorAll('input[type="password"]').forEach(el => { + el.value = '••••••••'; + }); + + // Walk text nodes and redact sensitive patterns + const sensitivePatterns = [ + /sk-[a-zA-Z0-9]{20,}/g, + /sk-ant-[a-zA-Z0-9-]{20,}/g, + /sk_live_[a-zA-Z0-9]{20,}/g, + /Bearer\\s+[a-zA-Z0-9._-]{20,}/g, + /gh[pos]_[a-zA-Z0-9]{36}/g, + /AKIA[A-Z0-9]{16}/g, + /eyJ[a-zA-Z0-9_-]{50,}\\.[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]+/g, + /-----BEGIN[A-Z ]*PRIVATE KEY-----/g, + /postgresql:\\/\\/[^\\s]+:[^\\s]+@/g, + ]; + + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null); + let node; + while (node = walker.nextNode()) { + let text = node.textContent || ''; + let changed = false; + for (const pattern of sensitivePatterns) { + pattern.lastIndex = 0; + if (pattern.test(text)) { + pattern.lastIndex = 0; + text = text.replace(pattern, '[REDACTED]'); + changed = true; + } + } + if (changed) { + node.textContent = text; + } + } +})()`; + +async function startServer(port: number) { + const server = await chromium.launchServer({ + port, + headless: true, + }); + console.log(`Playwright server started at: ${server.wsEndpoint()}`); + console.log('Press Ctrl+C to stop.'); + process.on('SIGINT', async () => { + await server.close(); + process.exit(0); + }); +} + +async function capture() { + const baseUrl = getArg('base-url'); + const routesStr = getArg('routes'); + const outputDir = getArg('output-dir') || './pr-screenshots'; + const viewport = getArg('viewport') || '1280x800'; + const connectUrl = getArg('connect'); + const extraMaskSelectors = getArg('mask-selectors'); + const waitMs = Number.parseInt(getArg('wait') || '2000', 10); + const fullPage = hasFlag('full-page'); + const authCookie = getArg('auth-cookie'); + + if (!baseUrl || !routesStr) { + console.error( + 'Usage: npx tsx capture.ts --base-url --routes [options]\n' + ); + console.error('Options:'); + console.error(' --output-dir Output directory (default: ./pr-screenshots)'); + console.error(' --viewport Viewport size (default: 1280x800)'); + console.error(' --connect Connect to existing Playwright server'); + console.error(' --mask-selectors Additional CSS selectors to blur (comma-separated)'); + console.error(' --wait Wait after page load (default: 2000)'); + console.error(' --full-page Capture full page screenshot'); + console.error(' --auth-cookie Set session cookie for auth'); + console.error('\nServer mode:'); + console.error(' --serve Start a reusable Playwright server'); + console.error(' --port Server port (default: 3001)'); + process.exit(1); + } + + const routes = routesStr.split(',').map((r) => r.trim()); + const [vw, vh] = viewport.split('x').map(Number); + + fs.mkdirSync(outputDir, { recursive: true }); + + let fullMaskingCss = MASKING_CSS; + if (extraMaskSelectors) { + const selectors = extraMaskSelectors.split(',').map((s) => s.trim()); + fullMaskingCss += selectors.map((s) => `\n ${s} { filter: blur(5px) !important; }`).join(''); + } + + let browser: Browser; + let isConnected = false; + + if (connectUrl) { + console.log(`Connecting to Playwright server at ${connectUrl}`); + browser = await chromium.connect(connectUrl); + isConnected = true; + } else { + console.log('Launching browser...'); + browser = await chromium.launch({ headless: true }); + } + + try { + const context = await browser.newContext({ + viewport: { width: vw, height: vh }, + }); + + if (authCookie) { + const url = new URL(baseUrl); + await context.addCookies([ + { + name: 'session', + value: authCookie, + domain: url.hostname, + path: '/', + }, + ]); + } + + const page = await context.newPage(); + + for (const route of routes) { + const url = `${baseUrl.replace(/\/$/, '')}${route}`; + const safeName = route.replace(/^\//, '').replace(/\//g, '-') || 'index'; + + console.log(`\nCapturing: ${url}`); + + try { + await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }); + } catch { + console.log(' networkidle timed out, proceeding with domcontentloaded...'); + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); + } + + await page.waitForTimeout(waitMs); + + await page.addStyleTag({ content: fullMaskingCss }); + await page.evaluate(MASKING_JS); + await page.waitForTimeout(500); + + const screenshotPath = path.join(outputDir, `${safeName}.png`); + await page.screenshot({ path: screenshotPath, fullPage }); + console.log(` Screenshot: ${screenshotPath}`); + + const domText = await page.evaluate(() => document.body.innerText); + const textPath = path.join(outputDir, `${safeName}.dom-text.txt`); + fs.writeFileSync(textPath, domText, 'utf-8'); + console.log(` DOM text: ${textPath}`); + } + + await context.close(); + console.log(`\nDone. ${routes.length} screenshot(s) saved to ${outputDir}`); + } finally { + if (!isConnected) { + await browser.close(); + } + } +} + +async function main() { + if (hasFlag('serve')) { + const port = Number.parseInt(getArg('port') || '3001', 10); + await startServer(port); + } else { + await capture(); + } +} + +main().catch((err) => { + console.error('Capture failed:', err); + process.exit(1); +}); diff --git a/skills/pr-screenshots/scripts/validate-sensitive.ts b/skills/pr-screenshots/scripts/validate-sensitive.ts new file mode 100644 index 0000000..7a8569e --- /dev/null +++ b/skills/pr-screenshots/scripts/validate-sensitive.ts @@ -0,0 +1,148 @@ +/** + * Pre-upload Sensitive Data Validation + * + * Scans DOM text files (produced by capture.ts) for patterns that indicate + * sensitive data may have leaked through masking. Must pass before uploading + * screenshots to GitHub. + * + * Usage: + * npx tsx validate-sensitive.ts --dir ./pr-screenshots + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +function getArg(name: string): string | undefined { + const idx = process.argv.indexOf(`--${name}`); + return idx !== -1 && idx + 1 < process.argv.length ? process.argv[idx + 1] : undefined; +} + +interface SensitivePattern { + name: string; + pattern: RegExp; + severity: 'critical' | 'warning'; +} + +const SENSITIVE_PATTERNS: SensitivePattern[] = [ + // Critical — real secrets + { name: 'OpenAI API key', pattern: /sk-[a-zA-Z0-9]{20,}/g, severity: 'critical' }, + { name: 'Anthropic API key', pattern: /sk-ant-[a-zA-Z0-9-]{20,}/g, severity: 'critical' }, + { name: 'Stripe secret key', pattern: /sk_live_[a-zA-Z0-9]{20,}/g, severity: 'critical' }, + { name: 'AWS access key', pattern: /AKIA[A-Z0-9]{16}/g, severity: 'critical' }, + { name: 'GitHub PAT (classic)', pattern: /ghp_[a-zA-Z0-9]{36}/g, severity: 'critical' }, + { name: 'GitHub OAuth token', pattern: /gho_[a-zA-Z0-9]{36}/g, severity: 'critical' }, + { name: 'GitHub App token', pattern: /ghs_[a-zA-Z0-9]{36}/g, severity: 'critical' }, + { name: 'PEM private key', pattern: /-----BEGIN[A-Z ]*PRIVATE KEY-----/g, severity: 'critical' }, + { + name: 'DB connection string with password', + pattern: /postgresql:\/\/[^\s:]+:[^\s@]+@/g, + severity: 'critical', + }, + { + name: 'Bearer token (long)', + pattern: /Bearer\s+[a-zA-Z0-9._-]{40,}/g, + severity: 'critical', + }, + + // Warning — might be sensitive + { + name: 'JWT token', + pattern: /eyJ[a-zA-Z0-9_-]{30,}\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g, + severity: 'warning', + }, + { + name: 'Bearer token (short)', + pattern: /Bearer\s+[a-zA-Z0-9._-]{20,39}/g, + severity: 'warning', + }, + { + name: 'Generic secret in assignment', + pattern: /(?:secret|password|token|api_key|apikey)\s*[:=]\s*["'][^"']{8,}["']/gi, + severity: 'warning', + }, +]; + +function scanFile(filePath: string): { critical: string[]; warnings: string[] } { + const content = fs.readFileSync(filePath, 'utf-8'); + const critical: string[] = []; + const warnings: string[] = []; + + for (const { name, pattern, severity } of SENSITIVE_PATTERNS) { + pattern.lastIndex = 0; + const matches = content.match(pattern); + if (matches) { + const msg = `${name}: ${matches.length} occurrence(s)`; + if (severity === 'critical') { + critical.push(msg); + } else { + warnings.push(msg); + } + } + } + + return { critical, warnings }; +} + +function main() { + const dir = getArg('dir') || './pr-screenshots'; + + if (!fs.existsSync(dir)) { + console.error(`Directory not found: ${dir}`); + process.exit(1); + } + + const textFiles = fs.readdirSync(dir).filter((f) => f.endsWith('.dom-text.txt')); + + if (textFiles.length === 0) { + console.log('No .dom-text.txt files found. Run capture.ts first.'); + process.exit(0); + } + + let hasCritical = false; + let hasWarnings = false; + + for (const file of textFiles) { + const filePath = path.join(dir, file); + const { critical, warnings } = scanFile(filePath); + + if (critical.length > 0) { + console.error(`\n\u274C CRITICAL in ${file}:`); + for (const msg of critical) { + console.error(` ${msg}`); + } + hasCritical = true; + } + + if (warnings.length > 0) { + console.warn(`\n\u26A0\uFE0F WARNING in ${file}:`); + for (const msg of warnings) { + console.warn(` ${msg}`); + } + hasWarnings = true; + } + + if (critical.length === 0 && warnings.length === 0) { + console.log(`\u2713 ${file}: clean`); + } + } + + console.log(''); + + if (hasCritical) { + console.error('\u274C Sensitive data detected. Do NOT upload these screenshots to GitHub.'); + console.error( + 'Re-capture with additional --mask-selectors or manually redact before uploading.' + ); + process.exit(1); + } + + if (hasWarnings) { + console.warn('\u26A0\uFE0F Warnings found. Review the flagged content before uploading.'); + console.warn('These may be false positives. Use judgment before proceeding.'); + process.exit(0); + } + + console.log('\u2705 All files clean. Safe to upload.'); +} + +main();