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 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
@@ -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
+ }