@juangadm/pre-post 1.3.0 → 1.5.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 (106) hide show
  1. package/README.md +43 -288
  2. package/assets/labels/post.png +0 -0
  3. package/assets/labels/pre.png +0 -0
  4. package/dist/attach.d.ts +59 -0
  5. package/dist/attach.d.ts.map +1 -0
  6. package/dist/attach.js +137 -0
  7. package/dist/attach.js.map +1 -0
  8. package/dist/baseline.d.ts +26 -1
  9. package/dist/baseline.d.ts.map +1 -1
  10. package/dist/baseline.js +92 -19
  11. package/dist/baseline.js.map +1 -1
  12. package/dist/bin/cli.js +40 -16
  13. package/dist/bin/cli.js.map +1 -1
  14. package/dist/browser.d.ts +79 -3
  15. package/dist/browser.d.ts.map +1 -1
  16. package/dist/browser.js +179 -38
  17. package/dist/browser.js.map +1 -1
  18. package/dist/commands/compare.d.ts.map +1 -1
  19. package/dist/commands/compare.js +5 -5
  20. package/dist/commands/compare.js.map +1 -1
  21. package/dist/commands/detect.d.ts +5 -0
  22. package/dist/commands/detect.d.ts.map +1 -1
  23. package/dist/commands/detect.js +3 -0
  24. package/dist/commands/detect.js.map +1 -1
  25. package/dist/commands/doctor.d.ts +6 -0
  26. package/dist/commands/doctor.d.ts.map +1 -1
  27. package/dist/commands/doctor.js +21 -0
  28. package/dist/commands/doctor.js.map +1 -1
  29. package/dist/commands/pr.d.ts +5 -0
  30. package/dist/commands/pr.d.ts.map +1 -1
  31. package/dist/commands/pr.js +167 -113
  32. package/dist/commands/pr.js.map +1 -1
  33. package/dist/comparison.d.ts +5 -0
  34. package/dist/comparison.d.ts.map +1 -1
  35. package/dist/comparison.js +5 -5
  36. package/dist/comparison.js.map +1 -1
  37. package/dist/compose.d.ts +72 -0
  38. package/dist/compose.d.ts.map +1 -0
  39. package/dist/compose.js +237 -0
  40. package/dist/compose.js.map +1 -0
  41. package/dist/diff-worker.d.ts +1 -0
  42. package/dist/diff-worker.d.ts.map +1 -1
  43. package/dist/diff-worker.js +1 -0
  44. package/dist/diff-worker.js.map +1 -1
  45. package/dist/diff.d.ts +42 -0
  46. package/dist/diff.d.ts.map +1 -1
  47. package/dist/diff.js +142 -24
  48. package/dist/diff.js.map +1 -1
  49. package/dist/doctor.d.ts +4 -1
  50. package/dist/doctor.d.ts.map +1 -1
  51. package/dist/doctor.js +5 -1
  52. package/dist/doctor.js.map +1 -1
  53. package/dist/drift.d.ts +29 -0
  54. package/dist/drift.d.ts.map +1 -0
  55. package/dist/drift.js +106 -0
  56. package/dist/drift.js.map +1 -0
  57. package/dist/errors.d.ts +6 -0
  58. package/dist/errors.d.ts.map +1 -1
  59. package/dist/errors.js +6 -0
  60. package/dist/errors.js.map +1 -1
  61. package/dist/ffmpeg.d.ts +35 -0
  62. package/dist/ffmpeg.d.ts.map +1 -0
  63. package/dist/ffmpeg.js +143 -0
  64. package/dist/ffmpeg.js.map +1 -0
  65. package/dist/index.d.ts +4 -0
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +2 -0
  68. package/dist/index.js.map +1 -1
  69. package/dist/moments.d.ts +83 -0
  70. package/dist/moments.d.ts.map +1 -0
  71. package/dist/moments.js +170 -0
  72. package/dist/moments.js.map +1 -0
  73. package/dist/record.d.ts +69 -0
  74. package/dist/record.d.ts.map +1 -0
  75. package/dist/record.js +223 -0
  76. package/dist/record.js.map +1 -0
  77. package/dist/report.d.ts +29 -1
  78. package/dist/report.d.ts.map +1 -1
  79. package/dist/report.js +174 -42
  80. package/dist/report.js.map +1 -1
  81. package/dist/routes/imports.d.ts +21 -1
  82. package/dist/routes/imports.d.ts.map +1 -1
  83. package/dist/routes/imports.js +32 -2
  84. package/dist/routes/imports.js.map +1 -1
  85. package/dist/routes.d.ts +18 -0
  86. package/dist/routes.d.ts.map +1 -1
  87. package/dist/routes.js +110 -29
  88. package/dist/routes.js.map +1 -1
  89. package/dist/run.d.ts +48 -12
  90. package/dist/run.d.ts.map +1 -1
  91. package/dist/run.js +140 -18
  92. package/dist/run.js.map +1 -1
  93. package/dist/timeline.d.ts +46 -0
  94. package/dist/timeline.d.ts.map +1 -0
  95. package/dist/timeline.js +80 -0
  96. package/dist/timeline.js.map +1 -0
  97. package/dist/types.d.ts +95 -1
  98. package/dist/types.d.ts.map +1 -1
  99. package/dist/types.js +8 -0
  100. package/dist/types.js.map +1 -1
  101. package/dist/video.d.ts +19 -0
  102. package/dist/video.d.ts.map +1 -0
  103. package/dist/video.js +109 -0
  104. package/dist/video.js.map +1 -0
  105. package/package.json +4 -2
  106. package/skill/SKILL.md +38 -10
