@juangadm/pre-post 0.3.0 → 1.1.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 (153) hide show
  1. package/README.md +174 -233
  2. package/dist/baseline.d.ts +119 -0
  3. package/dist/baseline.d.ts.map +1 -0
  4. package/dist/baseline.js +316 -0
  5. package/dist/baseline.js.map +1 -0
  6. package/dist/bin/cli.js +243 -458
  7. package/dist/bin/cli.js.map +1 -1
  8. package/dist/browser.d.ts +68 -25
  9. package/dist/browser.d.ts.map +1 -1
  10. package/dist/browser.js +500 -105
  11. package/dist/browser.js.map +1 -1
  12. package/dist/capture.d.ts +1 -6
  13. package/dist/capture.d.ts.map +1 -1
  14. package/dist/capture.js +12 -36
  15. package/dist/capture.js.map +1 -1
  16. package/dist/commands/compare.d.ts +18 -0
  17. package/dist/commands/compare.d.ts.map +1 -0
  18. package/dist/commands/compare.js +82 -0
  19. package/dist/commands/compare.js.map +1 -0
  20. package/dist/commands/detect.d.ts +25 -0
  21. package/dist/commands/detect.d.ts.map +1 -0
  22. package/dist/commands/detect.js +22 -0
  23. package/dist/commands/detect.js.map +1 -0
  24. package/dist/commands/doctor.d.ts +42 -0
  25. package/dist/commands/doctor.d.ts.map +1 -0
  26. package/dist/commands/doctor.js +119 -0
  27. package/dist/commands/doctor.js.map +1 -0
  28. package/dist/commands/login.d.ts +6 -0
  29. package/dist/commands/login.d.ts.map +1 -0
  30. package/dist/commands/login.js +34 -0
  31. package/dist/commands/login.js.map +1 -0
  32. package/dist/commands/pr.d.ts +32 -0
  33. package/dist/commands/pr.d.ts.map +1 -0
  34. package/dist/commands/pr.js +259 -0
  35. package/dist/commands/pr.js.map +1 -0
  36. package/dist/commands/prune.d.ts +10 -0
  37. package/dist/commands/prune.d.ts.map +1 -0
  38. package/dist/commands/prune.js +13 -0
  39. package/dist/commands/prune.js.map +1 -0
  40. package/dist/comparison.d.ts +104 -0
  41. package/dist/comparison.d.ts.map +1 -0
  42. package/dist/comparison.js +236 -0
  43. package/dist/comparison.js.map +1 -0
  44. package/dist/config.d.ts +46 -0
  45. package/dist/config.d.ts.map +1 -0
  46. package/dist/config.js +82 -0
  47. package/dist/config.js.map +1 -0
  48. package/dist/deployments.d.ts +71 -0
  49. package/dist/deployments.d.ts.map +1 -0
  50. package/dist/deployments.js +204 -0
  51. package/dist/deployments.js.map +1 -0
  52. package/dist/diff-pool.d.ts +17 -0
  53. package/dist/diff-pool.d.ts.map +1 -0
  54. package/dist/diff-pool.js +92 -0
  55. package/dist/diff-pool.js.map +1 -0
  56. package/dist/diff-worker.d.ts +32 -0
  57. package/dist/diff-worker.d.ts.map +1 -0
  58. package/dist/diff-worker.js +39 -0
  59. package/dist/diff-worker.js.map +1 -0
  60. package/dist/diff.d.ts +47 -0
  61. package/dist/diff.d.ts.map +1 -0
  62. package/dist/diff.js +281 -0
  63. package/dist/diff.js.map +1 -0
  64. package/dist/doctor.d.ts +79 -0
  65. package/dist/doctor.d.ts.map +1 -0
  66. package/dist/doctor.js +138 -0
  67. package/dist/doctor.js.map +1 -0
  68. package/dist/errors.d.ts +38 -0
  69. package/dist/errors.d.ts.map +1 -0
  70. package/dist/errors.js +59 -0
  71. package/dist/errors.js.map +1 -0
  72. package/dist/git.d.ts +61 -0
  73. package/dist/git.d.ts.map +1 -0
  74. package/dist/git.js +182 -0
  75. package/dist/git.js.map +1 -0
  76. package/dist/github.d.ts +163 -0
  77. package/dist/github.d.ts.map +1 -0
  78. package/dist/github.js +411 -0
  79. package/dist/github.js.map +1 -0
  80. package/dist/index.d.ts +22 -23
  81. package/dist/index.d.ts.map +1 -1
  82. package/dist/index.js +17 -67
  83. package/dist/index.js.map +1 -1
  84. package/dist/landing.d.ts +48 -0
  85. package/dist/landing.d.ts.map +1 -0
  86. package/dist/landing.js +93 -0
  87. package/dist/landing.js.map +1 -0
  88. package/dist/pkg.d.ts +17 -0
  89. package/dist/pkg.d.ts.map +1 -0
  90. package/dist/pkg.js +49 -0
  91. package/dist/pkg.js.map +1 -0
  92. package/dist/report.d.ts +20 -0
  93. package/dist/report.d.ts.map +1 -0
  94. package/dist/report.js +130 -0
  95. package/dist/report.js.map +1 -0
  96. package/dist/routes/imports.d.ts +46 -0
  97. package/dist/routes/imports.d.ts.map +1 -0
  98. package/dist/routes/imports.js +208 -0
  99. package/dist/routes/imports.js.map +1 -0
  100. package/dist/routes/vite.d.ts +15 -0
  101. package/dist/routes/vite.d.ts.map +1 -0
  102. package/dist/routes/vite.js +130 -0
  103. package/dist/routes/vite.js.map +1 -0
  104. package/dist/routes.d.ts +65 -11
  105. package/dist/routes.d.ts.map +1 -1
  106. package/dist/routes.js +346 -141
  107. package/dist/routes.js.map +1 -1
  108. package/dist/run.d.ts +116 -0
  109. package/dist/run.d.ts.map +1 -0
  110. package/dist/run.js +316 -0
  111. package/dist/run.js.map +1 -0
  112. package/dist/sameness.d.ts +91 -0
  113. package/dist/sameness.d.ts.map +1 -0
  114. package/dist/sameness.js +157 -0
  115. package/dist/sameness.js.map +1 -0
  116. package/dist/sessions.d.ts +50 -0
  117. package/dist/sessions.d.ts.map +1 -0
  118. package/dist/sessions.js +80 -0
  119. package/dist/sessions.js.map +1 -0
  120. package/dist/shift.d.ts +67 -0
  121. package/dist/shift.d.ts.map +1 -0
  122. package/dist/shift.js +184 -0
  123. package/dist/shift.js.map +1 -0
  124. package/dist/types.d.ts +179 -68
  125. package/dist/types.d.ts.map +1 -1
  126. package/dist/types.js +1 -0
  127. package/dist/types.js.map +1 -1
  128. package/dist/url.d.ts +12 -0
  129. package/dist/url.d.ts.map +1 -0
  130. package/dist/url.js +30 -0
  131. package/dist/url.js.map +1 -0
  132. package/dist/viewport.d.ts +6 -1
  133. package/dist/viewport.d.ts.map +1 -1
  134. package/dist/viewport.js +13 -7
  135. package/dist/viewport.js.map +1 -1
  136. package/package.json +21 -14
  137. package/skill/SKILL.md +56 -221
  138. package/dist/clipboard.d.ts +0 -9
  139. package/dist/clipboard.d.ts.map +0 -1
  140. package/dist/clipboard.js +0 -26
  141. package/dist/clipboard.js.map +0 -1
  142. package/dist/upload.d.ts +0 -39
  143. package/dist/upload.d.ts.map +0 -1
  144. package/dist/upload.js +0 -159
  145. package/dist/upload.js.map +0 -1
  146. package/dist/video.d.ts +0 -19
  147. package/dist/video.d.ts.map +0 -1
  148. package/dist/video.js +0 -263
  149. package/dist/video.js.map +0 -1
  150. package/skill/scripts/adapters/0x0st.sh +0 -36
  151. package/skill/scripts/adapters/blob.sh +0 -55
  152. package/skill/scripts/adapters/git-native.sh +0 -51
  153. package/skill/scripts/upload-and-copy.sh +0 -230
