@juangadm/pre-post 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +102 -240
  2. package/dist/bin/cli.js +200 -454
  3. package/dist/bin/cli.js.map +1 -1
  4. package/dist/browser.d.ts +42 -27
  5. package/dist/browser.d.ts.map +1 -1
  6. package/dist/browser.js +358 -105
  7. package/dist/browser.js.map +1 -1
  8. package/dist/capture.d.ts +1 -6
  9. package/dist/capture.d.ts.map +1 -1
  10. package/dist/capture.js +12 -36
  11. package/dist/capture.js.map +1 -1
  12. package/dist/commands/compare.d.ts +18 -0
  13. package/dist/commands/compare.d.ts.map +1 -0
  14. package/dist/commands/compare.js +64 -0
  15. package/dist/commands/compare.js.map +1 -0
  16. package/dist/commands/detect.d.ts +20 -0
  17. package/dist/commands/detect.d.ts.map +1 -0
  18. package/dist/commands/detect.js +19 -0
  19. package/dist/commands/detect.js.map +1 -0
  20. package/dist/commands/doctor.d.ts +10 -0
  21. package/dist/commands/doctor.d.ts.map +1 -0
  22. package/dist/commands/doctor.js +38 -0
  23. package/dist/commands/doctor.js.map +1 -0
  24. package/dist/commands/login.d.ts +6 -0
  25. package/dist/commands/login.d.ts.map +1 -0
  26. package/dist/commands/login.js +34 -0
  27. package/dist/commands/login.js.map +1 -0
  28. package/dist/commands/pr.d.ts +28 -0
  29. package/dist/commands/pr.d.ts.map +1 -0
  30. package/dist/commands/pr.js +159 -0
  31. package/dist/commands/pr.js.map +1 -0
  32. package/dist/commands/prune.d.ts +10 -0
  33. package/dist/commands/prune.d.ts.map +1 -0
  34. package/dist/commands/prune.js +13 -0
  35. package/dist/commands/prune.js.map +1 -0
  36. package/dist/config.d.ts +36 -0
  37. package/dist/config.d.ts.map +1 -0
  38. package/dist/config.js +60 -0
  39. package/dist/config.js.map +1 -0
  40. package/dist/diff-pool.d.ts +17 -0
  41. package/dist/diff-pool.d.ts.map +1 -0
  42. package/dist/diff-pool.js +89 -0
  43. package/dist/diff-pool.js.map +1 -0
  44. package/dist/diff-worker.d.ts +29 -0
  45. package/dist/diff-worker.d.ts.map +1 -0
  46. package/dist/diff-worker.js +38 -0
  47. package/dist/diff-worker.js.map +1 -0
  48. package/dist/diff.d.ts +37 -0
  49. package/dist/diff.d.ts.map +1 -0
  50. package/dist/diff.js +171 -0
  51. package/dist/diff.js.map +1 -0
  52. package/dist/doctor.d.ts +38 -0
  53. package/dist/doctor.d.ts.map +1 -0
  54. package/dist/doctor.js +72 -0
  55. package/dist/doctor.js.map +1 -0
  56. package/dist/errors.d.ts +38 -0
  57. package/dist/errors.d.ts.map +1 -0
  58. package/dist/errors.js +59 -0
  59. package/dist/errors.js.map +1 -0
  60. package/dist/git.d.ts +23 -0
  61. package/dist/git.d.ts.map +1 -0
  62. package/dist/git.js +86 -0
  63. package/dist/git.js.map +1 -0
  64. package/dist/github.d.ts +73 -0
  65. package/dist/github.d.ts.map +1 -0
  66. package/dist/github.js +240 -0
  67. package/dist/github.js.map +1 -0
  68. package/dist/index.d.ts +22 -23
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +17 -67
  71. package/dist/index.js.map +1 -1
  72. package/dist/pkg.d.ts +8 -0
  73. package/dist/pkg.d.ts.map +1 -0
  74. package/dist/pkg.js +35 -0
  75. package/dist/pkg.js.map +1 -0
  76. package/dist/report.d.ts +20 -0
  77. package/dist/report.d.ts.map +1 -0
  78. package/dist/report.js +107 -0
  79. package/dist/report.js.map +1 -0
  80. package/dist/routes/imports.d.ts +46 -0
  81. package/dist/routes/imports.d.ts.map +1 -0
  82. package/dist/routes/imports.js +208 -0
  83. package/dist/routes/imports.js.map +1 -0
  84. package/dist/routes/vite.d.ts +15 -0
  85. package/dist/routes/vite.d.ts.map +1 -0
  86. package/dist/routes/vite.js +130 -0
  87. package/dist/routes/vite.js.map +1 -0
  88. package/dist/routes.d.ts +41 -14
  89. package/dist/routes.d.ts.map +1 -1
  90. package/dist/routes.js +271 -141
  91. package/dist/routes.js.map +1 -1
  92. package/dist/run.d.ts +36 -0
  93. package/dist/run.d.ts.map +1 -0
  94. package/dist/run.js +128 -0
  95. package/dist/run.js.map +1 -0
  96. package/dist/sessions.d.ts +50 -0
  97. package/dist/sessions.d.ts.map +1 -0
  98. package/dist/sessions.js +74 -0
  99. package/dist/sessions.js.map +1 -0
  100. package/dist/types.d.ts +118 -69
  101. package/dist/types.d.ts.map +1 -1
  102. package/dist/types.js +1 -0
  103. package/dist/types.js.map +1 -1
  104. package/dist/viewport.d.ts +6 -1
  105. package/dist/viewport.d.ts.map +1 -1
  106. package/dist/viewport.js +13 -7
  107. package/dist/viewport.js.map +1 -1
  108. package/package.json +21 -14
  109. package/skill/SKILL.md +31 -229
  110. package/dist/clipboard.d.ts +0 -9
  111. package/dist/clipboard.d.ts.map +0 -1
  112. package/dist/clipboard.js +0 -26
  113. package/dist/clipboard.js.map +0 -1
  114. package/dist/upload.d.ts +0 -39
  115. package/dist/upload.d.ts.map +0 -1
  116. package/dist/upload.js +0 -159
  117. package/dist/upload.js.map +0 -1
  118. package/dist/video.d.ts +0 -19
  119. package/dist/video.d.ts.map +0 -1
  120. package/dist/video.js +0 -263
  121. package/dist/video.js.map +0 -1
  122. package/skill/scripts/adapters/0x0st.sh +0 -36
  123. package/skill/scripts/adapters/blob.sh +0 -55
  124. package/skill/scripts/adapters/git-native.sh +0 -51
  125. package/skill/scripts/upload-and-copy.sh +0 -230
