retake-dev 0.4.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 (43) hide show
  1. package/LICENSE +91 -0
  2. package/README.md +198 -0
  3. package/bin/retake.js +319 -0
  4. package/package.json +87 -0
  5. package/src/code-versions.js +357 -0
  6. package/src/core.js +265 -0
  7. package/src/plugin.js +137 -0
  8. package/src/runtime/00-core.js +308 -0
  9. package/src/runtime/10-animations.js +362 -0
  10. package/src/runtime/20-media.js +157 -0
  11. package/src/runtime/30-recorder.js +315 -0
  12. package/src/runtime/32-state.js +315 -0
  13. package/src/runtime/35-network.js +789 -0
  14. package/src/runtime/36-scripts.js +129 -0
  15. package/src/runtime/37-next.js +150 -0
  16. package/src/runtime/38-observers.js +252 -0
  17. package/src/runtime/40-input.js +795 -0
  18. package/src/runtime/45-hover.js +78 -0
  19. package/src/runtime/50-engine.js +341 -0
  20. package/src/runtime/60-preview.js +437 -0
  21. package/src/runtime/65-timeline.js +289 -0
  22. package/src/runtime/66-activity.js +114 -0
  23. package/src/runtime/67-csssource.js +226 -0
  24. package/src/runtime/70-boot.js +460 -0
  25. package/src/server/api.js +285 -0
  26. package/src/server/child.js +174 -0
  27. package/src/server/detect.js +167 -0
  28. package/src/server/front.js +647 -0
  29. package/src/server/mcp.js +332 -0
  30. package/src/shell/00-state.js +85 -0
  31. package/src/shell/05-api.js +136 -0
  32. package/src/shell/10-dock.js +754 -0
  33. package/src/shell/12-checkpoint.js +121 -0
  34. package/src/shell/15-session.js +291 -0
  35. package/src/shell/20-timeline.js +734 -0
  36. package/src/shell/25-input.js +537 -0
  37. package/src/shell/30-notes.js +870 -0
  38. package/src/shell/40-code.js +80 -0
  39. package/src/shell/90-handle.js +15 -0
  40. package/src/shell/shell.css +295 -0
  41. package/src/shell/shell.html +59 -0
  42. package/types/client.d.ts +73 -0
  43. package/types/index.d.ts +111 -0