package/dist/bin/cli.js CHANGED
@@ -1,498 +1,283 @@
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);
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 { doctorExitCode, 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;
24
+ /**
25
+ * parseArgs throws a TypeError for an unknown flag, and an uncaught one prints
26
+ * a Node-internal stack trace: eight lines of `node:internal/util/parse_args`
27
+ * for a typo. The CLI is mostly read by agents, so the first line has to be the
28
+ * thing to fix, not a frame from Node's parser.
29
+ *
30
+ * Only the parse is wrapped, and the message is Node's own — this reformats a
31
+ * failure, it does not decide what counts as one.
32
+ */
33
+ function parse() {
34
+ try {
35
+ return parseArgs({
36
+ args: argv,
37
+ allowPositionals: true,
38
+ options: OPTIONS,
39
+ });
40
+ }
41
+ catch (err) {
42
+ const message = err?.message?.split('\n')[0] ?? 'could not parse the arguments';
43
+ console.error(`pre-post: ${message}`);
44
+ console.error('Run: pre-post --help');
45
+ process.exit(2);
46
+ }
20
47
  }
21
- const { values, positionals } = parseArgs({
22
- allowPositionals: true,
23
- options: {
24
- help: { type: 'boolean', short: 'h' },
25
- // Viewport presets (default: desktop)
26
- mobile: { type: 'boolean', short: 'm' },
27
- tablet: { type: 'boolean', short: 't' },
28
- size: { type: 'string' }, // WxH custom size
29
- // Capture options
30
- full: { type: 'boolean', short: 'f' },
31
- selector: { type: 'string', short: 's' },
32
- // Output options
33
- 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' },
48
- },
49
- });
48
+ const OPTIONS = {
49
+ help: { type: 'boolean', short: 'h' },
50
+ version: { type: 'boolean', short: 'v' },
51
+ before: { type: 'string' },
52
+ after: { type: 'string' },
53
+ routes: { type: 'string' },
54
+ 'max-routes': { type: 'string' },
55
+ framework: { type: 'string' },
56
+ base: { type: 'string' },
57
+ 'no-local-baseline': { type: 'boolean' },
58
+ viewports: { type: 'string' },
59
+ mobile: { type: 'boolean', short: 'm' },
60
+ tablet: { type: 'boolean', short: 't' },
61
+ responsive: { type: 'boolean', short: 'r' },
62
+ size: { type: 'string' },
63
+ full: { type: 'boolean', short: 'f' },
64
+ 'viewport-only': { type: 'boolean' },
65
+ scale: { type: 'string' },
66
+ threshold: { type: 'string' },
67
+ 'max-height': { type: 'string' },
68
+ wait: { type: 'string' },
69
+ header: { type: 'string', multiple: true },
70
+ cookie: { type: 'string', multiple: true },
71
+ output: { type: 'string', short: 'o' },
72
+ 'dry-run': { type: 'boolean' },
73
+ 'no-comment': { type: 'boolean' },
74
+ pr: { type: 'string' },
75
+ json: { type: 'boolean' },
76
+ days: { type: 'string' },
77
+ quiet: { type: 'boolean', short: 'q' },
78
+ };
79
+ const { values, positionals } = parse();
50
80
  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
81
+ console.log(`pre-post ${VERSION} — before/after screenshots for pull requests
117
82
 
118
- # Responsive capture (desktop + mobile)
119
- pre-post compare --before-base url1 --after-base url2 --responsive
83
+ USAGE
84
+ pre-post pr [options] Detect routes, capture, diff, publish, comment on the PR
85
+ pre-post <before> <after> [options] Compare two URLs (or two PNG files)
86
+ pre-post detect Print the routes this branch affects (JSON)
87
+ pre-post ... --base <ref> Compare against <ref> instead of the detected fork point
88
+ pre-post login <url> Sign in once; the session is reused for captures
89
+ pre-post prune [--days 90] Delete screenshots for PRs closed longer ago
90
+ pre-post doctor Check browser, GitHub auth, dev server
120
91
 
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
92
+ OPTIONS
93
+ --before <url> Production base URL (saved to .pre-post.json)
94
+ --after <url> Local base URL (auto-detected)
95
+ --routes /a,/b Explicit routes instead of detection
96
+ --max-routes <n> Cap on detected routes (default 6)
97
+ --viewports desktop,mobile,1440x900 Viewports (default desktop)
98
+ -r, --responsive Desktop and mobile
99
+ -m, --mobile -t, --tablet --size WxH Single-viewport shorthands
100
+ -f, --full | --viewport-only Full page (default for pr) or first screen only
101
+ --scale <n> Device scale factor (default 2)
102
+ --threshold <0..1> Change ratio treated as "no change" (default 0.001)
103
+ --max-height <px> Full-page height cap in CSS px (default 2400)
104
+ --wait <ms> Extra wait after the page settles
105
+ --header k=v Extra request header (repeatable)
106
+ --cookie name=value Cookie for the production host (repeatable)
107
+ -o, --output <dir> Where to write images (default: temp dir)
108
+ --no-local-baseline Do not rebuild the baseline from the base commit
109
+ --dry-run Capture and diff only; no upload, no comment
110
+ --no-comment Publish images but do not touch the PR
111
+ --pr <number> Target a specific PR
112
+ --json Machine-readable output
113
+ -q, --quiet Only the final summary
124
114
 
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
115
+ ENVIRONMENT
116
+ GH_TOKEN / GITHUB_TOKEN GitHub token (default: gh auth token)
117
+ VERCEL_AUTOMATION_BYPASS_SECRET Bypass Vercel Deployment Protection
118
+ PRE_POST_CONCURRENCY Parallel pages (default 6)
119
+ PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH Use a specific Chromium binary
130
120
  `);
131
121
  }
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);
122
+ function num(v, flag) {
123
+ if (v === undefined)
124
+ return undefined;
125
+ const n = Number(v);
126
+ if (!Number.isFinite(n)) {
127
+ console.error(`Invalid value for ${flag}: ${v}`);
128
+ process.exit(2);
167
129
  }
168
- return parsed;
130
+ return n;
169
131
  }
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)
132
+ function kv(list) {
133
+ if (!list?.length)
176
134
  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);