package/dist/bin/cli.js CHANGED
@@ -1,498 +1,244 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from 'node:util';
3
- import path from 'path';
4
- import fs from 'fs';
5
- import { BeforeAndAfter, generateFilename } from '../index.js';
6
- import { VIEWPORT_PRESETS } from '../types.js';
7
- import { closeBrowser } from '../browser.js';
8
- import { captureScreenshot } from '../capture.js';
9
- import { captureVideo } from '../video.js';
10
- import { uploadBeforeAfter } from '../upload.js';
11
- import { copyToClipboard } from '../clipboard.js';
12
- import { detectRoutes, getChangedFiles, detectFramework } from '../routes.js';
13
- import { resolveViewport } from '../viewport.js';
14
- // Determine subcommand
15
- const subcommand = process.argv[2];
16
- const isSubcommand = ['detect', 'compare', 'run'].includes(subcommand);
17
- if (isSubcommand) {
18
- // Remove subcommand from argv for parseArgs
19
- process.argv.splice(2, 1);
20
- }
3
+ import { createRequire } from 'module';
4
+ import { runPr } from '../commands/pr.js';
5
+ import { runCompare } from '../commands/compare.js';
6
+ import { runLogin } from '../commands/login.js';
7
+ import { runPrune } from '../commands/prune.js';
8
+ import { runDoctor } from '../commands/doctor.js';
9
+ import { runDetect } from '../commands/detect.js';
10
+ import { NeedsHumanError } from '../errors.js';
11
+ import { buildSummary } from '../report.js';
12
+ const require = createRequire(import.meta.url);
13
+ const VERSION = (() => {
14
+ try {
15
+ return require('../../package.json').version;
16
+ }
17
+ catch {
18
+ return '0.0.0';
19
+ }
20
+ })();
21
+ const SUBCOMMANDS = ['pr', 'detect', 'login', 'prune', 'doctor', 'compare'];
22
+ const argv = process.argv.slice(2);
23
+ const subcommand = SUBCOMMANDS.includes(argv[0]) ? argv.shift() : null;
21
24
  const { values, positionals } = parseArgs({
25
+ args: argv,
22
26
  allowPositionals: true,
23
27
  options: {
24
28
  help: { type: 'boolean', short: 'h' },
25
- // Viewport presets (default: desktop)
29
+ version: { type: 'boolean', short: 'v' },
30
+ before: { type: 'string' },
31
+ after: { type: 'string' },
32
+ routes: { type: 'string' },
33
+ 'max-routes': { type: 'string' },
34
+ framework: { type: 'string' },
35
+ viewports: { type: 'string' },
26
36
  mobile: { type: 'boolean', short: 'm' },
27
37
  tablet: { type: 'boolean', short: 't' },
28
- size: { type: 'string' }, // WxH custom size
29
- // Capture options
38
+ responsive: { type: 'boolean', short: 'r' },
39
+ size: { type: 'string' },
30
40
  full: { type: 'boolean', short: 'f' },
31
- selector: { type: 'string', short: 's' },
32
- // Output options
41
+ 'viewport-only': { type: 'boolean' },
42
+ scale: { type: 'string' },
43
+ threshold: { type: 'string' },
44
+ 'max-height': { type: 'string' },
45
+ wait: { type: 'string' },
46
+ header: { type: 'string', multiple: true },
47
+ cookie: { type: 'string', multiple: true },
33
48
  output: { type: 'string', short: 'o' },
34
- markdown: { type: 'boolean' },
35
- 'upload-url': { type: 'string' },
36
- // New options
37
- responsive: { type: 'boolean', short: 'r' },
38
- routes: { type: 'string' },
39
- 'max-routes': { type: 'string' },
40
- framework: { type: 'string' },
41
- 'before-base': { type: 'string' },
42
- 'after-base': { type: 'string' },
43
- // Video/GIF options
44
- video: { type: 'boolean' },
45
- duration: { type: 'string' },
46
- fps: { type: 'string' },
47
- delay: { type: 'string' },
49
+ 'dry-run': { type: 'boolean' },
50
+ 'no-comment': { type: 'boolean' },
51
+ pr: { type: 'string' },
52
+ json: { type: 'boolean' },
53
+ days: { type: 'string' },
54
+ quiet: { type: 'boolean', short: 'q' },
48
55
  },
49
56
  });
