retake-demos 0.2.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.
- package/.env.example +19 -0
- package/LICENSE +21 -0
- package/README.md +183 -0
- package/assets/icon-192.png +0 -0
- package/assets/icon-512.png +0 -0
- package/assets/logo.svg +13 -0
- package/demos/example.yaml +65 -0
- package/dist/appserver.js +316 -0
- package/dist/cli.js +467 -0
- package/dist/describe.js +377 -0
- package/dist/digest.js +197 -0
- package/dist/dryrun.js +196 -0
- package/dist/edits.js +229 -0
- package/dist/manifest.js +358 -0
- package/dist/operator/run.js +231 -0
- package/dist/operator/tools.js +412 -0
- package/dist/paths.js +39 -0
- package/dist/presets.js +75 -0
- package/dist/record.js +814 -0
- package/dist/render.js +661 -0
- package/dist/ui/chat.html +385 -0
- package/dist/ui/guide.html +85 -0
- package/dist/ui/index.html +722 -0
- package/dist/ui/server.js +1112 -0
- package/package.json +67 -0
- package/site/index.html +158 -0
- package/skill/SKILL.md +154 -0
package/.env.example
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Retake — optional. Only `retake describe` and `retake ideas` use a model; validate/dry/run/render/check never do.
|
|
2
|
+
# With a coding agent driving Retake over MCP you can leave all of this empty.
|
|
3
|
+
# Pick explicitly with RETAKE_MODEL=claude-code|codex|groq|mistral|local; otherwise the first available is used.
|
|
4
|
+
RETAKE_MODEL=claude-code
|
|
5
|
+
|
|
6
|
+
# A. the CLIs you are already signed into on this machine — no keys needed
|
|
7
|
+
# claude-code: `claude` (Claude Code) codex: `codex` (OpenAI Codex CLI)
|
|
8
|
+
# optional: RETAKE_CLAUDE_MODEL= RETAKE_CODEX_MODEL=
|
|
9
|
+
|
|
10
|
+
# A. hosted keys
|
|
11
|
+
GROQ_API_KEY=
|
|
12
|
+
RETAKE_GROQ_MODEL=openai/gpt-oss-120b
|
|
13
|
+
MISTRAL_API_KEY=
|
|
14
|
+
RETAKE_MISTRAL_MODEL=mistral-small-latest
|
|
15
|
+
|
|
16
|
+
# B. local, OpenAI-compatible (Ollama: http://localhost:11434/v1 · LM Studio: http://localhost:1234/v1)
|
|
17
|
+
RETAKE_LOCAL_URL=
|
|
18
|
+
RETAKE_LOCAL_MODEL=llama3.1
|
|
19
|
+
RETAKE_LOCAL_KEY=local
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gleb Bogachev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<p align="center"><img src="assets/hero.png" width="720" alt="Retake — rerun the demo, don't re-record it"></p>
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<b>Rerun the demo. Don't re-record it.</b><br>
|
|
5
|
+
A product walkthrough as a small YAML file. Your coding agent writes it, Retake records it, you watch and ship it.
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<p align="center">
|
|
9
|
+
<a href="https://www.npmjs.com/package/retake-demos"><img src="https://img.shields.io/npm/v/retake-demos?color=111&label=retake-demos" alt="npm"></a>
|
|
10
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-111" alt="MIT"></a>
|
|
11
|
+
<a href="https://github.com/glebbogachev00/retake/actions/workflows/ci.yml"><img src="https://github.com/glebbogachev00/retake/actions/workflows/ci.yml/badge.svg" alt="ci"></a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
Retake drives a real browser through a web app, records it, and hands back an MP4 with burned-in captions, a still for every scene, and a proof log of exactly what happened. The demo is a file, so when a button moves you change a selector instead of re-shooting — and when the app changes, you rerun the same file.
|
|
17
|
+
|
|
18
|
+
It is built for the way people work now: **you ask your coding agent for a demo in one sentence**, the agent drafts the file, dry-runs every selector, records a preview, reads the proof log, fixes what failed, and records the real one. Claude Code, Codex, Cursor and anything else that speaks MCP get Retake's tools; `retake ui` is the window you watch it in.
|
|
19
|
+
|
|
20
|
+
<p align="center"><img src="assets/ui.png" width="900" alt="The Retake window: your demos on the left, the video and its scenes in the middle"></p>
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
Needs Node 20+. ffmpeg ships with the package; Chromium is downloaded once.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install -g retake-demos
|
|
28
|
+
mkdir my-demos && cd my-demos
|
|
29
|
+
retake install
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`retake install` makes the folder a workspace (`demos/`, `outputs/`, `.env`, the `.gitignore` lines), downloads Chromium, registers the tools with Claude Code and installs its *recording-product-demos* skill, and prints the config for Codex. Restart your agent afterwards — agents load tools at the start of a session.
|
|
33
|
+
|
|
34
|
+
Prefer not to install globally? `npx retake-demos install` works the same. Don't use Claude Code? `retake agent` prints the MCP config for Codex, Cursor, or anything else.
|
|
35
|
+
|
|
36
|
+
Then, in your agent:
|
|
37
|
+
|
|
38
|
+
> Record a demo of my app at localhost:3000 showing a new user creating their first project.
|
|
39
|
+
|
|
40
|
+
and in another terminal, `retake ui` → http://localhost:4310 to watch it happen. The agent reports its plan and progress there; the video, stills and receipts land there when it's done.
|
|
41
|
+
|
|
42
|
+
`retake doctor` tells you if anything is missing.
|
|
43
|
+
|
|
44
|
+
## What you get
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
outputs/<name>/
|
|
48
|
+
demo.mp4 the shareable — captions burned in
|
|
49
|
+
master.mp4 the CRF-14 keeper (post presets only)
|
|
50
|
+
stills/ one PNG per scene, mid-scene and at its last moment
|
|
51
|
+
thumbnail.png the frame at a chosen scene
|
|
52
|
+
proof-log.md result, shot list, every step's timing, pass/fail, what was on screen when it failed
|
|
53
|
+
take.json the raw timeline the renderer reads
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
In the window: play it, switch speed (0.75× to 2×, re-rendered in seconds), download, show in Finder, open the file to edit. Demos group by the app they point at; archive what you're done with.
|
|
57
|
+
|
|
58
|
+
## The loop
|
|
59
|
+
|
|
60
|
+
Everything an agent does, you can do from the terminal:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
retake validate demos/x.yaml # schema + warnings, instant
|
|
64
|
+
retake dry demos/x.yaml # every selector and wait, no camera (~30s)
|
|
65
|
+
retake run demos/x.yaml --preset draft # first take, quarter-size, fast
|
|
66
|
+
retake check outputs/x # resolution, fps, duration, files: pass/fail
|
|
67
|
+
retake run demos/x.yaml # the real one
|
|
68
|
+
retake render outputs/x # captions/camera/speed changed? re-render, no browser
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The expensive step comes last on purpose. `dry` catches most failures in seconds and prints the step, the error, and the text that was on screen; a failed `run` costs a minute and a confusing video. When a step fails during `run`, Retake stops, keeps what it has, and the proof log says which step and why — an agent fixes it from that text, never from watching the video.
|
|
72
|
+
|
|
73
|
+
Exit codes: `0` good · `2` a step failed or the video is a raw fallback · `3` `dry`/`check` failed.
|
|
74
|
+
|
|
75
|
+
## Demo-as-code
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
name: first-project
|
|
79
|
+
title: "Creating your first project"
|
|
80
|
+
url: http://localhost:3000
|
|
81
|
+
preset: post-landscape # 1080p @ 30fps · `retake presets`
|
|
82
|
+
viewport: { width: 1440, height: 1080 }
|
|
83
|
+
scale: 1.8 # page drawn at 1.8× so text stays crisp
|
|
84
|
+
reducedMotion: true
|
|
85
|
+
camera: auto # each scene eases toward the last thing touched
|
|
86
|
+
|
|
87
|
+
seed: [] # put the app in a known state first
|
|
88
|
+
setup: [] # runs before the camera; trimmed off the video
|
|
89
|
+
|
|
90
|
+
steps:
|
|
91
|
+
- { action: scene, label: start, caption: "An empty workspace.", camera: static }
|
|
92
|
+
- { action: wait, ms: 1500 }
|
|
93
|
+
- { action: click, selector: "button:has-text('New project')", pauseAfter: 800 }
|
|
94
|
+
- { action: type, selector: "input[name=title]", text: "Q3 launch plan", delay: 40 }
|
|
95
|
+
- { action: click, selector: "button[type=submit]" }
|
|
96
|
+
- { action: waitFor, selector: ".project-header" }
|
|
97
|
+
- { action: scene, label: result, caption: "And it exists." }
|
|
98
|
+
- { action: wait, ms: 2500 }
|
|
99
|
+
|
|
100
|
+
outputs:
|
|
101
|
+
thumbnail: { scene: result }
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The full template is [`demos/example.yaml`](demos/example.yaml); `retake init` copies it into a new workspace.
|
|
105
|
+
|
|
106
|
+
**Steps** — `click`, `type`, `fill`, `hover`, `drag`, `scroll`, `keyboard`, `navigate`, `wait`, `waitFor`, `evaluate`, `upload`, `download`, `screenshot`, `scene`. Every step takes `pauseAfter`, `waitFor`, `timeout`, `secret`. Targets are a selector, or a point — `{x, y}` or `{selector, dx, dy}` — for canvases and SVG editors. `${VAR}` expands from `.env` and fails loudly when unset.
|
|
107
|
+
|
|
108
|
+
**Scenes** are the spine: `{ action: scene, label, caption }` at each beat. They take their timestamps from the actual run, so captions land on the frames they describe even when the app took 3 seconds this time and 11 the last.
|
|
109
|
+
|
|
110
|
+
**Seeds** put the app in a known state before the camera: write a JSON file, run JS in the page, or run a shell command. **Setup** is the stuff that runs before recording and is trimmed off the front — logins live there. **`tempo`** speeds the finished video up or down at render time; **`lock`** names a shared resource so two demos that touch the same backend don't record over each other.
|
|
111
|
+
|
|
112
|
+
## Quality
|
|
113
|
+
|
|
114
|
+
Think in the publishing format, not the browser window.
|
|
115
|
+
|
|
116
|
+
| Preset | Canvas | For |
|
|
117
|
+
|---|---:|---|
|
|
118
|
+
| `draft` | 960×540 @ 15fps | iterating — same layout as the finals at a quarter of the pixels, fastest |
|
|
119
|
+
| `preview-fast` | 1920×1080 @ 24fps | checking timing and framing, hardware-encoded |
|
|
120
|
+
| `post-landscape` *(default)* | 1920×1080 @ 30fps | the general-purpose demo, plus a CRF-14 master |
|
|
121
|
+
| `post-square` | 1080×1080 @ 30fps | feeds |
|
|
122
|
+
| `post-vertical` | 1080×1920 @ 30fps | shorts and reels |
|
|
123
|
+
| `docs-gif` | 1440×900 @ 24fps | README and docs GIFs |
|
|
124
|
+
| `master` | 1920×1080 @ 30fps | archive, CRF 12 |
|
|
125
|
+
|
|
126
|
+
**Page scale** is what makes it crisp: the page lays out as if the viewport were smaller and every glyph is drawn at 2×, so 1080p text reads on a phone. **Camera** eases toward whatever the demo just touched, at render time, clamped so it can never crop the thing it points at — per scene: `camera: static` or `camera: { focus: ".result", zoom: 1.3 }`. Captions, camera, speed, trim, format and layout are all render-time: change them and `retake render` takes seconds, never touches the browser.
|
|
127
|
+
|
|
128
|
+
## Signing in
|
|
129
|
+
|
|
130
|
+
```yaml
|
|
131
|
+
auth:
|
|
132
|
+
storageState: .auth/myapp.json # session saved here, reused until stale
|
|
133
|
+
maxAgeHours: 8
|
|
134
|
+
setup:
|
|
135
|
+
- { action: fill, selector: "#user", text: "${APP_USER}" }
|
|
136
|
+
- { action: fill, selector: "#password", text: "${APP_PASSWORD}", secret: true }
|
|
137
|
+
- { action: click, selector: "button[type=submit]" }
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Secrets live in `.env`, never in a manifest. `secret: true` keeps a value out of the terminal, the window and the proof log — but not out of the video, which is why `validate` warns when a secret step is in `steps` rather than `setup`. A session is only saved when the sign-in actually succeeded. Use a demo account: the output is a video you may publish.
|
|
141
|
+
|
|
142
|
+
Apps that keep their session in IndexedDB (Firebase) can't be restored from `storageState`; put the login in plain `setup` instead.
|
|
143
|
+
|
|
144
|
+
## Limits worth knowing
|
|
145
|
+
|
|
146
|
+
- **One page.** Retake records one tab. `keepInTab` (on by default) folds `window.open` and `target="_blank"` back into it, so popup logins and "preview" buttons stay on camera; flows that genuinely need a second window do not.
|
|
147
|
+
- **~45 cursor moves per take.** The cursor overlay is one nested expression per move and ffmpeg's parser has a depth limit. `validate` warns, the recorder detects it, `check` fails. Split long stories into chapters of 30–45 seconds — the viewer agrees.
|
|
148
|
+
- **`scale` uses CSS zoom**, which shifts the coordinate space some forms rely on for focus; if typing goes nowhere, `scale: 1`. (`deviceScaleFactor` is not the fix — Playwright's screencast never upscales.)
|
|
149
|
+
- **macOS is where it's used daily.** `videotoolbox` hardware encoding falls back to libx264 elsewhere; Linux is untested beyond CI.
|
|
150
|
+
|
|
151
|
+
## How it's built
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
src/
|
|
155
|
+
cli.ts install · init · doctor · ui · run · dry · render · check · validate · describe · ideas
|
|
156
|
+
manifest.ts zod schema, presets, warnings
|
|
157
|
+
record.ts Playwright + testreel → take.json (seeds, auth, locks, stop-on-fail, drag)
|
|
158
|
+
dryrun.ts every selector and wait, no camera
|
|
159
|
+
render.ts ffmpeg: camera → layout → captions → demo/master, stills, check
|
|
160
|
+
edits.ts structural edits agents make to a manifest (comments preserved)
|
|
161
|
+
operator/ the MCP server: 22 tools, from `scout` and `draft` to `run`, `look` and `done`
|
|
162
|
+
ui/ one http server, three pages, no framework, no build step
|
|
163
|
+
skill/SKILL.md what the agent is taught: order of operations, failures as text, when to stop
|
|
164
|
+
demos/ manifests; outputs/ is where takes land
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Recording and the cursor come from [testreel](https://github.com/greentfrapp/testreel); GIFs from [gifski](https://github.com/ImageOptim/gifski) when installed; ffmpeg from `ffmpeg-static`. Retake adds what the job needs on top: waiting on selectors, seeds, scenes with real timestamps, camera and captions at render time, presets, credentials, locks, structural edits, and the receipts.
|
|
168
|
+
|
|
169
|
+
## Developing
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
git clone https://github.com/glebbogachev00/retake && cd retake
|
|
173
|
+
npm install # also builds dist/
|
|
174
|
+
npx playwright install chromium
|
|
175
|
+
npm run retake -- run demos/todo-basics.yaml --preset draft # a public app, no setup
|
|
176
|
+
npm test && npm run typecheck
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`npm run retake -- …` runs the TypeScript source; `retake …` is the built package — same code. See [CONTRIBUTING.md](CONTRIBUTING.md), [BACKLOG.md](BACKLOG.md), and [AGENTS.md](AGENTS.md) (what coding agents read when pointed at this folder). Found something off? The **Bug?** button in the window pre-fills an issue with the receipts.
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
[MIT](LICENSE) — Gleb Bogachev.
|
|
Binary file
|
|
Binary file
|
package/assets/logo.svg
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="Retake">
|
|
2
|
+
<!-- Retake: a play button built out of takes.
|
|
3
|
+
Five stacked bars — recorded runs — that together read as ▶.
|
|
4
|
+
The longest one is the take you keep. -->
|
|
5
|
+
<rect width="512" height="512" rx="112" fill="#1a1c1e"/>
|
|
6
|
+
<g fill="#eceee9">
|
|
7
|
+
<rect x="132" y="112" width="96" height="46" rx="23" opacity="0.55"/>
|
|
8
|
+
<rect x="132" y="180" width="192" height="46" rx="23" opacity="0.8"/>
|
|
9
|
+
<rect x="132" y="248" width="288" height="46" rx="23" fill="#e8b04b"/>
|
|
10
|
+
<rect x="132" y="316" width="192" height="46" rx="23" opacity="0.8"/>
|
|
11
|
+
<rect x="132" y="384" width="96" height="46" rx="23" opacity="0.55"/>
|
|
12
|
+
</g>
|
|
13
|
+
</svg>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# A template. Copy it, point it at your app, replace the selectors.
|
|
2
|
+
#
|
|
3
|
+
# retake validate demos/example.yaml schema + warnings, instant
|
|
4
|
+
# retake dry demos/example.yaml every selector and wait, no camera
|
|
5
|
+
# retake run demos/example.yaml record + render → outputs/example/
|
|
6
|
+
#
|
|
7
|
+
# Or don't write it at all: once `retake install` has run, ask your coding
|
|
8
|
+
# agent for "a demo of my app at localhost:3000 showing …" and it drafts,
|
|
9
|
+
# dry-runs, records and checks this file for you. `retake ui` is the window
|
|
10
|
+
# to watch it in.
|
|
11
|
+
|
|
12
|
+
name: example
|
|
13
|
+
title: "Example — the shape of a demo"
|
|
14
|
+
url: http://localhost:3000
|
|
15
|
+
|
|
16
|
+
preset: post-landscape # 1080p @ 30fps · see `retake presets`
|
|
17
|
+
viewport: { width: 1440, height: 1080 } # match your app's natural shape
|
|
18
|
+
scale: 1.8 # page drawn at 1.8× so text stays crisp at 1080p
|
|
19
|
+
reducedMotion: true # calms infinite spinners and entrance animations
|
|
20
|
+
camera: auto # each scene eases toward the last thing touched
|
|
21
|
+
|
|
22
|
+
# Put the app in a known state before the camera rolls. Pick what fits:
|
|
23
|
+
# - { kind: file, path: /tmp/app/state.json, from: seeds/empty.json }
|
|
24
|
+
# - { kind: evaluate, script: "localStorage.clear()" }
|
|
25
|
+
# - { kind: command, run: "curl -X POST http://localhost:3000/api/test/reset" }
|
|
26
|
+
seed: []
|
|
27
|
+
|
|
28
|
+
# Runs before recording starts and is trimmed off the front of the video.
|
|
29
|
+
# Sign-ins belong here — see the "Signing in" section of the README.
|
|
30
|
+
setup: []
|
|
31
|
+
|
|
32
|
+
steps:
|
|
33
|
+
- action: scene
|
|
34
|
+
label: start
|
|
35
|
+
caption: "The problem, before anything happens."
|
|
36
|
+
camera: static
|
|
37
|
+
- action: wait
|
|
38
|
+
ms: 1800
|
|
39
|
+
|
|
40
|
+
- action: click
|
|
41
|
+
selector: "button:has-text('Get started')"
|
|
42
|
+
pauseAfter: 900
|
|
43
|
+
|
|
44
|
+
- action: type
|
|
45
|
+
selector: "input[name=title]"
|
|
46
|
+
text: "A realistic thing a person would type"
|
|
47
|
+
delay: 40
|
|
48
|
+
pauseAfter: 600
|
|
49
|
+
|
|
50
|
+
- action: click
|
|
51
|
+
selector: "button[type=submit]"
|
|
52
|
+
- action: waitFor # wait for the RESULT, never a fixed sleep
|
|
53
|
+
selector: ".result"
|
|
54
|
+
timeout: 30000
|
|
55
|
+
|
|
56
|
+
- action: scene
|
|
57
|
+
label: result
|
|
58
|
+
caption: "And the result, which is the whole point."
|
|
59
|
+
- action: wait
|
|
60
|
+
ms: 2600
|
|
61
|
+
|
|
62
|
+
outputs:
|
|
63
|
+
mp4: true
|
|
64
|
+
gif: false # opt-in; gifski is used when installed
|
|
65
|
+
thumbnail: { scene: result }
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starting the person's app after an explicit Retake workflow requests it.
|
|
3
|
+
*
|
|
4
|
+
* Retake records web apps, so a dead port is a common dead end. The UI invokes
|
|
5
|
+
* this only after the person selects a project and asks Retake to scan or
|
|
6
|
+
* record it. External agents keep a separate opt-in gate in operator/tools.ts.
|
|
7
|
+
*
|
|
8
|
+
* The hard part is not spawning — it is knowing WHICH port. Next and Vite take
|
|
9
|
+
* the next free one and say so once, in their log. So we watch the log and the
|
|
10
|
+
* set of listening ports, and only report success when something answers.
|
|
11
|
+
*/
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import net from "node:net";
|
|
16
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
17
|
+
/** Processes this server started, so it can stop them and not double-start. */
|
|
18
|
+
const started = new Map();
|
|
19
|
+
export const startedApps = () => [...started.entries()].map(([k, v]) => ({ key: k, ...v }));
|
|
20
|
+
export function listeningPorts() {
|
|
21
|
+
try {
|
|
22
|
+
const out = execFileSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], { encoding: "utf8", timeout: 3000 });
|
|
23
|
+
const ports = new Set();
|
|
24
|
+
for (const line of out.split("\n")) {
|
|
25
|
+
const m = /:(\d{4,5})\s+\(LISTEN\)/.exec(line);
|
|
26
|
+
if (m)
|
|
27
|
+
ports.add(Number(m[1]));
|
|
28
|
+
}
|
|
29
|
+
return [...ports].filter((p) => p >= 1024 && p < 65535).sort((a, b) => a - b);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export async function answers(url, ms = 2500) {
|
|
36
|
+
try {
|
|
37
|
+
const r = await fetch(url, { signal: AbortSignal.timeout(ms) });
|
|
38
|
+
return r.status < 500;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Is something holding the port open? Cheap, and true long before a dev
|
|
45
|
+
server can actually answer — the two are minutes apart on a cold start. */
|
|
46
|
+
export function portOpen(port, ms = 900) {
|
|
47
|
+
return new Promise((done) => {
|
|
48
|
+
const sock = net.connect({ port, host: "127.0.0.1" });
|
|
49
|
+
const end = (v) => { sock.destroy(); done(v); };
|
|
50
|
+
sock.setTimeout(ms);
|
|
51
|
+
sock.once("connect", () => end(true));
|
|
52
|
+
sock.once("timeout", () => end(false));
|
|
53
|
+
sock.once("error", () => end(false));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export async function waitForUrl(url, ms) {
|
|
57
|
+
const until = Date.now() + ms;
|
|
58
|
+
while (Date.now() < until) {
|
|
59
|
+
if (await answers(url, 2000))
|
|
60
|
+
return true;
|
|
61
|
+
await new Promise((ok) => setTimeout(ok, 900));
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
/** Modern Next prints every useful fact on separate lines when the same
|
|
66
|
+
project is already running. Prefer the last live localhost URL: the first
|
|
67
|
+
one can be the short-lived port from the failed second launch. */
|
|
68
|
+
function alreadyRunningHint(log, cwd) {
|
|
69
|
+
if (!/already running/i.test(log))
|
|
70
|
+
return null;
|
|
71
|
+
const urls = [...log.matchAll(/https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]):\d{2,5}/g)].map((m) => m[0]);
|
|
72
|
+
const dir = /^- Dir:\s+(.+)$/m.exec(log)?.[1]?.trim();
|
|
73
|
+
if (dir && path.resolve(dir) !== cwd)
|
|
74
|
+
return null;
|
|
75
|
+
const pid = Number(/^- PID:\s+(\d+)$/m.exec(log)?.[1] || 0) || undefined;
|
|
76
|
+
const url = [...new Set(urls)].at(-1);
|
|
77
|
+
return url ? { url, pid } : null;
|
|
78
|
+
}
|
|
79
|
+
/** The project's own way of starting itself. We do not invent commands. */
|
|
80
|
+
export function startOffer(dir) {
|
|
81
|
+
const root = path.resolve(dir.replace(/^~/, os.homedir()));
|
|
82
|
+
const pkgFile = path.join(root, "package.json");
|
|
83
|
+
if (fs.existsSync(pkgFile)) {
|
|
84
|
+
try {
|
|
85
|
+
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8"));
|
|
86
|
+
const scripts = pkg.scripts ?? {};
|
|
87
|
+
for (const name of ["dev", "start", "serve", "preview"]) {
|
|
88
|
+
if (scripts[name])
|
|
89
|
+
return { command: `npm run ${name}`, script: scripts[name], dir: root };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch { /* fall through */ }
|
|
93
|
+
}
|
|
94
|
+
// Vercel-style projects with no package.json (static + edge functions).
|
|
95
|
+
if (fs.existsSync(path.join(root, "vercel.json")) || fs.existsSync(path.join(root, "api"))) {
|
|
96
|
+
return { command: "vercel dev", script: "vercel dev", dir: root };
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
/** A port nothing is listening on, walking up from a sensible base. */
|
|
101
|
+
async function freePort(from = 3200) {
|
|
102
|
+
const busy = new Set(listeningPorts());
|
|
103
|
+
for (let p = from; p < from + 60; p++)
|
|
104
|
+
if (!busy.has(p) && !(await portOpen(p, 300)))
|
|
105
|
+
return p;
|
|
106
|
+
return from + 61;
|
|
107
|
+
}
|
|
108
|
+
/** Say the same command again, but on a port of our choosing. Dev servers
|
|
109
|
+
disagree about flags and agree about $PORT, so we set both where we can. */
|
|
110
|
+
function onPort(command, port) {
|
|
111
|
+
const flagged = /\b(next|vite|astro|nuxt|remix|ng|serve|http-server)\b/.test(command) && !/\bnpm run\b/.test(command)
|
|
112
|
+
? `${command} --port ${port}`
|
|
113
|
+
: command;
|
|
114
|
+
return `PORT=${port} ${flagged}`;
|
|
115
|
+
}
|
|
116
|
+
function withWebpack(command) {
|
|
117
|
+
if (/\b--webpack\b/.test(command))
|
|
118
|
+
return command;
|
|
119
|
+
return /\bnpm run\b/.test(command) ? `${command} -- --webpack` : `${command} --webpack`;
|
|
120
|
+
}
|
|
121
|
+
/** A retried command is still the same app. Keep one map entry so a second
|
|
122
|
+
request reuses the process we moved instead of starting it again. */
|
|
123
|
+
function startKey(cwd, command) {
|
|
124
|
+
let canonical = command;
|
|
125
|
+
if (/^PORT=\d+\s+/.test(canonical))
|
|
126
|
+
canonical = canonical.replace(/^PORT=\d+\s+/, "").replace(/\s+--port\s+\d+$/, "");
|
|
127
|
+
return `${cwd}::${canonical}`;
|
|
128
|
+
}
|
|
129
|
+
/** Start it and wait until something actually answers. Reports the real port. */
|
|
130
|
+
export async function startApp(opts) {
|
|
131
|
+
const cwd = path.resolve(opts.dir.replace(/^~/, os.homedir()));
|
|
132
|
+
const key = startKey(cwd, opts.command);
|
|
133
|
+
const existing = started.get(key);
|
|
134
|
+
if (existing && existing.url && (await answers(existing.url)))
|
|
135
|
+
return { ok: true, url: existing.url, pid: existing.pid, log: "" };
|
|
136
|
+
// Do this before spawning. Otherwise an already-open expected port can win
|
|
137
|
+
// the readiness race and make us report somebody else's process as the app
|
|
138
|
+
// we just started, before our child has time to print EADDRINUSE.
|
|
139
|
+
if (opts.expectUrl && !opts.noRetry) {
|
|
140
|
+
const expected = Number(new URL(opts.expectUrl).port || 80);
|
|
141
|
+
if (await portOpen(expected, 300)) {
|
|
142
|
+
const next = await freePort();
|
|
143
|
+
opts.onProgress?.(`Port ${expected} is already in use — starting on ${next} instead.`);
|
|
144
|
+
return startApp({ ...opts, command: onPort(opts.command, next), expectUrl: `http://localhost:${next}`, noRetry: true });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const before = new Set(listeningPorts());
|
|
148
|
+
const logFile = path.join(os.tmpdir(), `retake-app-${Date.now()}.log`);
|
|
149
|
+
const out = fs.openSync(logFile, "a");
|
|
150
|
+
// A login shell so nvm/asdf/volta shims are on PATH, and its own process
|
|
151
|
+
// group so stopping it takes the whole tree with it.
|
|
152
|
+
const child = spawn("/bin/sh", ["-lc", opts.command], { cwd, env: process.env, stdio: ["ignore", out, out], detached: true });
|
|
153
|
+
child.unref();
|
|
154
|
+
started.set(key, { pid: child.pid ?? 0, logFile });
|
|
155
|
+
const tail = () => { try {
|
|
156
|
+
return fs.readFileSync(logFile, "utf8").split("\n").slice(-14).join("\n");
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return "";
|
|
160
|
+
} };
|
|
161
|
+
const say = opts.onProgress ?? (() => { });
|
|
162
|
+
const handleExisting = async (log) => {
|
|
163
|
+
const hint = alreadyRunningHint(log, cwd);
|
|
164
|
+
if (!hint)
|
|
165
|
+
return null;
|
|
166
|
+
if (await answers(hint.url, 8000)) {
|
|
167
|
+
try {
|
|
168
|
+
process.kill(-(child.pid ?? 0), "SIGTERM");
|
|
169
|
+
}
|
|
170
|
+
catch { /* failed launch already ended */ }
|
|
171
|
+
say(`It is already running at ${hint.url} — using that.`);
|
|
172
|
+
started.set(key, { pid: 0, url: hint.url, logFile });
|
|
173
|
+
return { ok: true, url: hint.url, log };
|
|
174
|
+
}
|
|
175
|
+
if (!hint.pid || opts.restartedExisting)
|
|
176
|
+
return { ok: false, log, why: `the existing server at ${hint.url} is not responding` };
|
|
177
|
+
say(`The existing server at ${hint.url} is not responding — restarting it.`);
|
|
178
|
+
try {
|
|
179
|
+
process.kill(hint.pid, "SIGTERM");
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return { ok: false, log, why: `the existing server at ${hint.url} could not be stopped` };
|
|
183
|
+
}
|
|
184
|
+
const port = Number(new URL(hint.url).port || 80);
|
|
185
|
+
for (let i = 0; i < 12 && (await portOpen(port, 400)); i++)
|
|
186
|
+
await new Promise((ok) => setTimeout(ok, 500));
|
|
187
|
+
if (await portOpen(port, 400)) {
|
|
188
|
+
say(`The stale server did not stop cleanly — force-stopping PID ${hint.pid}.`);
|
|
189
|
+
try {
|
|
190
|
+
process.kill(hint.pid, "SIGKILL");
|
|
191
|
+
}
|
|
192
|
+
catch { /* verify the port below */ }
|
|
193
|
+
for (let i = 0; i < 8 && (await portOpen(port, 300)); i++)
|
|
194
|
+
await new Promise((ok) => setTimeout(ok, 350));
|
|
195
|
+
}
|
|
196
|
+
if (await portOpen(port, 400))
|
|
197
|
+
return { ok: false, log, why: `the existing server at ${hint.url} did not stop` };
|
|
198
|
+
try {
|
|
199
|
+
process.kill(-(child.pid ?? 0), "SIGTERM");
|
|
200
|
+
}
|
|
201
|
+
catch { /* failed launch already ended */ }
|
|
202
|
+
const restarted = await startApp({ ...opts, expectUrl: hint.url, noRetry: true, restartedExisting: true });
|
|
203
|
+
if (restarted.ok && restarted.url)
|
|
204
|
+
started.set(key, { pid: restarted.pid ?? 0, url: restarted.url, logFile });
|
|
205
|
+
return restarted;
|
|
206
|
+
};
|
|
207
|
+
// Phase 1 — find the port. A dev server opens it within seconds and usually
|
|
208
|
+
// announces it in the log; it may not be able to answer for minutes yet.
|
|
209
|
+
const portUntil = Date.now() + 45_000;
|
|
210
|
+
let port = opts.expectUrl ? Number(new URL(opts.expectUrl).port || 80) : 0;
|
|
211
|
+
while (Date.now() < portUntil) {
|
|
212
|
+
const log = tail();
|
|
213
|
+
const existingResult = await handleExisting(log);
|
|
214
|
+
if (existingResult)
|
|
215
|
+
return existingResult;
|
|
216
|
+
if (child.exitCode !== null) {
|
|
217
|
+
if (/EADDRINUSE|address already in use|port \d+ is (?:already )?in use|another .* is already running/i.test(log) && !opts.noRetry) {
|
|
218
|
+
const p2 = await freePort();
|
|
219
|
+
say(`That port is taken — trying ${p2} instead.`);
|
|
220
|
+
return startApp({ ...opts, command: onPort(opts.command, p2), expectUrl: `http://localhost:${p2}`, noRetry: true, onProgress: opts.onProgress });
|
|
221
|
+
}
|
|
222
|
+
return { ok: false, log, why: `the command exited (${child.exitCode}) — see the log` };
|
|
223
|
+
}
|
|
224
|
+
if (port && (await portOpen(port)))
|
|
225
|
+
break;
|
|
226
|
+
const m = /https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0):(\d{2,5})/.exec(tail());
|
|
227
|
+
if (m && (await portOpen(Number(m[1])))) {
|
|
228
|
+
port = Number(m[1]);
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
const fresh = listeningPorts().filter((p) => !before.has(p));
|
|
232
|
+
if (fresh.length) {
|
|
233
|
+
port = fresh[0];
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
await new Promise((ok) => setTimeout(ok, 1000));
|
|
237
|
+
}
|
|
238
|
+
if (!port || !(await portOpen(port))) {
|
|
239
|
+
const log = tail();
|
|
240
|
+
// Two things a dev server says when it will not start, and both have a
|
|
241
|
+
// better answer than "failed". If it is already running, that is the app
|
|
242
|
+
// the person wanted — use it. If the port is simply taken, move over.
|
|
243
|
+
const existingResult = await handleExisting(log);
|
|
244
|
+
if (existingResult)
|
|
245
|
+
return existingResult;
|
|
246
|
+
const taken = /EADDRINUSE|address already in use|port \d+ is (?:already )?in use|another .* is already running/i.test(log);
|
|
247
|
+
if (taken && !opts.noRetry) {
|
|
248
|
+
const p2 = await freePort();
|
|
249
|
+
say(`Something already holds that port — trying ${p2} instead.`);
|
|
250
|
+
try {
|
|
251
|
+
process.kill(-(child.pid ?? 0), "SIGTERM");
|
|
252
|
+
}
|
|
253
|
+
catch { /* already gone */ }
|
|
254
|
+
return startApp({ ...opts, command: onPort(opts.command, p2), expectUrl: `http://localhost:${p2}`, noRetry: true, onProgress: opts.onProgress });
|
|
255
|
+
}
|
|
256
|
+
return { ok: false, log, why: "nothing opened a port within 45s — check the log" };
|
|
257
|
+
}
|
|
258
|
+
const url = `http://localhost:${port}`;
|
|
259
|
+
say(`Port ${port} is open — waiting for the first page to build…`);
|
|
260
|
+
// Phase 2 — be patient. A cold Next/Vite dev server compiles on the first
|
|
261
|
+
// request, and on a loaded machine that is minutes, not seconds. Failing at
|
|
262
|
+
// two seconds (as this used to) reports a working app as dead.
|
|
263
|
+
const answerUntil = Date.now() + (opts.seconds ?? 300) * 1000;
|
|
264
|
+
const answerStarted = Date.now();
|
|
265
|
+
while (Date.now() < answerUntil) {
|
|
266
|
+
const log = tail();
|
|
267
|
+
const existingResult = await handleExisting(log);
|
|
268
|
+
if (existingResult)
|
|
269
|
+
return existingResult;
|
|
270
|
+
if (child.exitCode !== null)
|
|
271
|
+
return { ok: false, log: tail(), why: `the command exited (${child.exitCode}) — see the log` };
|
|
272
|
+
if (await answers(url, 20_000)) {
|
|
273
|
+
started.set(key, { pid: child.pid ?? 0, url, logFile });
|
|
274
|
+
return { ok: true, url, pid: child.pid ?? undefined, log: tail() };
|
|
275
|
+
}
|
|
276
|
+
if (!opts.webpackFallback && Date.now() - answerStarted > 25_000 && /Next\.js[\s\S]*\(Turbopack\)/i.test(log)) {
|
|
277
|
+
say("The Turbopack server opened a port but did not serve the first page — retrying with webpack.");
|
|
278
|
+
try {
|
|
279
|
+
process.kill(-(child.pid ?? 0), "SIGTERM");
|
|
280
|
+
}
|
|
281
|
+
catch { /* verify below */ }
|
|
282
|
+
for (let i = 0; i < 12 && (await portOpen(port, 350)); i++)
|
|
283
|
+
await new Promise((ok) => setTimeout(ok, 450));
|
|
284
|
+
if (await portOpen(port, 350)) {
|
|
285
|
+
try {
|
|
286
|
+
process.kill(-(child.pid ?? 0), "SIGKILL");
|
|
287
|
+
}
|
|
288
|
+
catch { /* retry reports a conflict if still alive */ }
|
|
289
|
+
}
|
|
290
|
+
const fallback = await startApp({ ...opts, command: onPort(withWebpack(opts.command), port), expectUrl: url, noRetry: true, webpackFallback: true });
|
|
291
|
+
if (fallback.ok && fallback.url)
|
|
292
|
+
started.set(key, { pid: fallback.pid ?? 0, url: fallback.url, logFile });
|
|
293
|
+
return fallback;
|
|
294
|
+
}
|
|
295
|
+
say("Still building…");
|
|
296
|
+
}
|
|
297
|
+
return { ok: false, log: tail(), why: `it opened ${url} but never finished building — check the log` };
|
|
298
|
+
}
|
|
299
|
+
export function stopApp(key) {
|
|
300
|
+
const s = started.get(key);
|
|
301
|
+
if (!s?.pid)
|
|
302
|
+
return false;
|
|
303
|
+
try {
|
|
304
|
+
process.kill(-s.pid, "SIGTERM");
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
try {
|
|
308
|
+
process.kill(s.pid, "SIGTERM");
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
started.delete(key);
|
|
315
|
+
return true;
|
|
316
|
+
}
|