package/LICENSE ADDED
@@ -0,0 +1,91 @@
1
+ Required Notice: Copyright (c) 2026 Rushil (https://github.com/LightningDesigner/retake)
2
+
3
+ # PolyForm Shield License 1.0.0
4
+
5
+ <https://polyformproject.org/licenses/shield/1.0.0>
6
+
7
+ ## Acceptance
8
+
9
+ In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
10
+
11
+ ## Copyright License
12
+
13
+ The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
14
+
15
+ ## Distribution License
16
+
17
+ The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
18
+
19
+ ## Notices
20
+
21
+ You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
22
+
23
+ > Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
24
+
25
+ ## Changes and New Works License
26
+
27
+ The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
28
+
29
+ ## Patent License
30
+
31
+ The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
32
+
33
+ ## Noncompete
34
+
35
+ Any purpose is a permitted purpose, except for providing any product that competes with the software or any product the licensor or any of its affiliates provides using the software.
36
+
37
+ ## Competition
38
+
39
+ Goods and services compete even when they provide functionality through different kinds of interfaces or for different technical platforms. Applications can compete with services, libraries with plugins, frameworks with development tools, and so on, even if they're written in different programming languages or for different computer architectures. Goods and services compete even when provided free of charge. If you market a product as a practical substitute for the software or another product, it definitely competes.
40
+
41
+ ## New Products
42
+
43
+ If you are using the software to provide a product that does not compete, but the licensor or any of its affiliates brings your product into competition by providing a new version of the software or another product using the software, you may continue using versions of the software available under these terms beforehand to provide your competing product, but not any later versions.
44
+
45
+ ## Discontinued Products
46
+
47
+ You may begin using the software to compete with a product or service that the licensor or any of its affiliates has stopped providing, unless the licensor includes a plain-text line beginning with `Licensor Line of Business:` with the software that mentions that line of business. For example:
48
+
49
+ > Licensor Line of Business: YoyodyneCMS Content Management System (http://example.com/cms)
50
+
51
+ ## Sales of Business
52
+
53
+ If the licensor or any of its affiliates sells a line of business developing the software or using the software to provide a product, the buyer can also enforce [Noncompete](#noncompete) for that product.
54
+
55
+ ## Fair Use
56
+
57
+ You may have "fair use" rights for the software under the law. These terms do not limit them.
58
+
59
+ ## No Other Rights
60
+
61
+ These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
62
+
63
+ ## Patent Defense
64
+
65
+ If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
66
+
67
+ ## Violations
68
+
69
+ The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
70
+
71
+ ## No Liability
72
+
73
+ ***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
74
+
75
+ ## Definitions
76
+
77
+ The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
78
+
79
+ A **product** can be a good or service, or a combination of them.
80
+
81
+ **You** refers to the individual or entity agreeing to these terms.
82
+
83
+ **Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all its affiliates.
84
+
85
+ **Affiliates** means the other organizations than an organization has control over, is under the control of, or is under common control with.
86
+
87
+ **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
88
+
89
+ **Your licenses** are all the licenses granted to you for the software under these terms.
90
+
91
+ **Use** means anything you do with the software requiring one of your licenses.
package/README.md ADDED
@@ -0,0 +1,198 @@
1
+ # Retake
2
+
3
+ A time machine for your dev server: Vite apps, Next.js, React Router and other
4
+ frameworks. A timeline docks over the bottom of your app and records everything from page load. Drag it back and the app is at that
5
+ moment. Ctrl-click the timeline to start a new take from there; the old one
6
+ stays as a lane you can click back into. Dev only: nothing ships in builds.
7
+
8
+ ## Getting started
9
+
10
+ ### Requirements
11
+ - Node 18 or newer
12
+ - A Vite 5+ app served from an `index.html` (React, Vue, Svelte, plain JS), or anything
13
+ else with a dev server that serves HTML (Next.js, React Router, Remix, Nuxt, SvelteKit, Astro...):
14
+ see [Frameworks](#frameworks).
15
+ - Chrome, Edge or another Chromium browser (that's what it's tested on)
16
+
17
+ ### Try it without installing
18
+ From your app's folder:
19
+
20
+ ```sh
21
+ npx retake-dev . # npm
22
+ pnpm dlx retake-dev . # pnpm
23
+ yarn dlx retake-dev . # yarn (berry)
24
+ bunx retake-dev . # bun
25
+ ```
26
+
27
+ Open the URL it prints (default http://localhost:3014). Your files aren't
28
+ touched: on a Vite app it runs your own `vite.config` through a wrapper kept in your temp
29
+ folder; on a framework it runs your `dev` script and sits in front of it.
30
+ Retake keeps its session in a `.retake/` folder in the app (it git-ignores itself).
31
+
32
+ > Use the full name `retake-dev` with `npx`/`dlx`. The short `retake` command only
33
+ > exists after you install `retake-dev` in the project (an unrelated npm package
34
+ > is called `retake`).
35
+
36
+ ### Install it in the project
37
+ ```sh
38
+ npm i -D retake-dev # or: pnpm add -D retake-dev / yarn add -D retake-dev / bun add -d retake-dev
39
+ npx retake . # same as above, now from node_modules
40
+ ```
41
+
42
+ Or keep your usual `npm run dev` and add the plugin to `vite.config`:
43
+
44
+ ```js
45
+ import { retake } from "retake-dev"
46
+
47
+ export default defineConfig({
48
+ plugins: [retake(), /* ...your plugins */],
49
+ })
50
+ ```
51
+
52
+ `retake()` only runs in `vite dev`; `vite build` output has no Retake code in it.
53
+
54
+ ### Connect your coding agent (MCP)
55
+ Notes you leave in the dock can go straight to your coding agent. Register the
56
+ MCP server once, from the app folder. With Claude Code:
57
+
58
+ ```sh
59
+ claude mcp add retake -- npx -y retake-dev mcp
60
+ ```
61
+
62
+ Any other MCP client (Cursor, Codex, Windsurf...) takes the same command,
63
+ `npx -y retake-dev mcp`, in its MCP settings.
64
+
65
+ With the dev server running, the agent can `list_notes`, `get_note`,
66
+ `get_active_timeline`, `acknowledge`, `resolve`, `reply` and `watch_notes`.
67
+ It finds the server through `.retake/server.json` (or pass `--url http://localhost:3014`).
68
+ Acknowledging and resolving show up on the note in the dock right away.
69
+
70
+ ### The first 60 seconds
71
+ 1. **Use your app** for a few seconds: it's being recorded already.
72
+ 2. **Pause** with space (or ⌥P, or the play button). The app goes view-only:
73
+ you can scroll, not click.
74
+ 3. **Rewind**: drag the playhead back. Let go and the moment is rebuilt for real.
75
+ ⌘-scroll on the timeline zooms in; ←/→ step frame by frame; F fits everything.
76
+ 4. **Branch**: Ctrl-click the timeline at a moment to start a new take there.
77
+ It starts paused: press play and do something different. Click the other
78
+ lane to go back to the first take.
79
+ 5. **Note**: hold ⌘ and click an element (or one of its animation layers),
80
+ write what should change, press Enter. "Copy for agent" copies a prompt with
81
+ the element, its React component, source file:line and CSS; or let your
82
+ agent pick it up over MCP.
83
+
84
+ ### Uninstall
85
+ ```sh
86
+ npm uninstall retake-dev # or pnpm remove / yarn remove / bun remove
87
+ rm -rf .retake # Retake's session and recordings
88
+ claude mcp remove retake # if you added the MCP server (or remove it in your client's MCP settings)
89
+ ```
90
+ Remove `retake()` from `vite.config` if you added it.
91
+
92
+ ## Commands
93
+ ```sh
94
+ retake <project> # run the project's dev server with the timeline
95
+ retake <project> --port 4000
96
+ retake <project> --code-branches # each timeline keeps its own version of the code (Vite apps; rewrites files!)
97
+ retake <project> -- --host # anything after -- goes to the dev server
98
+ retake -- <dev command> # run that command with the timeline in front (retake -- next dev)
99
+ retake http://localhost:3000 # put the timeline in front of a dev server that's already running
100
+ retake init # print the vite.config lines
101
+ retake mcp # the MCP server your coding agent runs
102
+ ```
103
+ `--root <dir>` puts `.retake/` somewhere else; `--verbose` logs every request the
104
+ front server handles to `.retake/front.log`. Opt out for one page load with `?retake=0`.
105
+
106
+ Recordings hold what you typed and what your API answered. Retake's front server
107
+ only answers on localhost; with the plugin and `vite --host`, anyone on your
108
+ network can read the session.
109
+
110
+ ## Frameworks
111
+
112
+ Retake needs to put a small script first in the page the dock frames. A Vite
113
+ single-page app gets it from the Vite plugin. Anything that renders its own HTML
114
+ gets it from Retake's **front server**: Retake listens on its port (3014), serves
115
+ the dock there, and passes everything else through to your dev server, adding the
116
+ script to the frame's page as it streams. Assets, data fetches, server actions,
117
+ API routes and the HMR socket go through untouched. Your config isn't changed.
118
+
119
+ `retake .` works out which one you have. A project that depends on Next, Nuxt,
120
+ React Router (framework mode), Remix, SvelteKit, Astro, TanStack Start, SolidStart,
121
+ Vike, Waku or Analog gets the front server, in front of its `dev` script (run with
122
+ the package manager its lockfile names):
123
+
124
+ | Framework | Run | Notes |
125
+ |---|---|---|
126
+ | Vite SPA (React, Vue, Svelte, plain) | `npx retake-dev .` | or `plugins: [retake()]` in `vite.config` |
127
+ | Next.js | `npx retake-dev .` | tested on 16.3 (app and pages router, server actions) and 15.5 (app router), Turbopack |
128
+ | React Router 7 framework mode | `npx retake-dev .` | or `plugins: [retake(), reactRouter()]` and your usual `npm run dev`; tested on 7.18 |
129
+ | Remix 2 (Vite) | `npx retake-dev .` | tested on 2.17 |
130
+ | Astro | `npx retake-dev .` | tested on 7.3 with React islands and `<ClientRouter />` |
131
+ | SvelteKit | `npx retake-dev .` | tested on 3.0 (Svelte 5) |
132
+ | Nuxt | `npx retake-dev .` | tested on 4.5; runs `nuxt dev` behind Retake (it ignores `PORT`: `npx retake-dev . -- --port 3001` picks its port) |
133
+ | TanStack Start, SolidStart, Vike... | `npx retake-dev .` | untested; with Vite you can also add `retake()` to its Vite plugins |
134
+ | Anything else that serves HTML | `npx retake-dev -- <your dev command>` | e.g. `npx retake-dev -- npm run dev` |
135
+ | A dev server that's already running | `npx retake-dev http://localhost:3000` | `.retake/` goes in the current folder (or `--root`) |
136
+
137
+ Tested means: the app hydrates in the dock with no warning, and recording, scrubbing back, the rebuilt moment, Play, hot
138
+ updates and reloads all work, started either way (`retake .` or `retake http://localhost:…`). The untested ones go
139
+ through the same front server and should work; say so if one doesn't.
140
+
141
+ - **Which port?** Retake sets `PORT` to a free port for the dev command (or keeps
142
+ yours), and otherwise uses the first `http://localhost:…` the command prints, so
143
+ tools that ignore `PORT` work too. Ctrl-C stops the dev server with it.
144
+ - **The plugin in a Vite-based framework.** With `retake()` in `vite.config` and
145
+ no `index.html` in the Vite root, the plugin docks into the pages your framework
146
+ renders, on your usual dev server port: no second server.
147
+ - **Signing in.** Sign-in pages (OAuth) refuse to load inside a frame, so sign in
148
+ at your app's own port first (cookies on `localhost` are shared across ports),
149
+ then open Retake's. A sign-in redirect that comes back from another site gets the
150
+ plain page so it completes; reload to get the dock back.
151
+ - **Next 16.** Next 16 sends React debug data for every request over its HMR
152
+ socket (`experimental.reactDebugChannel`). Retake keeps it with the recording and
153
+ hands it back on replay, so nothing needs changing. Other Next versions with a
154
+ debug channel aren't known yet: if replayed navigations or server actions don't
155
+ show, Retake's warning says to set `experimental: { reactDebugChannel: false }`.
156
+ - **Exact replays on server-rendered pages.** Behind the front server the clock
157
+ starts once the page has loaded and nothing more is loading (so an app that
158
+ imports itself after load, like Nuxt's, has mounted), scripts and stylesheets added later (lazily
159
+ loaded chunks) run at their recorded moment, the dev server's own traffic (HMR)
160
+ is left out of the recording, and a rebuilt moment gets the page's HTML as it
161
+ was recorded (kept in `.retake/docs/`), not rendered again. Native `import()`
162
+ (Vite's lazy routes, Astro islands) can't be held to its moment.
163
+ - **Bottom of the app hidden by the dock?** The dock floats over the bottom of
164
+ the app (a cookie banner's buttons, Next's dev badge). Drag the dock's divider
165
+ down, or open the app with `?retake=0`.
166
+ - **Not rewound.** Retake rewinds the browser, not your server: database writes,
167
+ server sessions and server-action side effects stay as they are (replays answer
168
+ from the recording). Service workers are off while Retake is in front, and
169
+ `--code-branches` is Vite-only.
170
+
171
+ **Code per timeline** (`--code-branches`): when the source changes, the timeline
172
+ you're on takes the new code; the others keep theirs. Stepping into a timeline
173
+ checks its code out on disk (snapshots are kept in `.retake/`, and the newest
174
+ code is put back when the server stops), but use it on prototypes, not shared repos.
175
+
176
+ ## How going back works
177
+
178
+ It doesn't snapshot the DOM. It records every input (pointer, keys, typing,
179
+ scrolling, back/forward), runs time on a virtual clock (timers, rAF, `Date`,
180
+ idle callbacks, CSS and Web Animations) and seeds randomness (`Math.random`,
181
+ `crypto`). Going back reloads the app and replays those inputs at full speed up
182
+ to the chosen moment, with the app's own code rebuilding the screen.
183
+
184
+ Server traffic is answered from the recording: `fetch` (streamed replies too,
185
+ chunk by chunk at the pace they arrived), `XMLHttpRequest`, `EventSource` and
186
+ `WebSocket`. So are observer callbacks and worker messages. Web storage,
187
+ cookies and IndexedDB go back to how they were when the recording began.
188
+
189
+ It rewinds the browser, not your server, so it suits prototypes whose backends
190
+ don't remember state. Not covered: the Cache API / service workers, and
191
+ cross-origin iframes.
192
+
193
+ Requires Node 18+, and Vite 5 or newer for Vite apps and the plugin.
194
+
195
+ ## License
196
+
197
+ [PolyForm Shield 1.0.0](LICENSE). Use it, change it and share it for anything,
198
+ except building a product that competes with Retake.
package/bin/retake.js ADDED
@@ -0,0 +1,319 @@
1
+ #!/usr/bin/env node
2
+ // Retake CLI.
3
+ // retake [dev] <project> [--port 3014] [--code-branches] [-- ...dev server args]
4
+ // A Vite single-page app: runs the project's own Vite with the timeline
5
+ // added, from a wrapper config kept outside the project. A framework with
6
+ // its own dev server (Next, Nuxt, React Router, Remix, SvelteKit, Astro...):
7
+ // runs its dev command and puts Retake in front of it (src/server/front.js).
8
+ // Nothing in the project is touched (except with --code-branches, Vite
9
+ // only, which checks timelines' code out on disk).
10
+ // retake http://localhost:3000
11
+ // Puts Retake in front of a dev server that's already running.
12
+ // retake -- <dev command>
13
+ // Runs that command (e.g. `retake -- next dev`) and puts Retake in front.
14
+ // retake init
15
+ // Prints the lines that add Retake to a project's vite.config instead.
16
+ // retake mcp
17
+ // MCP server (stdio) for coding agents, talking to a running dev server.
18
+ import fs from "node:fs"
19
+ import os from "node:os"
20
+ import path from "node:path"
21
+ import crypto from "node:crypto"
22
+ import { spawn } from "node:child_process"
23
+ import { createRequire } from "node:module"
24
+ import { fileURLToPath, pathToFileURL } from "node:url"
25
+ import { detectProject, nextDebugChannelWarning, shellQuote, withArgs } from "../src/server/detect.js"
26
+ import { runDevCommand } from "../src/server/child.js"
27
+
28
+ const HOME = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
29
+ const PKG = JSON.parse(fs.readFileSync(path.join(HOME, "package.json"), "utf8"))
30
+ const CONFIGS = ["vite.config.ts", "vite.config.mts", "vite.config.cts", "vite.config.js", "vite.config.mjs", "vite.config.cjs"]
31
+ const USAGE = `Retake ${PKG.version}: a time machine for your dev server
32
+
33
+ Usage:
34
+ retake <project> run the project's dev server with the timeline
35
+ (Vite apps, Next, Nuxt, React Router, Remix,
36
+ SvelteKit, Astro...)
37
+ retake dev <project> [options] same thing
38
+ retake http://localhost:3000 put the timeline in front of a running dev server
39
+ retake -- <dev command> run that command with the timeline in front
40
+ (e.g. retake -- next dev, retake -- pnpm dev)
41
+ retake init print the vite.config lines instead
42
+ retake mcp [--url http://localhost:3014]
43
+ MCP server for coding agents (stdio)
44
+
45
+ Options:
46
+ --port <n> port to serve on (default 3014)
47
+ --root <dir> where .retake/ (sessions, recordings) goes
48
+ (default: the project, or this folder)
49
+ --code-branches each timeline keeps its own version of the code
50
+ (Vite apps only; rewrites files on disk)
51
+ --verbose log every request to .retake/front.log (front server)
52
+ -- after a project: arguments for its dev server
53
+ (e.g. retake . -- --host); without one: the dev command
54
+ -v, --version print the version
55
+ -h, --help show this help`
56
+
57
+ /** @returns {never} */
58
+ function fail(msg, hint) {
59
+ console.error(`retake: ${msg}`)
60
+ if (hint) console.error(` ${hint}`)
61
+ process.exit(1)
62
+ }
63
+
64
+ // Minimal argv parsing with validation. After `--`: with no project, the dev
65
+ // command to run (`retake -- next dev`); otherwise arguments for the dev server.
66
+ export function parseArgs(argv) {
67
+ const dash = argv.indexOf("--")
68
+ const own = dash < 0 ? argv : argv.slice(0, dash)
69
+ let passthrough = dash < 0 ? [] : argv.slice(dash + 1)
70
+ /** @type {{ cmd: string | null, project: string | null, upstream: string | null, command: string[] | null, root: string | null, verbose: boolean,
71
+ * port: number, portSet?: boolean, codeBranches: boolean, url: string | null, passthrough: string[], help: boolean, version: boolean }} */
72
+ const out = { cmd: null, project: null, upstream: null, command: null, root: null, verbose: false, port: 3014, codeBranches: false, url: null, passthrough, help: false, version: false }
73
+ const positional = []
74
+ const value = (a, i, what) => {
75
+ const v = a.includes("=") ? a.slice(a.indexOf("=") + 1) : own[i]
76
+ if (v == null || v === "") throw new Error(`${a.split("=")[0]} needs ${what}`)
77
+ return v
78
+ }
79
+ for (let i = 0; i < own.length; i++) {
80
+ const a = own[i]
81
+ if (a === "-h" || a === "--help") out.help = true
82
+ else if (a === "-v" || a === "--version") out.version = true
83
+ else if (a === "--code-branches") out.codeBranches = true
84
+ else if (a === "--verbose") out.verbose = true
85
+ else if (a === "--port" || a.startsWith("--port=")) {
86
+ const v = a.includes("=") ? a.split("=")[1] : own[++i]
87
+ const n = Number(v)
88
+ if (v == null || v === "" || !Number.isInteger(n) || n < 1 || n > 65535) throw new Error(`--port needs a number between 1 and 65535 (got ${v == null ? "nothing" : JSON.stringify(v)})`)
89
+ out.port = n
90
+ out.portSet = true
91
+ } else if (a === "--url" || a.startsWith("--url=")) {
92
+ out.url = a.includes("=") ? a.split("=")[1] : own[++i]
93
+ if (!out.url) throw new Error("--url needs a value, like http://localhost:3014")
94
+ } else if (a === "--root" || a.startsWith("--root=")) {
95
+ out.root = value(a, a.includes("=") ? i : ++i, "a folder")
96
+ } else if (a.startsWith("-")) throw new Error(`unknown option ${a} (pass dev server options after --, e.g. retake . -- ${a})`)
97
+ else positional.push(a)
98
+ }
99
+ if (["dev", "init", "mcp", "help"].includes(positional[0])) out.cmd = positional.shift()
100
+ else if (positional.length) out.cmd = "dev" // a bare path (or URL) means dev
101
+ if (out.cmd === "help") out.help = true
102
+ const target = positional.shift() ?? null
103
+ if (target && /^https?:\/\//i.test(target)) {
104
+ try {
105
+ out.upstream = new URL(target).href
106
+ } catch {
107
+ throw new Error(`${JSON.stringify(target)} isn't a URL`)
108
+ }
109
+ } else out.project = target
110
+ if (positional.length) throw new Error(`unexpected argument ${JSON.stringify(positional[0])}`)
111
+ // `retake -- next dev`: no project, and the first word isn't an option.
112
+ if (!out.project && !out.upstream && passthrough.length && !passthrough[0].startsWith("-") && (out.cmd === null || out.cmd === "dev")) {
113
+ out.command = passthrough
114
+ out.passthrough = passthrough = []
115
+ out.cmd = "dev"
116
+ }
117
+ return out
118
+ }
119
+
120
+ // The project's own Vite (so its plugins match), found the way Node would find
121
+ // it from the project, walking up to a workspace root. Falls back to ours.
122
+ export function resolveVite(project) {
123
+ const ours = path.join(HOME, "package.json")
124
+ for (const from of [path.join(project, "package.json"), ours]) {
125
+ try {
126
+ const pkgPath = createRequire(from).resolve("vite/package.json")
127
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"))
128
+ const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin && pkg.bin.vite
129
+ return { bin: path.join(path.dirname(pkgPath), bin || "bin/vite.js"), version: pkg.version, own: from === ours }
130
+ } catch {}
131
+ }
132
+ return null
133
+ }
134
+
135
+ // Wrapper config and Vite's dep cache live outside the project, keyed by the
136
+ // project's absolute path so two projects with the same folder name don't clash.
137
+ export function workDir(project) {
138
+ const hash = crypto.createHash("sha1").update(project).digest("hex").slice(0, 10)
139
+ const base = process.env.RETAKE_CACHE_DIR || path.join(os.tmpdir(), "retake")
140
+ return path.join(base, `${path.basename(project)}-${hash}`)
141
+ }
142
+
143
+ async function dev(opts) {
144
+ if (opts.upstream || opts.command) {
145
+ if (opts.codeBranches) fail("--code-branches only works on Vite apps (retake <project>)")
146
+ const root = path.resolve(opts.root || ".")
147
+ // `retake -- "next dev --turbo"` (one word) is a command line as it is.
148
+ const command = opts.command && (opts.command.length === 1 ? opts.command[0] : opts.command.map(shellQuote).join(" "))
149
+ // Run from a framework's folder, it's that framework (it tunes the runtime).
150
+ const here = opts.command ? detectProject(process.cwd()) : null
151
+ const framework = here && here.mode === "front" ? here.framework : null
152
+ return front({ ...opts, root, upstream: opts.upstream, command, cwd: process.cwd(), framework, dir: framework ? process.cwd() : null })
153
+ }
154
+ const project = path.resolve(opts.project || ".")
155
+ if (!fs.existsSync(project)) fail(`${project} doesn't exist`)
156
+ if (!fs.existsSync(path.join(project, "package.json"))) fail(`no package.json in ${project}`, "point retake at your app's folder, e.g. retake ./my-app, or run retake -- <your dev command>")
157
+ const found = detectProject(project)
158
+ if (found.mode === "front") {
159
+ if (opts.codeBranches) fail(`--code-branches only works on Vite apps, not ${found.framework}`, "drop --code-branches; everything else works the same")
160
+ if (found.framework === "next") {
161
+ const warning = nextDebugChannelWarning(project)
162
+ if (warning) console.warn(`retake: ${warning}`)
163
+ }
164
+ return front({ ...opts, root: path.resolve(opts.root || project), command: withArgs(found.command, opts.passthrough, found.pm), cwd: project, framework: found.framework, dir: project })
165
+ }
166
+ if (found.mode !== "vite") {
167
+ fail(`${found.reason}`, `run your dev server through Retake instead: retake -- <your dev command> (e.g. retake -- npm run dev), or retake http://localhost:<port> if it's already running`)
168
+ }
169
+ viteDev(project, opts)
170
+ }
171
+
172
+ // A Vite single-page app: the project's own Vite, with the plugin added from a
173
+ // wrapper config outside the project.
174
+ function viteDev(project, opts) {
175
+ const vite = resolveVite(project)
176
+ if (!vite) fail(`vite isn't installed for ${project}`, "run your package manager's install there first")
177
+ if (vite.own) console.warn(`retake: vite not found from ${project}; using retake's own vite ${vite.version}`)
178
+ const config = CONFIGS.map((f) => path.join(project, f)).find((f) => fs.existsSync(f))
179
+ const work = workDir(project)
180
+ fs.mkdirSync(work, { recursive: true })
181
+ // One wrapper (and dep cache) per port: Vite watches its config, so two runs
182
+ // on one project sharing a wrapper would restart each other on the wrong port.
183
+ const wrapper = path.join(work, `vite.config.${opts.port}.mjs`)
184
+ const url = (p) => JSON.stringify(pathToFileURL(p).href)
185
+ fs.writeFileSync(
186
+ wrapper,
187
+ `// Generated by retake; regenerated on every run.
188
+ import { retake } from ${url(path.join(HOME, "src", "plugin.js"))}
189
+ ${config ? `import base from ${url(config)}` : "const base = {}"}
190
+
191
+ export default async (env) => {
192
+ const cfg = (typeof base === "function" ? await base(env) : await base) || {}
193
+ return {
194
+ ...cfg,
195
+ root: cfg.root ? cfg.root : ${JSON.stringify(project)},
196
+ // Our own dep cache, so the project's node_modules/.vite is left alone.
197
+ cacheDir: ${JSON.stringify(path.join(work, `vite-${opts.port}`))},
198
+ plugins: [retake({ codeBranches: ${opts.codeBranches}, banner: true${opts.root ? `, root: ${JSON.stringify(path.resolve(opts.root))}` : ""} }), ...(cfg.plugins || [])],
199
+ server: { ...cfg.server, port: ${opts.port}, strictPort: true },
200
+ }
201
+ }
202
+ `,
203
+ )
204
+ const child = spawn(process.execPath, [vite.bin, "--config", wrapper, ...opts.passthrough], {
205
+ cwd: project,
206
+ stdio: "inherit",
207
+ env: { ...process.env, VITE_CONFIG_NATIVE_IGNORE_WARNING: "true", RETAKE_PROJECT: project },
208
+ })
209
+ child.on("exit", (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
210
+ for (const sig of /** @type {NodeJS.Signals[]} */ (["SIGINT", "SIGTERM", "SIGHUP"])) process.on(sig, () => child.kill(sig))
211
+ }
212
+
213
+ // Front-server mode: Retake on --port, in front of a dev server that's
214
+ // running (`upstream`) or that `command` starts.
215
+ async function front(opts) {
216
+ const { startFront } = await import(pathToFileURL(path.join(HOME, "src", "server", "front.js")).href)
217
+ // Where the dev command listens isn't known until it says (or answers).
218
+ let found = /** @type {{ resolve: (url: string) => void, reject: (err: any) => void } | null} */ (null)
219
+ const upstream = opts.upstream || new Promise((resolve, reject) => (found = { resolve, reject }))
220
+ let label = opts.upstream ? new URL(opts.upstream).host : opts.command
221
+ let server
222
+ try {
223
+ server = await startFront({
224
+ upstream,
225
+ port: opts.port,
226
+ root: opts.root,
227
+ verbose: opts.verbose,
228
+ framework: opts.framework,
229
+ dir: opts.dir,
230
+ watch: opts.dir || (opts.command ? opts.cwd : null), // whose edits retire kept pages
231
+ get label() {
232
+ return label // the waiting page's "Waiting for next dev on :3015…"
233
+ },
234
+ })
235
+ } catch (err) {
236
+ if (err.code === "EADDRINUSE") fail(`port ${opts.port} is in use`, `pick another with --port, e.g. --port ${opts.port === 65535 ? 3014 : opts.port + 1}`)
237
+ throw err
238
+ }
239
+ // "Retake timeline docked at http://localhost:3014 (in front of next dev on :3015)"
240
+ const banner = (url, what) => {
241
+ console.log(`\n \x1b[1mRetake\x1b[0m timeline docked at ${server.url} (in front of ${what})`)
242
+ console.log(` Using sign-in? Sign in at ${new URL(url).origin} first.\n`)
243
+ }
244
+ const stop = async (code) => {
245
+ await server.close().catch(() => {})
246
+ process.exit(code)
247
+ }
248
+ if (opts.upstream) {
249
+ banner(opts.upstream, new URL(opts.upstream).origin)
250
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, () => stop(0))
251
+ return
252
+ }
253
+ // PORT is the dev server's, unless it's the one Retake took.
254
+ const env = { ...process.env }
255
+ if (Number(env.PORT) === server.port) delete env.PORT
256
+ const dev = await runDevCommand(opts.command, { cwd: opts.cwd, env })
257
+ if (dev.portTaken) console.warn(`retake: PORT ${dev.portTaken} is already in use; the dev command gets PORT=${dev.port}`)
258
+ label = `${opts.command} on :${dev.port}`
259
+ for (const sig of /** @type {NodeJS.Signals[]} */ (["SIGINT", "SIGTERM", "SIGHUP"])) {
260
+ process.on(sig, () => {
261
+ dev.stop(sig)
262
+ // A dev server that ignores the signal gets SIGKILL after 5 s.
263
+ setTimeout(() => dev.stop("SIGKILL"), 5000).unref()
264
+ })
265
+ }
266
+ // Exit with the dev command's exit code, once what it started has stopped
267
+ // too (a wrapper can exit on Ctrl-C before its server does: that one gets
268
+ // SIGKILL 5 s on, not left running on its own).
269
+ dev.exited.then(async (code) => {
270
+ await dev.reap(5000)
271
+ stop(code)
272
+ })
273
+ try {
274
+ const url = await dev.url
275
+ found?.resolve(url)
276
+ label = `${opts.command} on :${new URL(url).port}`
277
+ banner(url, label)
278
+ } catch (err) {
279
+ found?.reject(err)
280
+ }
281
+ }
282
+ function init() {
283
+ console.log(`Add Retake to vite.config (dev only; it does nothing in builds):
284
+
285
+ import { retake } from "retake-dev"
286
+
287
+ export default defineConfig({
288
+ plugins: [retake(), /* ...your plugins */],
289
+ })
290
+
291
+ Options: retake({ codeBranches: true }) gives each timeline its own version of the code.
292
+ Or leave the project untouched and run: npx retake-dev .
293
+ (Next, Nuxt, React Router, SvelteKit, Astro... too: it puts Retake in front of your dev server.)`)
294
+ }
295
+
296
+ async function main() {
297
+ let opts
298
+ try {
299
+ opts = parseArgs(process.argv.slice(2))
300
+ } catch (err) {
301
+ fail(err.message, "run retake --help for usage")
302
+ }
303
+ if (opts.version) return console.log(PKG.version)
304
+ if (opts.help || !opts.cmd) {
305
+ console.log(USAGE)
306
+ return
307
+ }
308
+ if (opts.cmd === "dev") return dev(opts).catch((err) => fail(err.message))
309
+ if (opts.cmd === "init") return init()
310
+ if (opts.cmd === "mcp") {
311
+ const { runMcp } = await import(pathToFileURL(path.join(HOME, "src", "server", "mcp.js")).href)
312
+ // Without --url/--port it finds the server from <cwd>/.retake/server.json.
313
+ return runMcp({ url: opts.url || process.env.RETAKE_URL || (opts.portSet ? `http://localhost:${opts.port}` : null) })
314
+ }
315
+ }
316
+
317
+ // Only run when executed, not when imported by tests.
318
+ const invoked = process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
319
+ if (invoked) main()