package/README.md CHANGED
@@ -1,330 +1,85 @@
1
1
  # pre-post
2
2
 
3
- Before/after screenshots for pull requests. One command detects the routes your branch
4
- changed, captures each one before and after, uploads the images to GitHub, and puts them at
5
- the top of the PR description where a reviewer can judge them at a glance.
3
+ **See the change before you read the diff.**
6
4
 
7
- It works out both sides itself, cheapest option first:
8
-
9
- | | Pre (the baseline) | Post (this branch) |
10
- |---|---|---|
11
- | 1 | `--before` | `--after` |
12
- | 2 | `before` in `.pre-post.json` | the preview deployment for this commit |
13
- | 3 | the production deployment for the base commit | a local dev server |
14
- | 4 | whatever is on production now | |
15
- | 5 | the base commit, served locally | |
16
-
17
- Rows 2 to 4 need no dev environment at all, which is the point: a preview deployment and a
18
- production URL are enough for anyone who can open the PR. Deployments come from the GitHub
19
- Deployments API, so Vercel, Cloudflare Pages, Netlify and Render all work with no extra
20
- token and nothing to configure; a host that records only a commit status is read from the
21
- deployment bot's own PR comment instead. Row 4 covers repositories that do not deploy every
22
- push to their default branch, so nothing is deployed at the fork point — it prints which
23
- commit Pre actually came from rather than implying the base.
24
-
25
- pre-post never guesses a baseline. If no deployment can be found it says so and tells you
26
- the one flag that fixes it, because a baseline that is quietly the wrong site reads as 100%
27
- changed on every route and looks exactly like a real result.
28
-
29
- The last baseline needs no network at all: it checks the base commit into a throwaway
30
- worktree and boots its dev script. That keeps pre-post working inside a sandboxed agent
31
- container, a CI job, or behind an egress allowlist — and it compares against exactly what
32
- the branch forked from, rendered in the same browser as the Post side.
33
-
34
- > Originally forked from [before-and-after](https://github.com/vercel-labs/before-and-after) by James Clements / Vercel Labs.
5
+ pre-post puts before/after screenshots of every page your branch touched at the top of the
6
+ pull request. One command finds the changed routes, captures Pre and Post, highlights what
7
+ moved, and updates the PR description. No setup, no pasting images.
35
8
 
36
9
  ```
37
10
  $ npx -y @juangadm/pre-post@latest pr
38
11
 
39
12
  Routes (nextjs-app, 41ms): /, /pricing
40
- / medium src/app/page.tsx imports src/components/ui/button.tsx (2 hops)
41
- /pricing medium src/app/pricing/page.tsx imports src/components/ui/button.tsx (2 hops)
42
- Capturing 8 screenshots (2 route(s) × 2 viewport(s)) ...
43
- changed /pricing @ mobile (1.42%, 1811ms)
13
+ Capturing 4 screenshots (2 route(s) × 1 viewport(s)) ...
44
14
  changed /pricing @ desktop (0.64%, 2036ms)
45
- same / @ mobile (0.00%, 1990ms)
46
15
  same / @ desktop (0.00%, 2211ms)
47
- Publishing 10 image(s) to acme/web@pre-post-assets ...
48
16
  Updated PR description: https://github.com/acme/web/pull/42
49
- pre-post · PR #42 · 2 route(s) · 2 viewport(s) · 6.8s
50
- / desktop no change
51
- / mobile no change
52
- /pricing desktop 0.64% changed
53
- /pricing mobile 1.4% changed
54
- Comment: https://github.com/acme/web/pull/42
55
17
  ```
56
18
 
57
- ## How it works
58
-
59
- 1. **Routes.** Diffs the branch against the merge base with `main` — fetching that branch
60
- first when the checkout does not have it, which is the normal shape in CI and in the
61
- web/sandbox editors. When no shared history can be established it stops with one
62
- sentence rather than reporting an empty diff, and `--base <ref>` names the commit
63
- directly. Then it follows the import
64
- graph: a change to `components/ui/button.tsx` marks every page that imports it. Next.js
65
- App Router and Pages Router, Vite apps (React Router, file-based `src/pages`), and a
66
- generic fallback. Monorepos are handled by picking the app that owns the changed files.
67
- 2. **Capture.** Playwright + Chromium headless shell. The page's own clock is held
68
- still while it loads and then run forward by a fixed budget, so a page that animates
69
- on a timer is photographed at the same frame on both sides however fast each host
70
- answered. Reduced motion, animations finished, caret hidden, fonts and images settled,
71
- layout stable, lazy content primed. 2x device scale, full page (capped at 2400 CSS
72
- px), desktop (add mobile with `-r`).
73
- All routes and viewports run concurrently.
74
- 3. **Diff.** Pure-JS pixel comparison in worker threads. Reports the percentage changed,
75
- the bounding box, and a tight crop of the changed region. A route counts as changed when
76
- the painted difference covers at least `minChangedArea` CSS pixels² (default 100, roughly
77
- a third of a 16px icon) or at least `threshold` of the canvas — the first rule is what
78
- decides on a page, the second on a small image.
79
- 4. **Layout shift.** A padding change near the top of a page moves everything below it, and
80
- pixel diffing counts every moved pixel as changed: a change a designer would call
81
- "slightly roomier" reads as most of the page repainted, and the crop is suppressed just
82
- when it would be most useful. So the two sides are checked for a single vertical offset
83
- first — how far the content moved, and from which row. When one is found, Pre is re-spaced
84
- into Post's layout and the two are compared there: the crop comes from that pair, and the
85
- comment says `Content shifted down 48px` instead of quoting a percentage. Rows Post gained
86
- are left as background rather than skipped, so content inserted above the shift — the
87
- banner that caused it — still reads as new rather than as nothing. The offset is only
88
- accepted when it accounts for most of the difference across the rows both sides share.
89
- The raw numbers are left alone: a move is a visual change, and reporting less of one would
90
- hide it. Reflow, where content moves both across and down, has no single offset to find,
91
- so it is reported the way it always was.
92
- 5. **Publish.** Images go to a `pre-post-assets` branch in the same repository via the GitHub
93
- API, as one commit per run. Nothing is committed to the PR branch, no CI is triggered,
94
- and the blob URLs render on private repos — a screenshot is visible to exactly whoever
95
- can see the repository. `pre-post prune` removes images for PRs closed more than 90 days
96
- ago, but note that it commits a deletion rather than rewriting history: the older commits
97
- still hold the blobs, so a link handed out earlier keeps working. Treat anything captured
98
- as permanent, and think twice before pointing pre-post at a preview holding real data.
99
- See [Screenshot storage](#screenshot-storage) for how fast the branch grows.
100
- 6. **Describe.** The images go in a delimited block at the top of the PR description,
101
- replaced in place on every run and leaving your own text untouched. Changed routes show
102
- a Pre/Post crop with the full page collapsed underneath; unchanged routes fold into a
103
- single line. A pure layout shift — nothing changed once the move is undone — has no
104
- region to crop, so it shows the full pages directly under the `Content shifted down 40px.
105
- Nothing else changed.` line, with no collapsed section repeating them. If the PR cannot be edited — a fork, a read-only token — it falls back to
106
- one sticky comment.
19
+ ## Get started
107
20
 
108
- ## Install
21
+ Pick one. None of them installs anything permanently.
109
22
 
110
- Two ways. Neither installs anything permanently.
111
-
112
- ### Claude Code — easiest
23
+ **In Claude Code**: install the skill once, then say `/pre-post` after a UI change.
113
24
 
114
25
  ```bash
115
26
  npx skills add juangadm/pre-post -y
116
27
  ```
117
28
 
118
- Then say `/pre-post` after a UI change. Install once, never type a flag. No npm
119
- knowledge needed.
120
-
121
- ### Terminal
29
+ **In a terminal**: run it on a branch with an open PR.
122
30
 
123
31
  ```bash
124
32
  npx -y @juangadm/pre-post@latest pr
125
33
  ```
126
34
 
127
- Run it on a branch with an open PR. No install step.
35
+ **On every PR, automatically**: add the [GitHub Action](https://github.com/juangadm/pre-post/blob/main/docs/github-action.md). No tokens or
36
+ secrets needed.
128
37
 
129
- ### Both need
38
+ ### You'll need
130
39
 
131
- | | |
132
- |---|---|
133
- | **Node 20+** | The one hard requirement. Check with `node --version`. |
134
- | **A GitHub token** | `gh auth login`, or set `GH_TOKEN`. Needs write access — it publishes images and edits the PR. |
135
- | **An open PR** | Where the images go. Without one, pre-post prints the markdown to paste. |
40
+ - **Node 20+**
41
+ - **A GitHub token** with write access: `gh auth login`, or set `GH_TOKEN`
42
+ - **An open PR**: without one, pre-post prints the markdown for you to paste
136
43
 
137
- First run also pulls a Chromium headless shell (~80 MB), then reuses it. Stuck? Run
44
+ The first run downloads a small browser (~80 MB). Stuck? Run
138
45
  `npx -y @juangadm/pre-post@latest doctor`.
139
46
 
140
- ### GitHub Action: every PR, no tokens
141
-
142
- For a team, or for work done in cloud agent sessions: GitHub runs pre-post on each push to a
143
- PR and posts with the job's own token. Nobody sets up anything per person, and there are no
144
- secrets to add. Add `.github/workflows/pre-post.yml`:
145
-
146
- ```yaml
147
- name: pre-post
148
- on: pull_request
149
- permissions: { contents: write, pull-requests: write }
150
- concurrency: { group: 'pre-post-${{ github.event.pull_request.number }}', cancel-in-progress: true }
151
- jobs:
152
- pre-post:
153
- if: github.event.pull_request.head.repo.full_name == github.repository
154
- runs-on: ubuntu-latest
155
- steps:
156
- - uses: actions/checkout@v4
157
- with: { ref: '${{ github.event.pull_request.head.sha }}', fetch-depth: 0 }
158
- - uses: juangadm/pre-post@v1
159
- ```
160
-
161
- The runner builds the base commit and the PR's commit and serves both, the same way pre-post
162
- does on a laptop with nothing deployed. No preview deployment is involved, so Vercel's
163
- Deployment Protection never gets in the way.
164
-
165
- - **Your app must start with its dev script without secrets.** If it needs environment
166
- variables, give them to the job with `env:`.
167
- - **pnpm or yarn** comes from the `packageManager` field in package.json. Without one, set
168
- it up before the pre-post step (e.g. `pnpm/action-setup`), as any other job would.
169
- - **Fork PRs are skipped:** their token cannot write, so there is nowhere to post.
170
- - **Flags** go in `with: { args: '--responsive' }`.
171
- - **The run's images**, including `sheet.png`, are kept as a workflow artifact even when
172
- posting fails.
173
- - **Pinned:** `@v1` runs the pre-post version released with that tag, never whatever npm
174
- calls `latest`.
175
-
176
- ## Usage
47
+ ## Everyday commands
177
48
 
178
49
  ```bash
179
- pre-post pr # everything, on the current branch's PR
180
- pre-post pr --before https://acme.com # pin the baseline (saved to .pre-post.json)
181
- pre-post pr --no-local-baseline # never build the base commit locally
182
- pre-post pr --routes /pricing,/docs # explicit routes
183
- pre-post pr --viewports desktop,1440x900 # custom viewports
184
- pre-post pr --dry-run # capture + diff locally, post nothing
185
- pre-post pr --json # machine-readable output
186
-
187
- pre-post https://acme.com http://localhost:3000 --routes /pricing # ad-hoc comparison
188
- pre-post before.png after.png # diff two images
189
- pre-post detect # which routes does this branch touch?
190
- pre-post login https://staging.acme.com # sign in once; the session is reused
191
- pre-post prune --days 90 # clean up the assets branch
192
- pre-post doctor # browser, token, dev server, config
193
- ```
194
-
195
- When something needs a human, the CLI exits with code 3 and one sentence saying what to do
196
- (log in, start the dev server, pass `--before`). Re-running picks up where it left off.
197
-
198
- Exit codes, so a script or an agent can branch on them:
199
-
200
- | code | meaning |
201
- |---|---|
202
- | 0 | done; for `doctor`, `pre-post pr` can run |
203
- | 1 | the run failed — every capture errored, or an unexpected error; for `doctor`, a required check failed |
204
- | 2 | the arguments could not be parsed |
205
- | 3 | something needs a human; the message says what |
206
-
207
- `doctor` marks a check **FAIL** only when `pre-post pr` has no way to proceed without it —
208
- the browser, a GitHub token, and being inside a git repository. Everything else prints as
209
- **note**: no dev server running, or no `--before` saved, narrows *which* strategy a run
210
- picks rather than stopping it, so those never change the exit code.
211
-
212
- Results go into a delimited block at the top of the PR description, which re-runs replace in
213
- place, leaving your own text untouched. If the PR cannot be edited — a fork, a read-only
214
- token — it falls back to a single sticky comment.
215
-
216
- ## Screenshot storage
217
-
218
- Every real run adds one commit to the `pre-post-assets` branch holding that run's images —
219
- roughly 100–200 KB for a small PR, and again on every re-run. As a rough guide, 20 PRs a
220
- week at 3 runs each is about 9 MB a week, or ~450 MB a year.
221
-
222
- Two things to know before that adds up:
223
-
224
- - **`prune` tidies the branch; it does not shrink the repository.** It commits a deletion,
225
- so the images stay in history and every link already posted in a PR keeps working. The
226
- repository's size only ever grows.
227
- - **A plain `git clone` downloads every branch**, screenshots included. Teammates who never
228
- look at them can skip the branch in an existing clone (git 2.29+):
229
-
230
- ```bash
231
- git config --add remote.origin.fetch '^refs/heads/pre-post-assets'
232
- ```
233
-
234
- A weekly prune keeps the branch's own listing short. As a scheduled workflow:
235
-
236
- ```yaml
237
- # .github/workflows/pre-post-prune.yml
238
- on:
239
- schedule: [{ cron: '17 6 * * 1' }] # Mondays
240
- workflow_dispatch:
241
- permissions: { contents: write, pull-requests: read }
242
- jobs:
243
- prune:
244
- runs-on: ubuntu-latest
245
- steps:
246
- - uses: actions/checkout@v4
247
- - run: npx -y @juangadm/pre-post@latest prune --days 90
248
- env: { GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' }
50
+ pre-post pr # screenshot this branch's PR
51
+ pre-post pr --routes /pricing,/docs # only these pages
52
+ pre-post pr -r # desktop and mobile
53
+ pre-post pr --before https://acme.com # set the "before" site (remembered)
54
+ pre-post pr --dry-run # try it without posting
55
+ pre-post login https://staging.acme.com # sign in once for protected sites
56
+ pre-post doctor # check your setup
249
57
  ```
250
58
 
251
- It removes folders for PRs closed more than 90 days ago, and runs made before a PR was
252
- opened once they are that old. Actually reclaiming the space means rewriting the branch's
253
- history, which also breaks the images in those older PRs — so it is left as your call.
254
-
255
- ## Configuration
256
-
257
- Optional `.pre-post.json` in the repo root:
59
+ If pre-post needs something from you, it stops with one sentence saying exactly what.
258
60
 
259
- ```json
260
- {
261
- "before": "https://acme.com",
262
- "routes": ["/"],
263
- "samples": { "/blog/[slug]": "/blog/hello-world" },
264
- "viewports": ["desktop"],
265
- "fullPage": true,
266
- "maxHeight": 2400,
267
- "scale": 2,
268
- "threshold": 0.001,
269
- "minChangedArea": 100,
270
- "maxRoutes": 6,
271
- "ignore": ["apps/docs"],
272
- "headers": {},
273
- "assetsBranch": "pre-post-assets",
274
- "baselineSetup": "pnpm run build:packages"
275
- }
276
- ```
277
-
278
- `baselineSetup` runs in the app directory between the install and the dev server when the
279
- baseline is built from source. Use it for whatever the app needs before it can boot — a
280
- workspace build, a codegen step. In a turborepo it is inferred: `turbo run build
281
- --filter=<app>^...`, which builds the packages the app imports and leaves the app to the
282
- dev server. Setting it turns the guess off.
61
+ ## How it decides what "before" is
283
62
 
284
- A command that fails stops the run with one instruction, rather than quietly comparing
285
- against something else.
63
+ Post (your branch) is the PR's preview deployment, or a local dev server. Pre is the
64
+ production site your branch forked from. When nothing is deployed, pre-post builds the base
65
+ commit itself, so it works in CI and in sandboxes too. It never guesses: if it can't find a
66
+ trustworthy baseline, it tells you which flag to pass.
286
67
 
287
- Env files copied into the baseline's worktree get their origin variables rewritten to the
288
- port the baseline actually listens on, before the setup step runs so a build cannot bake in
289
- the old one. Without this an auth-gated app rejects its own callbacks and every capture is a
290
- loading skeleton. The names rewritten are the ones that can only mean the app's own address
291
- — `BETTER_AUTH_URL`, `NEXTAUTH_URL`, `AUTH_URL`, `APP_URL`, `SITE_URL`, `CANONICAL_URL` and
292
- their `_ORIGIN`/`_BASE_URL` forms, with a `NEXT_PUBLIC_` / `VITE_` / `PUBLIC_` prefix
293
- optional. Generic names are left alone: `BASE_URL`, `SERVER_URL` and `PUBLIC_URL` are as
294
- often a backend on another port or a path prefix, and `DATABASE_URL` is never an origin.
68
+ Screenshots are stored on a `pre-post-assets` branch in your own repo, so only people who can
69
+ see the repo can see them. Nothing is committed to your PR branch.
295
70
 
296
- Environment:
71
+ ## Learn more
297
72
 
298
- | Variable | Purpose |
73
+ | | |
299
74
  |---|---|
300
- | `PRE_POST_GH_TOKEN` | GitHub token read before the two below. Use it where a hosted environment sets `GH_TOKEN` itself |
301
- | `GH_TOKEN` / `GITHUB_TOKEN` | GitHub token (default: `gh auth token`) |
302
- | `VERCEL_AUTOMATION_BYPASS_SECRET` | Bypass Vercel Deployment Protection on preview and production URLs |
303
- | `PRE_POST_CONCURRENCY` | Parallel pages (default 6) |
304
- | `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` | Use a specific Chromium binary |
305
- | `GH_REPO` | `owner/repo` when the remote URL cannot be parsed |
306
-
307
- ## Library
308
-
309
- ```ts
310
- import { runPr, captureScreenshot, diffImages, detectRoutesForRepo } from '@juangadm/pre-post';
311
- ```
312
-
313
- ## Development
314
-
315
- ```bash
316
- pnpm install
317
- pnpm build
318
- pnpm test:unit
319
- TEST_BROWSER=true pnpm test # needs a Chromium; npx playwright-core install chromium-headless-shell
320
- ```
321
-
322
- pnpm and npm cannot share a `node_modules`. pnpm builds a symlinked tree that npm
323
- cannot read, so `npm install` over it fails with `Cannot read properties of null
324
- (reading 'edgesOut')`, which names none of that. Run `rm -rf node_modules
325
- site/node_modules` before switching either way. npm also installs the CLI only —
326
- `site/` is a pnpm workspace member, so use pnpm to work on the site.
75
+ | [How it works](https://github.com/juangadm/pre-post/blob/main/docs/how-it-works.md) | How Pre and Post are chosen, capture, diffing, layout shifts |
76
+ | [GitHub Action](https://github.com/juangadm/pre-post/blob/main/docs/github-action.md) | Run on every PR with no per-person setup |
77
+ | [Video: Moments](https://github.com/juangadm/pre-post/blob/main/docs/video.md) | Record interactions (menus, hovers, transitions) as synced Pre \| Post clips |
78
+ | [Reference](https://github.com/juangadm/pre-post/blob/main/docs/reference.md) | All commands, exit codes, `.pre-post.json`, environment variables |
79
+ | [Screenshot storage](https://github.com/juangadm/pre-post/blob/main/docs/storage.md) | How much space it uses and how to prune it |
80
+ | [Contributing](https://github.com/juangadm/pre-post/blob/main/CONTRIBUTING.md) | Build and test locally |
327
81
 
328
- ## License
82
+ ## Credits
329
83
 
330
- MIT
84
+ Forked from [before-and-after](https://github.com/vercel-labs/before-and-after) by James
85
+ Clements / Vercel Labs. MIT licensed.
Binary file
Binary file
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Put clips where GitHub will play them.
3
+ *
4
+ * A video plays inline in a PR only from GitHub's own attachment storage
5
+ * (`github.com/user-attachments/assets/…`); a file on the assets branch is a
6
+ * download link at best. Since 2026-09-01 `gh` uploads there with `--attach`
7
+ * (gh 2.99+, a person's login or a classic PAT — the Actions GITHUB_TOKEN is
8
+ * refused), rewriting a local reference in the text it posts into the
9
+ * attachment URL.
10
+ *
11
+ * The description itself is written by the same code as always, so the clips
12
+ * are uploaded on their own first: a temporary comment carries them up, their
13
+ * URLs are read back, and the comment is deleted — the flow vercel-labs'
14
+ * before-and-after settled on. Anything that goes wrong leaves the caller to
15
+ * fall back to the assets branch. The cost: PR subscribers are notified of a
16
+ * comment that is gone by the time they look. Passing `--attach` to the
17
+ * description edit itself would avoid it, but the sticky-comment fallback
18
+ * would still need this flow.
19
+ */
20
+ import { GitHub } from './github.js';
21
+ import { MomentOutcome } from './types.js';
22
+ /** The first gh release whose `--attach` works on PRs and comments. */
23
+ export declare const MIN_GH_FOR_ATTACH: readonly [2, 99, 0];
24
+ export declare const UPDATE_GH_HINT = "Update GitHub CLI to 2.99 or newer (https://cli.github.com, or `brew upgrade gh`) to play videos inline in the PR.";
25
+ /** `gh version 2.101.0 (2026-09-20)` → [2, 101, 0]. */
26
+ export declare function parseGhVersion(output: string): number[] | null;
27
+ export declare function atLeast(version: number[], min: readonly number[]): boolean;
28
+ /** Whether this machine's gh can attach video, and if not the sentence that fixes it. */
29
+ export declare function ghAttachSupport(): {
30
+ ok: true;
31
+ } | {
32
+ ok: false;
33
+ reason: string;
34
+ };
35
+ /** The attachment URLs in a comment body, in the order they appear. */
36
+ export declare function attachmentUrls(body: string): string[];
37
+ /**
38
+ * Upload `files` (all in one folder) as PR attachments. Returns file → URL.
39
+ * Throws when any of them does not come back as an attachment.
40
+ */
41
+ export declare function uploadAttachments(gh: GitHub, ownerRepo: string, prNumber: number, files: string[], token?: string): Promise<Map<string, string>>;
42
+ export interface AttachOptions {
43
+ gh: GitHub;
44
+ ownerRepo: string;
45
+ /** No PR yet means nowhere to attach to. */
46
+ pr?: number;
47
+ token?: string;
48
+ log?: (msg: string) => void;
49
+ }
50
+ /**
51
+ * Attach recorded clips to the PR where possible, marking each one inline.
52
+ * Returns the clips left for the assets branch, and the one sentence that
53
+ * would make them play when there are any.
54
+ */
55
+ export declare function attachClips(clips: MomentOutcome[], opts: AttachOptions): Promise<{
56
+ linked: MomentOutcome[];
57
+ hint?: string;
58
+ }>;
59
+ //# sourceMappingURL=attach.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attach.d.ts","sourceRoot":"","sources":["../src/attach.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAMH,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AACrC,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAE3C,uEAAuE;AACvE,eAAO,MAAM,iBAAiB,qBAAsB,CAAC;AAErD,eAAO,MAAM,cAAc,uHAAuH,CAAC;AAKnJ,uDAAuD;AACvD,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,CAG9D;AAED,wBAAgB,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,GAAG,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAK1E;AAED,yFAAyF;AACzF,wBAAgB,eAAe,IAAI;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAQ9E;AAED,uEAAuE;AACvE,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAErD;AAmBD;;;GAGG;AACH,wBAAsB,iBAAiB,CACrC,EAAE,EAAE,MAAM,EACV,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EAAE,EACf,KAAK,CAAC,EAAE,MAAM,GACb,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CA4B9B;AAED,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,SAAS,EAAE,MAAM,CAAC;IAClB,4CAA4C;IAC5C,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAC7B;AAED;;;;GAIG;AACH,wBAAsB,WAAW,CAAC,KAAK,EAAE,aAAa,EAAE,EAAE,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC;IAAE,MAAM,EAAE,aAAa,EAAE,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAkBlI"}
package/dist/attach.js ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Put clips where GitHub will play them.
3
+ *
4
+ * A video plays inline in a PR only from GitHub's own attachment storage
5
+ * (`github.com/user-attachments/assets/…`); a file on the assets branch is a
6
+ * download link at best. Since 2026-09-01 `gh` uploads there with `--attach`
7
+ * (gh 2.99+, a person's login or a classic PAT — the Actions GITHUB_TOKEN is
8
+ * refused), rewriting a local reference in the text it posts into the
9
+ * attachment URL.
10
+ *
11
+ * The description itself is written by the same code as always, so the clips
12
+ * are uploaded on their own first: a temporary comment carries them up, their
13
+ * URLs are read back, and the comment is deleted — the flow vercel-labs'
14
+ * before-and-after settled on. Anything that goes wrong leaves the caller to
15
+ * fall back to the assets branch. The cost: PR subscribers are notified of a
16
+ * comment that is gone by the time they look. Passing `--attach` to the
17
+ * description edit itself would avoid it, but the sticky-comment fallback
18
+ * would still need this flow.
19
+ */
20
+ import fs from 'fs';
21
+ import os from 'os';
22
+ import path from 'path';
23
+ import { spawn, spawnSync } from 'child_process';
24
+ /** The first gh release whose `--attach` works on PRs and comments. */
25
+ export const MIN_GH_FOR_ATTACH = [2, 99, 0];
26
+ export const UPDATE_GH_HINT = 'Update GitHub CLI to 2.99 or newer (https://cli.github.com, or `brew upgrade gh`) to play videos inline in the PR.';
27
+ const ATTACHMENT_URL = /https:\/\/github\.com\/user-attachments\/assets\/[\w-]+/g;
28
+ const UPLOAD_MARKER = '<!-- pre-post:upload (temporary; deleted once the clips are uploaded) -->';
29
+ /** `gh version 2.101.0 (2026-09-20)` → [2, 101, 0]. */
30
+ export function parseGhVersion(output) {
31
+ const m = /gh version (\d+)\.(\d+)\.(\d+)/.exec(output);
32
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
33
+ }
34
+ export function atLeast(version, min) {
35
+ for (let i = 0; i < min.length; i++) {
36
+ if ((version[i] ?? 0) !== min[i])
37
+ return (version[i] ?? 0) > min[i];
38
+ }
39
+ return true;
40
+ }
41
+ /** Whether this machine's gh can attach video, and if not the sentence that fixes it. */
42
+ export function ghAttachSupport() {
43
+ const probe = spawnSync('gh', ['--version'], { encoding: 'utf-8' });
44
+ if (probe.error || probe.status !== 0) {
45
+ return { ok: false, reason: 'Install GitHub CLI 2.99 or newer (https://cli.github.com) to play videos inline in the PR.' };
46
+ }
47
+ const version = parseGhVersion(probe.stdout);
48
+ if (!version || !atLeast(version, MIN_GH_FOR_ATTACH))
49
+ return { ok: false, reason: UPDATE_GH_HINT };
50
+ return { ok: true };
51
+ }
52
+ /** The attachment URLs in a comment body, in the order they appear. */
53
+ export function attachmentUrls(body) {
54
+ return body.match(ATTACHMENT_URL) ?? [];
55
+ }
56
+ function runGh(args, cwd, token) {
57
+ return new Promise((resolve, reject) => {
58
+ const child = spawn('gh', args, {
59
+ cwd,
60
+ // The token pre-post already uses, so gh acts as the same identity.
61
+ env: token ? { ...process.env, GH_TOKEN: token } : process.env,
62
+ stdio: ['ignore', 'pipe', 'pipe'],
63
+ });
64
+ let out = '';
65
+ let err = '';
66
+ child.stdout.on('data', d => { out += d; });
67
+ child.stderr.on('data', d => { err += d; });
68
+ child.on('error', reject);
69
+ child.on('close', code => code === 0 ? resolve(out) : reject(new Error(err.trim().split('\n').pop() || `gh exited ${code}`)));
70
+ });
71
+ }
72
+ /**
73
+ * Upload `files` (all in one folder) as PR attachments. Returns file → URL.
74
+ * Throws when any of them does not come back as an attachment.
75
+ */
76
+ export async function uploadAttachments(gh, ownerRepo, prNumber, files, token) {
77
+ if (!files.length)
78
+ return new Map();
79
+ const dir = path.dirname(files[0]);
80
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'pre-post-upload-'));
81
+ const bodyFile = path.join(scratch, 'body.md');
82
+ // `--attach` rewrites references to the files it is given, resolved from
83
+ // gh's working directory, so both use the same relative spelling.
84
+ const refs = files.map(f => `./${path.basename(f)}`);
85
+ fs.writeFileSync(bodyFile, [UPLOAD_MARKER, '', ...refs.map(r => `![clip](${r})`), ''].join('\n'));
86
+ try {
87
+ const out = await runGh([
88
+ 'pr', 'comment', String(prNumber), '--repo', ownerRepo, '--body-file', bodyFile,
89
+ ...refs.flatMap(r => ['--attach', r]),
90
+ ], dir, token);
91
+ const id = /#issuecomment-(\d+)/.exec(out)?.[1];
92
+ if (!id)
93
+ throw new Error('gh did not say which comment it posted');
94
+ const comment = await gh.request('GET', `/repos/${ownerRepo}/issues/comments/${id}`);
95
+ const urls = attachmentUrls(comment.body ?? '');
96
+ if (urls.length !== files.length) {
97
+ // Kept, not deleted: the comment is the only place the uploads are listed.
98
+ throw new Error(`expected ${files.length} attachment(s), GitHub returned ${urls.length}`);
99
+ }
100
+ // The attachment outlives the comment that carried it up.
101
+ await gh.request('DELETE', `/repos/${ownerRepo}/issues/comments/${id}`).catch(() => undefined);
102
+ return new Map(files.map((f, i) => [f, urls[i]]));
103
+ }
104
+ finally {
105
+ fs.rmSync(scratch, { recursive: true, force: true });
106
+ }
107
+ }
108
+ /**
109
+ * Attach recorded clips to the PR where possible, marking each one inline.
110
+ * Returns the clips left for the assets branch, and the one sentence that
111
+ * would make them play when there are any.
112
+ */
113
+ export async function attachClips(clips, opts) {
114
+ const log = opts.log ?? (() => undefined);
115
+ if (!opts.pr)
116
+ return { linked: clips, hint: 'Open the PR and re-run to play the videos inline.' };
117
+ const support = ghAttachSupport();
118
+ if (!support.ok) {
119
+ log(`Videos will be linked, not inline: ${support.reason}`);
120
+ return { linked: clips, hint: support.reason };
121
+ }
122
+ try {
123
+ const urls = await uploadAttachments(opts.gh, opts.ownerRepo, opts.pr, clips.map(m => m.file), opts.token);
124
+ for (const m of clips) {
125
+ m.videoUrl = urls.get(m.file);
126
+ m.inline = true;
127
+ }
128
+ log(`Attached ${clips.length} video(s) to PR #${opts.pr}.`);
129
+ return { linked: [] };
130
+ }
131
+ catch (err) {
132
+ const hint = `GitHub did not take the videos as attachments (${err instanceof Error ? err.message : err}); they are linked instead. Inline video needs gh signed in as a person or with a classic token, not the Actions GITHUB_TOKEN.`;
133
+ log(hint);
134
+ return { linked: clips, hint };
135
+ }
136
+ }
137
+ //# sourceMappingURL=attach.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attach.js","sourceRoot":"","sources":["../src/attach.ts"],"names":[],"mappings":"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"}
@@ -11,13 +11,24 @@
11
11
  * The cost is time: a checkout, an install and a dev-server boot. So callers
12
12
  * reach for it after a reachable deployment, not before one.
13
13
  */
14
+ import { ChildProcess } from 'child_process';
14
15
  import { NeedsHumanError } from './errors.js';
15
16
  import { Stopwatch } from './timings.js';
16
17
  export interface LocalBaseline {
17
18
  url: string;
18
19
  /** Stop the dev server and delete the worktree. Safe to call twice. */
19
20
  stop: () => Promise<void>;
21
+ /** Everything the dev server printed, so a failure can be explained with its own words. */
22
+ logFile?: string;
20
23
  }
24
+ /**
25
+ * Where a dev server's output goes: a file per server, never the terminal.
26
+ *
27
+ * It used to go nowhere (`stdio: 'ignore'`), so when a server answered 500
28
+ * there was no way to say why. A file rather than a pipe: the server is
29
+ * detached and must not stall when this process stops reading.
30
+ */
31
+ export declare function serverLogFile(side: 'pre' | 'post', port: number): string;
21
32
  interface PackageManager {
22
33
  bin: string;
23
34
  install: string[];
@@ -172,7 +183,7 @@ export declare function installDeps(pm: PackageManager, cwd: string, timeoutMs:
172
183
  /**
173
184
  * The install failed and nothing else can rescue this run.
174
185
  *
175
- * Its own error rather than a quiet null, for the reason docs/portability.md
186
+ * Its own error rather than a quiet null, for the reason docs/internal/portability.md
176
187
  * §1 gives about base resolution: a run that compared nothing must not exit
177
188
  * clean. A null here fell through to "no baseline", and on a repository with a
178
189
  * configured production URL it fell through to comparing against that instead
@@ -257,6 +268,20 @@ export declare function reusableInstall(repoRoot: string, sha: string): Promise<
257
268
  * removed so the normal install starts from a clean tree.
258
269
  */
259
270
  export declare function reuseInstall(repoRoot: string, worktree: string, dirs: string[]): Promise<boolean>;
271
+ /** How long a dev server gets to exit on SIGTERM before it is killed. */
272
+ export declare const SERVER_STOP_TIMEOUT_MS = 5000;
273
+ /**
274
+ * SIGTERM the server's process group, wait for it to exit, then make sure.
275
+ *
276
+ * The wait is on the child itself — the package manager, which exits once the
277
+ * server under it has — because Node reaps it, so its exit is a fact. Asking
278
+ * the group with signal 0 was not: in a container whose PID 1 does not reap,
279
+ * dead members linger as zombies that still answer, and every stop waited out
280
+ * the full timeout. Once the child is gone, or `timeoutMs` has passed, the
281
+ * whole group is SIGKILLed, so nothing it started is left writing into a
282
+ * worktree about to be removed.
283
+ */
284
+ export declare function stopGroup(child: ChildProcess, timeoutMs?: number): Promise<void>;
260
285
  export interface BaselineOptions {
261
286
  repoRoot: string;
262
287
  /** Commit to serve. Omit to serve the working tree as it stands. */