50
57
  function printHelp() {
51
- console.log(`
52
- pre-post — Visual diff tool for PRs
53
-
54
- USAGE:
55
- pre-post <before> <after> [selector] [selector2]
56
- pre-post detect [options]
57
- pre-post compare --before-base <url> --after-base <url> [options]
58
- pre-post run --before-base <url> --after-base <url> [options]
59
-
60
- SUBCOMMANDS:
61
- detect Detect affected routes from git diff (JSON output)
62
- compare Compare before/after URLs with screenshots
63
- run Auto-detect routes + compare (detect + compare)
64
-
65
- Arguments can be URLs or image files (auto-detected).
66
- Selectors are optional - use one for both, or two for different selectors.
67
-
68
- VIEWPORT OPTIONS:
69
- (default) Desktop viewport (1280x800)
70
- -m, --mobile Mobile viewport (375x812)
71
- -t, --tablet Tablet viewport (768x1024)
72
- --size <WxH> Custom viewport (e.g., 1920x1080)
73
-
74
- CAPTURE OPTIONS:
75
- -f, --full Capture full scrollable page
76
- -s, --selector <css> Scroll element into view before capture
77
- -r, --responsive Capture at desktop + mobile viewports
78
-
79
- ROUTE DETECTION OPTIONS:
80
- --routes <paths> Explicit route list (comma-separated)
81
- --max-routes <n> Max routes to detect (default: 5)
82
- --framework <name> Force framework (nextjs-app, nextjs-pages, generic)
83
-
84
- COMPARE OPTIONS:
85
- --before-base <url> Base URL for "before" state (production)
86
- --after-base <url> Base URL for "after" state (localhost)
87
-
88
- VIDEO OPTIONS:
89
- --video Capture animated GIF instead of static screenshot
90
- --duration <seconds> Recording duration (default: 3, max: 10)
91
- --fps <n> Target frames per second (default: 5, max: 10)
92
- --delay <ms> Wait after page load before recording (default: 0)
93
-
94
- OUTPUT OPTIONS:
95
- -o, --output <dir> Output directory (default: ~/Downloads)
96
- --markdown Upload images & output markdown table
97
- --upload-url <url> Upload endpoint (overrides git-native default)
98
- Auto-detects: 0x0.st, blob.vercel, generic PUT
99
-
100
- OTHER OPTIONS:
101
- -h, --help Show this help
102
-
103
- EXAMPLES:
104
- # Compare two URLs (protocol optional)
105
- pre-post google.com facebook.com
106
- pre-post https://old.example.com https://new.example.com
107
-
108
- # Detect routes from git diff
109
- pre-post detect
110
- pre-post detect --framework nextjs-app
111
-
112
- # Compare with auto-detected routes
113
- pre-post run --before-base https://prod.com --after-base http://localhost:3000
114
-
115
- # Compare specific routes
116
- pre-post compare --before-base https://prod.com --after-base http://localhost:3000 --routes /dashboard,/settings
117
-
118
- # Responsive capture (desktop + mobile)
119
- pre-post compare --before-base url1 --after-base url2 --responsive
120
-
121
- # Capture animated GIFs instead of static screenshots
122
- pre-post google.com facebook.com --video
123
- pre-post google.com facebook.com --video --duration 5 --fps 8
124
-
125
- # GIF capture with compare mode
126
- pre-post compare --before-base url1 --after-base url2 --video --delay 500
127
-
128
- # Use existing images (auto-detected)
129
- pre-post before.png after.png --markdown
58
+ console.log(`pre-post ${VERSION} — before/after screenshots for pull requests
59
+
60
+ USAGE
61
+ pre-post pr [options] Detect routes, capture, diff, publish, comment on the PR
62
+ pre-post <before> <after> [options] Compare two URLs (or two PNG files)
63
+ pre-post detect Print the routes this branch affects (JSON)
64
+ pre-post login <url> Sign in once; the session is reused for captures
65
+ pre-post prune [--days 90] Delete screenshots for PRs closed longer ago
66
+ pre-post doctor Check browser, GitHub auth, dev server
67
+
68
+ OPTIONS
69
+ --before <url> Production base URL (saved to .pre-post.json)
70
+ --after <url> Local base URL (auto-detected)
71
+ --routes /a,/b Explicit routes instead of detection
72
+ --max-routes <n> Cap on detected routes (default 6)
73
+ --viewports desktop,mobile,1440x900 Viewports (default desktop,mobile)
74
+ -m, --mobile -t, --tablet --size WxH Single-viewport shorthands
75
+ -f, --full | --viewport-only Full page (default for pr) or first screen only
76
+ --scale <n> Device scale factor (default 2)
77
+ --threshold <0..1> Change ratio treated as "no change" (default 0.001)
78
+ --max-height <px> Full-page height cap in CSS px (default 2400)
79
+ --wait <ms> Extra wait after the page settles
80
+ --header k=v Extra request header (repeatable)
81
+ --cookie name=value Cookie for the production host (repeatable)
82
+ -o, --output <dir> Where to write images (default: temp dir)
83
+ --dry-run Capture and diff only; no upload, no comment
84
+ --no-comment Publish images but do not touch the PR
85
+ --pr <number> Target a specific PR
86
+ --json Machine-readable output
87
+ -q, --quiet Only the final summary
88
+
89
+ ENVIRONMENT
90
+ GH_TOKEN / GITHUB_TOKEN GitHub token (default: gh auth token)
91
+ VERCEL_AUTOMATION_BYPASS_SECRET Bypass Vercel Deployment Protection
92
+ PRE_POST_CONCURRENCY Parallel pages (default 6)
93
+ PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH Use a specific Chromium binary
130
94
  `);
131
95
  }
132
- const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff'];
133
- function isImageFile(filePath) {
134
- const ext = path.extname(filePath).toLowerCase();
135
- return IMAGE_EXTENSIONS.includes(ext) && fs.existsSync(filePath);
136
- }
137
- function normalizeUrl(url) {
138
- if (/^(https?|file):\/\//i.test(url)) {
139
- return url;
140
- }
141
- if (/^(localhost|127\.0\.0\.1)(:|\/|$)/i.test(url)) {
142
- return `http://${url}`;
143
- }
144
- return `https://${url}`;
145
- }
146
- function parseStrictNumber(value, flag) {
147
- if (!/^-?\d+(?:\.\d+)?$/.test(value.trim())) {
148
- console.error(`Invalid value for ${flag}: ${value}. Expected a number.`);
149
- process.exit(1);
150
- }
151
- const parsed = Number(value);
152
- if (!Number.isFinite(parsed)) {
153
- console.error(`Invalid value for ${flag}: ${value}. Expected a finite number.`);
154
- process.exit(1);
155
- }
156
- return parsed;
157
- }
158
- function parseStrictInteger(value, flag) {
159
- if (!/^-?\d+$/.test(value.trim())) {
160
- console.error(`Invalid value for ${flag}: ${value}. Expected an integer.`);
161
- process.exit(1);
162
- }
163
- const parsed = Number(value);
164
- if (!Number.isSafeInteger(parsed)) {
165
- console.error(`Invalid value for ${flag}: ${value}. Expected a safe integer.`);
166
- process.exit(1);
96
+ function num(v, flag) {
97
+ if (v === undefined)
98
+ return undefined;
99
+ const n = Number(v);
100
+ if (!Number.isFinite(n)) {
101
+ console.error(`Invalid value for ${flag}: ${v}`);
102
+ process.exit(2);
167
103
  }
168
- return parsed;
104
+ return n;
169
105
  }
170
- /**
171
- * Validate --video flag combinations and parse video-related options.
172
- * Returns parsed options if --video is set, undefined otherwise.
173
- */
174
- function validateVideoFlags() {
175
- if (!values.video)
106
+ function kv(list) {
107
+ if (!list?.length)
176
108
  return undefined;
177
- if (values.full) {
178
- console.error('--full (fullPage) is not supported with --video');
179
- process.exit(1);
180
- }
181
- const duration = values.duration ? parseStrictNumber(values.duration, '--duration') : undefined;
182
- if (duration !== undefined && (duration <= 0 || duration > 10)) {
183
- console.error('Duration must be between 0.1 and 10 seconds');
184
- process.exit(1);
185
- }
186
- const fps = values.fps ? parseStrictInteger(values.fps, '--fps') : undefined;
187
- if (fps !== undefined && (fps < 1 || fps > 10)) {
188
- console.error('FPS must be between 1 and 10');
189
- process.exit(1);
190
- }
191
- const delay = values.delay ? parseStrictInteger(values.delay, '--delay') : undefined;
192
- if (delay !== undefined && delay < 0) {
193
- console.error('Delay must be a non-negative integer in milliseconds');
194
- process.exit(1);
109
+ const out = {};
110
+ for (const item of list) {
111
+ const sep = item.search(/[=:]/);
112
+ if (sep === -1) {
113
+ console.error(`Expected key=value, got: ${item}`);
114
+ process.exit(2);
115
+ }
116
+ out[item.slice(0, sep).trim()] = item.slice(sep + 1).trim();
195
117
  }
196
- return { duration, fps, delay };
118
+ return out;
197
119
  }
198
- function resolveViewportFlag() {
120
+ function viewportsFromFlags() {
121
+ if (values.viewports)
122
+ return values.viewports.split(',').map(s => s.trim()).filter(Boolean);
123
+ if (values.responsive)
124
+ return ['desktop', 'mobile'];
199
125
  if (values.mobile)
200
- return 'mobile';
126
+ return ['mobile'];
201
127
  if (values.tablet)
202
- return 'tablet';
203
- if (values.size) {
204
- const match = values.size.match(/^(\d+)x(\d+)$/);
205
- if (match) {
206
- return { width: parseInt(match[1]), height: parseInt(match[2]) };
207
- }
208
- console.error(`Invalid size: ${values.size}. Use WxH format (e.g., 1920x1080).`);
209
- process.exit(1);
210
- }
211
- return 'desktop';
212
- }
213
- // ============================================================
214
- // Subcommand: detect
215
- // ============================================================
216
- async function runDetect() {
217
- const framework = values.framework;
218
- const maxRoutes = values['max-routes'] ? parseInt(values['max-routes']) : undefined;
219
- const changedFiles = getChangedFiles();
220
- if (changedFiles.length === 0) {
221
- console.log(JSON.stringify({ routes: [], message: 'No changed files detected' }, null, 2));
128
+ return ['tablet'];
129
+ if (values.size)
130
+ return [values.size];
131
+ return undefined;
132
+ }
133
+ /** `before`/`after` come from flags for `pr` and `compare`, from positionals otherwise. */
134
+ function resolveTargets() {
135
+ if (subcommand === 'pr' || subcommand === 'compare')
136
+ return { before: values.before, after: values.after };
137
+ return { before: positionals[0], after: positionals[1] };
138
+ }
139
+ const quiet = values.quiet || values.json;
140
+ const log = (msg) => { if (!quiet)
141
+ console.error(msg); };
142
+ function output(result) {
143
+ if (values.json) {
144
+ console.log(JSON.stringify(result, null, 2));
222
145
  return;
223
146
  }
224
- const detectedFramework = framework || detectFramework();
225
- const routes = detectRoutes(changedFiles, { framework, maxRoutes });
226
- console.log(JSON.stringify({
227
- framework: detectedFramework,
228
- changedFiles,
229
- routes,
230
- }, null, 2));
231
- }
232
- // ============================================================
233
- // Subcommand: compare
234
- // ============================================================
235
- async function runCompare(routeOverrides, videoOpts) {
236
- const beforeBase = values['before-base'];
237
- const afterBase = values['after-base'];
238
- if (!beforeBase || !afterBase) {
239
- console.error('Both --before-base and --after-base are required for compare.');
240
- process.exit(1);
241
- }
242
- const routeList = routeOverrides
243
- ?? (values.routes ? values.routes.split(',').map(r => r.trim()) : ['/']);
244
- const responsive = values.responsive ?? false;
245
- const isVideo = values.video ?? false;
246
- const ext = isVideo ? 'gif' : 'png';
247
- const captureType = isVideo ? 'GIF' : 'screenshot';
248
- const outputDir = values.output || path.join(process.env.HOME || '~', 'Downloads');
249
- fs.mkdirSync(outputDir, { recursive: true });
250
- const timestamp = new Date();
251
- const viewports = responsive
252
- ? ['desktop', 'mobile'].map(p => ({ label: p, size: VIEWPORT_PRESETS[p] }))
253
- : [{ size: resolveViewport(resolveViewportFlag()) }];
254
- const tasks = [];
255
- for (const route of routeList) {
256
- const beforeUrl = normalizeUrl(beforeBase.replace(/\/$/, '') + route);
257
- const afterUrl = normalizeUrl(afterBase.replace(/\/$/, '') + route);
258
- const routeSlug = route === '/' ? 'home' : route.replace(/^\//, '').replace(/\//g, '-');
259
- for (const { label: presetLabel, size: vp } of viewports) {
260
- const filePrefix = presetLabel ? `${routeSlug}-${presetLabel}` : routeSlug;
261
- tasks.push({
262
- route,
263
- presetLabel,
264
- vp,
265
- beforeUrl,
266
- afterUrl,
267
- beforeFilename: `${filePrefix}-before-${formatTimestamp(timestamp)}.${ext}`,
268
- afterFilename: `${filePrefix}-after-${formatTimestamp(timestamp)}.${ext}`,
269
- });
270
- }
271
- }
272
- const totalCaptures = tasks.length * 2;
273
- console.log(`Capturing ${totalCaptures} ${captureType}s across ${routeList.length} route(s)...`);
274
- try {
275
- // Execute all tasks in parallel (pool limits concurrency)
276
- const results = await Promise.allSettled(tasks.map(async (task) => {
277
- if (isVideo) {
278
- const opts = { viewport: task.vp, ...videoOpts, selector: values.selector };
279
- const [beforeResult, afterResult] = await Promise.all([
280
- captureVideo(task.beforeUrl, opts),
281
- captureVideo(task.afterUrl, opts),
282
- ]);
283
- fs.writeFileSync(path.join(outputDir, task.beforeFilename), beforeResult.gif);
284
- fs.writeFileSync(path.join(outputDir, task.afterFilename), afterResult.gif);
285
- console.log(` Done: ${task.route}${task.presetLabel ? ` @ ${task.presetLabel}` : ''} (${beforeResult.frameCount}f + ${afterResult.frameCount}f)`);
286
- }
287
- else {
288
- const [beforeResult, afterResult] = await Promise.all([
289
- captureScreenshot({
290
- url: task.beforeUrl, viewport: task.vp, fullPage: values.full, selector: values.selector,
291
- }),
292
- captureScreenshot({
293
- url: task.afterUrl, viewport: task.vp, fullPage: values.full, selector: values.selector,
294
- }),
295
- ]);
296
- fs.writeFileSync(path.join(outputDir, task.beforeFilename), beforeResult.image);
297
- fs.writeFileSync(path.join(outputDir, task.afterFilename), afterResult.image);
298
- console.log(` Done: ${task.route}${task.presetLabel ? ` @ ${task.presetLabel}` : ''}`);
299
- }
300
- }));
301
- const failures = results.filter((r) => r.status === 'rejected');
302
- if (failures.length > 0) {
303
- for (const f of failures)
304
- console.error(` Failed: ${f.reason}`);
305
- if (failures.length === results.length)
306
- throw new Error('All captures failed');
307
- }
308
- console.log(`\nAll ${captureType}s saved to: ${outputDir}`);
309
- }
310
- finally {
311
- await closeBrowser();
312
- }
147
+ console.log(buildSummary(result));
148
+ console.log(`Files: ${result.outputDir}`);
149
+ if (result.markdown && !result.commentUrl)
150
+ console.log('\n' + result.markdown);
313
151
  }
314
- // ============================================================
315
- // Subcommand: run (detect + compare)
316
- // ============================================================
317
- async function runFull(videoOpts) {
318
- const beforeBase = values['before-base'];
319
- const afterBase = values['after-base'];
320
- if (!beforeBase || !afterBase) {
321
- console.error('Both --before-base and --after-base are required for run.');
322
- process.exit(1);
323
- }
324
- const framework = values.framework;
325
- const maxRoutes = values['max-routes'] ? parseInt(values['max-routes']) : undefined;
326
- const changedFiles = getChangedFiles();
327
- let routeList;
328
- if (values.routes) {
329
- routeList = values.routes.split(',').map(r => r.trim());
330
- }
331
- else if (changedFiles.length > 0) {
332
- const routes = detectRoutes(changedFiles, { framework, maxRoutes });
333
- if (routes.length === 0) {
334
- console.log('No visual routes detected. Defaulting to /');
335
- routeList = ['/'];
336
- }
337
- else {
338
- routeList = routes.map(r => r.path);
339
- console.log(`Detected routes: ${routeList.join(', ')}`);
340
- }
341
- }
342
- else {
343
- console.log('No changed files detected. Defaulting to /');
344
- routeList = ['/'];
152
+ async function main() {
153
+ if (values.version) {
154
+ console.log(VERSION);
155
+ return;
345
156
  }
346
- await runCompare(routeList, videoOpts);
347
- }
348
- // ============================================================
349
- // Default mode (original before-and-after behavior)
350
- // ============================================================
351
- async function runDefault(videoOpts) {
352
- if (values.help) {
157
+ if (values.help || (!subcommand && positionals.length < 2)) {
353
158
  printHelp();
159
+ if (!values.help && !subcommand && positionals.length > 0)
160
+ process.exit(2);
354
161
  return;
355
162
  }
356
- if (positionals.length < 2) {
357
- console.error('Two arguments required (URLs or image paths). Run with --help for usage.');
358
- process.exit(1);
359
- }
360
- const [first, second, ...rest] = positionals;
361
- const viewport = resolveViewportFlag();
362
- const ba = new BeforeAndAfter({ viewport });
363
- try {
364
- // Auto-detect image mode
365
- if (isImageFile(first) && isImageFile(second)) {
366
- const result = await ba.fromImages({
367
- before: first,
368
- after: second,
163
+ const common = {
164
+ routes: values.routes ? values.routes.split(',').map(r => r.trim()).filter(Boolean) : undefined,
165
+ viewports: viewportsFromFlags(),
166
+ fullPage: values['viewport-only'] ? false : values.full ? true : undefined,
167
+ scale: num(values.scale, '--scale'),
168
+ threshold: num(values.threshold, '--threshold'),
169
+ maxHeight: num(values['max-height'], '--max-height'),
170
+ maxRoutes: num(values['max-routes'], '--max-routes'),
171
+ wait: num(values.wait, '--wait'),
172
+ headers: kv(values.header),
173
+ output: values.output,
174
+ log,
175
+ };
176
+ const { before, after } = resolveTargets();
177
+ switch (subcommand) {
178
+ case 'pr': {
179
+ const cookies = values.cookie ? Object.entries(kv(values.cookie)).map(([name, value]) => ({ name, value })) : undefined;
180
+ const result = await runPr({
181
+ ...common,
182
+ before,
183
+ after,
184
+ framework: values.framework,
185
+ cookies,
186
+ dryRun: values['dry-run'],
187
+ comment: !values['no-comment'],
188
+ pr: num(values.pr, '--pr'),
189
+ version: VERSION,
369
190
  });
370
- if (values.markdown) {
371
- await uploadAndOutputMarkdown(result.beforeImage, result.afterImage, path.basename(first), path.basename(second));
372
- }
373
- else {
374
- console.log(`Before: ${first}`);
375
- console.log(`After: ${second}`);
376
- }
191
+ output(result);
192
+ if (result.outcomes.length && result.outcomes.every(o => o.status === 'error'))
193
+ process.exit(1);
377
194
  return;
378
195
  }
379
- // URL mode
380
- const beforeUrl = normalizeUrl(first);
381
- const afterUrl = normalizeUrl(second);
382
- // Resolve selectors: positional args override -s flag
383
- let beforeSelector = values.selector;
384
- let afterSelector = values.selector;
385
- if (rest.length >= 1) {
386
- beforeSelector = rest[0];
387
- afterSelector = rest[0];
388
- }
389
- if (rest.length >= 2) {
390
- afterSelector = rest[1];
196
+ case 'detect': {
197
+ const detection = runDetect({ maxRoutes: common.maxRoutes, framework: values.framework });
198
+ console.log(JSON.stringify(detection, null, values.json ? 2 : 0));
199
+ return;
391
200
  }
392
- const outputDir = values.output || path.join(process.env.HOME || '~', 'Downloads');
393
- fs.mkdirSync(outputDir, { recursive: true });
394
- const isVideo = values.video ?? false;
395
- const captureType = isVideo ? 'GIF' : 'screenshot';
396
- console.log(`Capturing ${captureType} before: ${beforeUrl}${beforeSelector ? ` (${beforeSelector})` : ''}`);
397
- console.log(`Capturing ${captureType} after: ${afterUrl}${afterSelector ? ` (${afterSelector})` : ''}`);
398
- if (isVideo) {
399
- // Video/GIF mode
400
- const resolvedViewport = resolveViewport(viewport);
401
- const opts = {
402
- viewport: resolvedViewport,
403
- ...videoOpts,
404
- selector: beforeSelector,
405
- };
406
- const beforeResult = await captureVideo(beforeUrl, opts);
407
- const afterResult = await captureVideo(afterUrl, { ...opts, selector: afterSelector });
408
- const timestamp = new Date();
409
- const beforeFilename = generateFilename({ url: beforeUrl, suffix: 'before', timestamp, format: 'gif' });
410
- const afterFilename = generateFilename({ url: afterUrl, suffix: 'after', timestamp, format: 'gif' });
411
- const beforePath = path.join(outputDir, beforeFilename);
412
- const afterPath = path.join(outputDir, afterFilename);
413
- fs.writeFileSync(beforePath, beforeResult.gif);
414
- fs.writeFileSync(afterPath, afterResult.gif);
415
- console.log(`\nSaved: ${beforePath} (${beforeResult.frameCount} frames)`);
416
- console.log(`Saved: ${afterPath} (${afterResult.frameCount} frames)`);
417
- if (values.markdown) {
418
- await uploadAndOutputMarkdown(beforeResult.gif, afterResult.gif, beforeFilename, afterFilename);
201
+ case 'login': {
202
+ if (!positionals[0]) {
203
+ console.error('Usage: pre-post login <url>');
204
+ process.exit(2);
419
205
  }
206
+ await runLogin(positionals[0]);
207
+ return;
420
208
  }
421
- else {
422
- // Static screenshot mode
423
- const result = await ba.captureBeforeAfter({
424
- before: {
425
- url: beforeUrl,
426
- selector: beforeSelector,
427
- fullPage: values.full,
428
- },
429
- after: {
430
- url: afterUrl,
431
- selector: afterSelector,
432
- fullPage: values.full,
433
- },
434
- });
435
- const timestamp = new Date();
436
- const beforeFilename = generateFilename({ url: beforeUrl, suffix: 'before', timestamp });
437
- const afterFilename = generateFilename({ url: afterUrl, suffix: 'after', timestamp });
438
- const beforePath = path.join(outputDir, beforeFilename);
439
- const afterPath = path.join(outputDir, afterFilename);
440
- fs.writeFileSync(beforePath, result.before.image);
441
- fs.writeFileSync(afterPath, result.after.image);
442
- console.log(`\nSaved: ${beforePath}`);
443
- console.log(`Saved: ${afterPath}`);
444
- if (values.markdown) {
445
- await uploadAndOutputMarkdown(result.before.image, result.after.image, beforeFilename, afterFilename);
446
- }
209
+ case 'prune': {
210
+ const result = await runPrune({ days: num(values.days, '--days'), dryRun: values['dry-run'] });
211
+ console.log(values['dry-run'] ? 'Would remove:' : 'Removed:', result.removed.length ? result.removed.join(', ') : 'nothing');
212
+ console.log('Kept:', result.kept.length ? result.kept.join(', ') : 'nothing');
213
+ return;
214
+ }
215
+ case 'doctor': {
216
+ const checks = await runDoctor();
217
+ for (const c of checks)
218
+ console.log(`${c.name.padEnd(10)} ${c.ok ? 'ok' : 'FAIL'} ${c.detail}`);
219
+ if (checks.some(c => !c.ok && c.name === 'browser'))
220
+ process.exit(1);
221
+ return;
447
222
  }
448
- }
449
- finally {
450
- await closeBrowser();
451
- }
452
- }
453
- function formatTimestamp(date) {
454
- return date.toISOString().replace(/[:.]/g, '-').slice(0, 19);
455
- }
456
- function buildMarkdownTable(beforeUrl, afterUrl) {
457
- const header = '| Pre | Post |';
458
- const divider = '|:---:|:----:|';
459
- const row = `| ![Pre](${beforeUrl}) | ![Post](${afterUrl}) |`;
460
- return `${header}\n${divider}\n${row}`;
461
- }
462
- async function uploadAndOutputMarkdown(beforeImage, afterImage, beforeFilename, afterFilename) {
463
- const uploadUrl = values['upload-url'] || process.env.UPLOAD_URL;
464
- console.log(`Uploading images${uploadUrl ? ` to ${uploadUrl}` : ''}...`);
465
- const { beforeUrl, afterUrl } = await uploadBeforeAfter({ image: beforeImage, filename: beforeFilename }, { image: afterImage, filename: afterFilename }, uploadUrl);
466
- const markdown = buildMarkdownTable(beforeUrl, afterUrl);
467
- console.log(`\n${markdown}`);
468
- if (copyToClipboard(markdown)) {
469
- console.log('\nMarkdown copied to clipboard');
470
- }
471
- }
472
- // ============================================================
473
- // Main dispatch
474
- // ============================================================
475
- async function main() {
476
- if (values.help && !isSubcommand) {
477
- printHelp();
478
- return;
479
- }
480
- const videoOpts = validateVideoFlags();
481
- switch (subcommand) {
482
- case 'detect':
483
- return runDetect();
484
223
  case 'compare':
485
- return runCompare(undefined, videoOpts);
486
- case 'run':
487
- return runFull(videoOpts);
488
- default:
489
- return runDefault(videoOpts);
224
+ default: {
225
+ if (!before || !after) {
226
+ console.error('Two URLs (or two PNG files) are required.');
227
+ process.exit(2);
228
+ }
229
+ output(await runCompare({ ...common, before, after }));
230
+ return;
231
+ }
490
232
  }
491
233
  }
492
234
  main()
493
235
  .then(() => process.exit(0))
494
- .catch((err) => {
495
- console.error(err.message);
236
+ .catch(err => {
237
+ if (err instanceof NeedsHumanError) {
238
+ console.error(`\n${err.message}`);
239
+ process.exit(3);
240
+ }
241
+ console.error(err?.stack && process.env.PRE_POST_DEBUG ? err.stack : `Error: ${err?.message || err}`);
496
242
  process.exit(1);
497
243
  });
498
244
  //# sourceMappingURL=cli.js.map