135
+ const out = {};
136
+ for (const item of list) {
137
+ const sep = item.search(/[=:]/);
138
+ if (sep === -1) {
139
+ console.error(`Expected key=value, got: ${item}`);
140
+ process.exit(2);
141
+ }
142
+ out[item.slice(0, sep).trim()] = item.slice(sep + 1).trim();
195
143
  }
196
- return { duration, fps, delay };
144
+ return out;
197
145
  }
198
- function resolveViewportFlag() {
146
+ function viewportsFromFlags() {
147
+ if (values.viewports)
148
+ return values.viewports.split(',').map(s => s.trim()).filter(Boolean);
149
+ if (values.responsive)
150
+ return ['desktop', 'mobile'];
199
151
  if (values.mobile)
200
- return 'mobile';
152
+ return ['mobile'];
201
153
  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';
154
+ return ['tablet'];
155
+ if (values.size)
156
+ return [values.size];
157
+ return undefined;
212
158
  }
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));
222
- return;
223
- }
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));
159
+ /** `before`/`after` come from flags for `pr` and `compare`, from positionals otherwise. */
160
+ function resolveTargets() {
161
+ if (subcommand === 'pr' || subcommand === 'compare')
162
+ return { before: values.before, after: values.after };
163
+ return { before: positionals[0], after: positionals[1] };
231
164
  }
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();
165
+ const quiet = values.quiet || values.json;
166
+ const log = (msg) => { if (!quiet)
167
+ console.error(msg); };
168
+ function output(result) {
169
+ if (values.json) {
170
+ console.log(JSON.stringify(result, null, 2));
171
+ return;
312
172
  }
173
+ console.log(buildSummary(result));
174
+ console.log(`Files: ${result.outputDir}`);
175
+ if (result.markdown && !result.commentUrl)
176
+ console.log('\n' + result.markdown);
313
177
  }
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 = ['/'];
178
+ async function main() {
179
+ if (values.version) {
180
+ console.log(VERSION);
181
+ return;
345
182
  }
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) {
183
+ if (values.help || (!subcommand && positionals.length < 2)) {
353
184
  printHelp();
185
+ if (!values.help && !subcommand && positionals.length > 0)
186
+ process.exit(2);
354
187
  return;
355
188
  }
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,
369
- });
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}`);
189
+ const common = {
190
+ routes: values.routes ? values.routes.split(',').map(r => r.trim()).filter(Boolean) : undefined,
191
+ viewports: viewportsFromFlags(),
192
+ fullPage: values['viewport-only'] ? false : values.full ? true : undefined,
193
+ scale: num(values.scale, '--scale'),
194
+ threshold: num(values.threshold, '--threshold'),
195
+ maxHeight: num(values['max-height'], '--max-height'),
196
+ maxRoutes: num(values['max-routes'], '--max-routes'),
197
+ wait: num(values.wait, '--wait'),
198
+ headers: kv(values.header),
199
+ output: values.output,
200
+ log,
201
+ };
202
+ const { before, after } = resolveTargets();
203
+ switch (subcommand) {
204
+ case 'pr': {
205
+ // before/after come from flags here; positionals would be silently dropped.
206
+ if (positionals.length) {
207
+ console.error(`pre-post pr takes no positional arguments (got: ${positionals.join(' ')}). Use --before and --after.`);
208
+ process.exit(2);
376
209
  }
210
+ const cookies = values.cookie ? Object.entries(kv(values.cookie)).map(([name, value]) => ({ name, value })) : undefined;
211
+ const result = await runPr({
212
+ ...common,
213
+ before,
214
+ after,
215
+ framework: values.framework,
216
+ base: values.base,
217
+ cookies,
218
+ dryRun: values['dry-run'],
219
+ localBaseline: !values['no-local-baseline'],
220
+ comment: !values['no-comment'],
221
+ pr: num(values.pr, '--pr'),
222
+ version: VERSION,
223
+ });
224
+ output(result);
225
+ if (result.outcomes.length && result.outcomes.every(o => o.status === 'error'))
226
+ process.exit(1);
377
227
  return;
378
228
  }
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];
229
+ case 'detect': {
230
+ const detection = runDetect({ maxRoutes: common.maxRoutes, framework: values.framework, base: values.base });
231
+ console.log(JSON.stringify(detection, null, values.json ? 2 : 0));
232
+ return;
391
233
  }
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);
234
+ case 'login': {
235
+ if (!positionals[0]) {
236
+ console.error('Usage: pre-post login <url>');
237
+ process.exit(2);
419
238
  }
239
+ await runLogin(positionals[0]);
240
+ return;
420
241
  }
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);
242
+ case 'prune': {
243
+ const result = await runPrune({ days: num(values.days, '--days'), dryRun: values['dry-run'] });
244
+ console.log(values['dry-run'] ? 'Would remove:' : 'Removed:', result.removed.length ? result.removed.join(', ') : 'nothing');
245
+ console.log('Kept:', result.kept.length ? result.kept.join(', ') : 'nothing');
246
+ return;
247
+ }
248
+ case 'doctor': {
249
+ const checks = await runDoctor();
250
+ for (const c of checks) {
251
+ // Advisory failures are worth reading and are not failures of the
252
+ // command, so they must not look like the ones that are.
253
+ const status = c.ok ? 'ok' : c.required ? 'FAIL' : 'note';
254
+ console.log(`${c.name.padEnd(10)} ${status.padEnd(4)} ${c.detail}`);
446
255
  }
256
+ const code = doctorExitCode(checks);
257
+ console.log(code === 0
258
+ ? 'ready — pre-post pr can run'
259
+ : `not ready — fix: ${checks.filter(c => c.required && !c.ok).map(c => c.name).join(', ')}`);
260
+ process.exit(code);
447
261
  }
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
262
  case 'compare':
485
- return runCompare(undefined, videoOpts);
486
- case 'run':
487
- return runFull(videoOpts);
488
- default:
489
- return runDefault(videoOpts);
263
+ default: {
264
+ if (!before || !after) {
265
+ console.error('Two URLs (or two PNG files) are required.');
266
+ process.exit(2);
267
+ }
268
+ output(await runCompare({ ...common, before, after }));
269
+ return;
270
+ }
490
271
  }
491
272
  }
492
273
  main()
493
274
  .then(() => process.exit(0))
494
- .catch((err) => {
495
- console.error(err.message);
275
+ .catch(err => {
276
+ if (err instanceof NeedsHumanError) {
277
+ console.error(`\n${err.message}`);
278
+ process.exit(3);
279
+ }
280
+ console.error(err?.stack && process.env.PRE_POST_DEBUG ? err.stack : `Error: ${err?.message || err}`);
496
281
  process.exit(1);
497
282
  });
498
283
  //# sourceMappingURL=cli.js.map