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
@@ -0,0 +1,285 @@
1
+ // The dev-server side of the dock: session + recordings persisted under
2
+ // <root>/.retake, notes that agents can update, and an SSE stream so the dock
3
+ // hears about changes live. See CONTRACT.md ("Server HTTP").
4
+ //
5
+ // GET/PUT /__retake/session
6
+ // GET/PUT/DELETE /__retake/recording/:branchId
7
+ // GET /__retake/notes
8
+ // GET /__retake/notes/:id
9
+ // PATCH /__retake/notes/:id { status?, reply? }
10
+ // GET /__retake/events SSE: note-updated, code-version, active-changed, session
11
+ //
12
+ // Mutating requests need `x-retake-token`. The token is injected into the
13
+ // shell page and written to <root>/.retake/server.json for local tools (MCP).
14
+ import fs from "node:fs"
15
+ import path from "node:path"
16
+ import zlib from "node:zlib"
17
+ import { promisify } from "node:util"
18
+
19
+ const gzip = promisify(zlib.gzip)
20
+ const gunzip = promisify(zlib.gunzip)
21
+
22
+ const STATUSES = new Set(["pending", "acknowledged", "resolved", "dismissed"])
23
+ const MAX_BODY = 256 * 1024 * 1024
24
+ const EMPTY = () => ({ branches: [], activeId: null, markers: [], notes: [] })
25
+
26
+ export function createBus() {
27
+ const clients = new Set()
28
+ return {
29
+ clients,
30
+ emit(event, data) {
31
+ const msg = `event: ${event}\ndata: ${JSON.stringify(data ?? {})}\n\n`
32
+ for (const res of clients) res.write(msg)
33
+ },
34
+ }
35
+ }
36
+
37
+ export function sessionStore(root) {
38
+ const dir = path.join(root, ".retake")
39
+ const recDir = path.join(dir, "recordings")
40
+ const sessionFile = path.join(dir, "session.json")
41
+ fs.mkdirSync(recDir, { recursive: true })
42
+ const ignore = path.join(dir, ".gitignore")
43
+ if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, "*\n")
44
+
45
+ const atomic = (file, data) => {
46
+ const tmp = `${file}.${process.pid}-${Date.now()}.tmp`
47
+ fs.writeFileSync(tmp, data)
48
+ fs.renameSync(tmp, file)
49
+ }
50
+ const recFile = (id) => {
51
+ if (!/^[\w-]{1,64}$/.test(String(id))) throw Object.assign(new Error("bad branch id"), { status: 400 })
52
+ return path.join(recDir, `${id}.json`)
53
+ }
54
+ return {
55
+ dir,
56
+ getSession() {
57
+ try {
58
+ return { ...EMPTY(), ...JSON.parse(fs.readFileSync(sessionFile, "utf8")) }
59
+ } catch {
60
+ return EMPTY()
61
+ }
62
+ },
63
+ putSession(s) {
64
+ atomic(sessionFile, JSON.stringify({ ...EMPTY(), ...s }))
65
+ },
66
+ // Recordings are gzipped on disk (JSON compresses ~10×), written off the
67
+ // request path and atomically.
68
+ async getRecording(id) {
69
+ const f = recFile(id)
70
+ try {
71
+ return (await gunzip(await fs.promises.readFile(f + ".gz"))).toString("utf8")
72
+ } catch {}
73
+ try {
74
+ return await fs.promises.readFile(f, "utf8") // older, uncompressed
75
+ } catch {
76
+ return null
77
+ }
78
+ },
79
+ async putRecording(id, text) {
80
+ const f = recFile(id)
81
+ const tmp = `${f}.gz.${process.pid}-${Date.now()}.tmp`
82
+ await fs.promises.writeFile(tmp, await gzip(text, { level: 6 }))
83
+ await fs.promises.rename(tmp, f + ".gz")
84
+ fs.rmSync(f, { force: true })
85
+ },
86
+ deleteRecording(id) {
87
+ const f = recFile(id)
88
+ fs.rmSync(f, { force: true })
89
+ fs.rmSync(f + ".gz", { force: true })
90
+ },
91
+ // Drop recordings of branches the session no longer has ("Start fresh", deletes).
92
+ prune(session) {
93
+ const keep = new Set((session.branches || []).map((b) => `${b.id}.json`))
94
+ for (const f of fs.readdirSync(recDir)) {
95
+ const base = f.replace(/\.gz$/, "")
96
+ if (base.endsWith(".json") && !keep.has(base)) fs.rmSync(path.join(recDir, f), { force: true })
97
+ }
98
+ },
99
+ }
100
+ }
101
+
102
+ function readBody(req) {
103
+ return new Promise((resolve, reject) => {
104
+ let size = 0
105
+ const chunks = []
106
+ req.on("data", (c) => {
107
+ size += c.length
108
+ if (size > MAX_BODY) {
109
+ reject(Object.assign(new Error("body too large"), { status: 413 }))
110
+ req.destroy()
111
+ } else chunks.push(c)
112
+ })
113
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")))
114
+ req.on("error", reject)
115
+ })
116
+ }
117
+
118
+ // Apply a PATCH to a note: a status change and/or a reply.
119
+ export function patchNote(note, body, from = "agent") {
120
+ if (body.status != null) {
121
+ if (!STATUSES.has(body.status)) throw Object.assign(new Error(`status must be one of ${[...STATUSES].join(", ")}`), { status: 400 })
122
+ note.status = body.status
123
+ }
124
+ if (body.reply != null) {
125
+ const text = typeof body.reply === "string" ? body.reply : body.reply.text
126
+ if (!text) throw Object.assign(new Error("reply needs text"), { status: 400 })
127
+ note.replies = [...(note.replies || []), { from: body.reply.from || from, text: String(text), at: Date.now() }]
128
+ }
129
+ if (!note.status) note.status = "pending"
130
+ if (!note.replies) note.replies = []
131
+ return note
132
+ }
133
+
134
+ const cleanups = new Set()
135
+ // <root>/.retake/server.json tells local tools (MCP) where the server is and
136
+ // its token. Removed again when this process exits (if it's still ours).
137
+ export function writeServerInfo({ root, url, token, base = "/" }) {
138
+ const dir = path.join(root, ".retake")
139
+ const file = path.join(dir, "server.json")
140
+ try {
141
+ fs.mkdirSync(dir, { recursive: true })
142
+ fs.writeFileSync(file, JSON.stringify({ url, base, token, pid: process.pid }), { mode: 0o600 })
143
+ } catch {}
144
+ if (!cleanups.has(file)) {
145
+ cleanups.add(file)
146
+ process.once("exit", () => {
147
+ try {
148
+ if (JSON.parse(fs.readFileSync(file, "utf8")).pid === process.pid) fs.rmSync(file, { force: true })
149
+ } catch {}
150
+ })
151
+ }
152
+ }
153
+
154
+ /**
155
+ * The /__retake/ API as a plain `(req, res, next)` handler, for any Node HTTP
156
+ * server (Vite's middleware stack, the front server, a bare http.Server).
157
+ * Requests outside it (and /__retake/ routes it doesn't know, like the code
158
+ * versions' ones) go to `next`, or get a 404 without one.
159
+ * @param {{ root: string, token: string, bus: ReturnType<typeof createBus> }} options
160
+ */
161
+ export function createSessionHandler({ root, token, bus }) {
162
+ const store = sessionStore(root)
163
+ const heartbeat = setInterval(() => {
164
+ for (const res of bus.clients) res.write(": ping\n\n")
165
+ }, 25_000)
166
+ heartbeat.unref()
167
+
168
+ const send = (res, code, body, type = "application/json") => {
169
+ res.statusCode = code
170
+ res.setHeader("Content-Type", type)
171
+ res.setHeader("Cache-Control", "no-store")
172
+ res.end(typeof body === "string" ? body : JSON.stringify(body))
173
+ }
174
+
175
+ const handler = async (req, res, next = () => send(res, 404, { error: "not found" })) => {
176
+ const url = new URL(req.url, "http://x")
177
+ const p = url.pathname
178
+ if (!p.startsWith("/__retake/")) return next()
179
+ const route = p.slice("/__retake/".length).split("/")
180
+ const mutating = req.method !== "GET" && req.method !== "HEAD"
181
+ if (route[0] === "tailwind" && req.method === "GET") {
182
+ // Where Tailwind is configured: a v3 config file, or (v4) the CSS that imports it.
183
+ const cfg = ["tailwind.config.ts", "tailwind.config.js", "tailwind.config.mjs", "tailwind.config.cjs"].find((f) => fs.existsSync(path.join(root, f)))
184
+ let config = cfg || null
185
+ if (!config) {
186
+ const walk = (d, depth) => {
187
+ if (depth > 4 || config) return
188
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
189
+ if (config || e.name.startsWith(".") || e.name === "node_modules" || e.name === "dist") continue
190
+ const f = path.join(d, e.name)
191
+ if (e.isDirectory()) walk(f, depth + 1)
192
+ else if (/\.css$/.test(e.name) && /@import\s+["']tailwindcss/.test(fs.readFileSync(f, "utf8"))) config = path.relative(root, f)
193
+ }
194
+ }
195
+ try {
196
+ walk(root, 0)
197
+ } catch {}
198
+ }
199
+ return send(res, 200, { config })
200
+ }
201
+ const known = ["session", "recording", "notes", "events"].includes(route[0])
202
+ if (!known) return next() // e.g. /__retake/version and /checkout (code-versions)
203
+ if (mutating && req.headers["x-retake-token"] !== token) return send(res, 403, { error: "missing or wrong x-retake-token" })
204
+ try {
205
+ if (route[0] === "events" && req.method === "GET") {
206
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" })
207
+ res.write(": retake\n\n")
208
+ bus.clients.add(res)
209
+ req.on("close", () => bus.clients.delete(res))
210
+ return
211
+ }
212
+ if (route[0] === "session" && route.length === 1) {
213
+ if (req.method === "GET") return send(res, 200, store.getSession())
214
+ if (req.method === "PUT") {
215
+ const before = store.getSession()
216
+ const next = JSON.parse(await readBody(req))
217
+ store.putSession(next)
218
+ store.prune(next)
219
+ if (next.activeId !== before.activeId) bus.emit("active-changed", { activeId: next.activeId })
220
+ bus.emit("session", { at: Date.now() })
221
+ return send(res, 200, { ok: true })
222
+ }
223
+ }
224
+ if (route[0] === "recording" && route.length === 2) {
225
+ const id = decodeURIComponent(route[1])
226
+ if (req.method === "GET") {
227
+ const text = await store.getRecording(id)
228
+ return text == null ? send(res, 404, { error: "no recording" }) : send(res, 200, text)
229
+ }
230
+ if (req.method === "PUT") {
231
+ const text = await readBody(req)
232
+ JSON.parse(text) // must be valid JSON
233
+ await store.putRecording(id, text)
234
+ return send(res, 200, { ok: true })
235
+ }
236
+ if (req.method === "DELETE") {
237
+ store.deleteRecording(id)
238
+ return send(res, 200, { ok: true })
239
+ }
240
+ }
241
+ if (route[0] === "notes") {
242
+ const session = store.getSession()
243
+ const notes = session.notes || []
244
+ if (route.length === 1 && req.method === "GET") return send(res, 200, notes)
245
+ const id = route[1] && decodeURIComponent(route[1])
246
+ const note = notes.find((n) => String(n.id) === String(id))
247
+ if (route.length === 2 && req.method === "GET") return note ? send(res, 200, note) : send(res, 404, { error: "no such note" })
248
+ if (route.length === 2 && req.method === "PATCH") {
249
+ if (!note) return send(res, 404, { error: "no such note" })
250
+ patchNote(note, JSON.parse((await readBody(req)) || "{}"), req.headers["x-retake-from"] === "user" ? "user" : "agent")
251
+ store.putSession(session)
252
+ bus.emit("note-updated", note)
253
+ return send(res, 200, note)
254
+ }
255
+ }
256
+ return send(res, 405, { error: `${req.method} not supported on ${p}` })
257
+ } catch (err) {
258
+ return send(res, err.status || (err instanceof SyntaxError ? 400 : 500), { error: err.message })
259
+ }
260
+ }
261
+ handler.store = store
262
+ // Ends the heartbeat and the open event streams (a front server closing).
263
+ handler.close = () => {
264
+ clearInterval(heartbeat)
265
+ for (const res of bus.clients) res.end()
266
+ bus.clients.clear()
267
+ }
268
+ return handler
269
+ }
270
+
271
+ // The Vite adapter: the handler on Vite's middleware stack, and server.json
272
+ // written once Vite is listening.
273
+ export function sessionApi(server, { token, bus, root = server.config.root }) {
274
+ const handler = createSessionHandler({ root, token, bus })
275
+ const address = () => {
276
+ const a = server.httpServer && server.httpServer.address()
277
+ const port = a && typeof a === "object" ? a.port : server.config.server.port
278
+ return `${server.config.server.https ? "https" : "http"}://localhost:${port}`
279
+ }
280
+ const info = () => writeServerInfo({ root, url: address(), token, base: server.config.base || "/" })
281
+ if (server.httpServer) server.httpServer.once("listening", info)
282
+ else info()
283
+ server.middlewares.use(handler)
284
+ return { store: handler.store, address }
285
+ }
@@ -0,0 +1,174 @@
1
+ // Runs the app's own dev command (`next dev`, `pnpm run dev`...) for the front
2
+ // server, and finds out where it listens: the first local URL it prints wins
3
+ // (Vite-based tools ignore PORT), else PORT, which is set to a free port unless
4
+ // it already was (and that one is free: a PORT exported in the shell with
5
+ // another app on it would have been taken for the dev server). The command runs
6
+ // in its own process group, so stopping it stops what it started too
7
+ // (`pnpm dev` → `next dev`), even what outlives the command itself (`reap`).
8
+ import http from "node:http"
9
+ import https from "node:https"
10
+ import net from "node:net"
11
+ import path from "node:path"
12
+ import fs from "node:fs"
13
+ import { spawn, spawnSync } from "node:child_process"
14
+
15
+ const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07/g
16
+ const LOCAL_URL = /\bhttps?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0):(\d{2,5})\b[^\s'"]*/
17
+
18
+ // The first local URL in a line of output (0.0.0.0 means this machine).
19
+ export function localUrlIn(text) {
20
+ const m = LOCAL_URL.exec(String(text).replace(ANSI, ""))
21
+ return m ? m[0].replace("0.0.0.0", "localhost").replace(/[).,;]+$/, "") : null
22
+ }
23
+
24
+ export function freePort() {
25
+ return new Promise((resolve, reject) => {
26
+ const s = net.createServer()
27
+ s.unref()
28
+ s.once("error", reject)
29
+ s.listen(0, "127.0.0.1", () => {
30
+ const { port } = /** @type {import("node:net").AddressInfo} */ (s.address())
31
+ s.close(() => resolve(port))
32
+ })
33
+ })
34
+ }
35
+
36
+ // Can we listen on this port (on any loopback address an app would use)?
37
+ function portFree(port) {
38
+ const on = (host) =>
39
+ new Promise((resolve) => {
40
+ const s = net.createServer()
41
+ s.unref()
42
+ s.once("error", (/** @type {NodeJS.ErrnoException} */ e) => resolve(e.code === "EADDRNOTAVAIL" || e.code === "EAFNOSUPPORT"))
43
+ s.listen(port, host, () => s.close(() => resolve(true)))
44
+ })
45
+ return Promise.all([on("127.0.0.1"), on("::1")]).then((r) => r.every(Boolean))
46
+ }
47
+
48
+ // Does anything answer HTTP there (any status)?
49
+ export function answers(url, timeout = 2000) {
50
+ return new Promise((resolve) => {
51
+ const u = new URL(url)
52
+ const lib = u.protocol === "https:" ? https : http
53
+ const r = lib.request(u, /** @type {import("node:https").RequestOptions} */ ({ method: "HEAD", autoSelectFamily: true, rejectUnauthorized: false, timeout }), (res) => {
54
+ res.resume()
55
+ resolve(true)
56
+ })
57
+ r.on("timeout", () => r.destroy())
58
+ r.on("error", () => resolve(false))
59
+ r.end()
60
+ })
61
+ }
62
+
63
+ // The project's node_modules/.bin folders (walking up to a workspace root), so
64
+ // `retake -- next dev` finds the project's own `next`.
65
+ function binPath(cwd) {
66
+ const dirs = []
67
+ for (let d = path.resolve(cwd); ; d = path.dirname(d)) {
68
+ const bin = path.join(d, "node_modules", ".bin")
69
+ if (fs.existsSync(bin)) dirs.push(bin)
70
+ if (path.dirname(d) === d) break
71
+ }
72
+ return dirs
73
+ }
74
+
75
+ /**
76
+ * @param {string} command a shell command line
77
+ * @param {{ cwd?: string, env?: NodeJS.ProcessEnv, onOutput?: (chunk: Buffer, stream: "stdout" | "stderr") => void }} [options]
78
+ * @returns {Promise<{ child: import("node:child_process").ChildProcess, port: number, portTaken: number | null,
79
+ * url: Promise<string>, exited: Promise<number>, stop(signal?: NodeJS.Signals): void, reap(ms?: number): Promise<void> }>}
80
+ * `url` resolves once the server answers (it rejects if the command exits first).
81
+ * `portTaken`: the PORT given, if something else was already on it. `reap`:
82
+ * once the command has exited, what it started that's still running gets
83
+ * SIGTERM (unless it was signalled already), then SIGKILL `ms` after that.
84
+ */
85
+ export async function runDevCommand(command, { cwd = process.cwd(), env = process.env, onOutput } = {}) {
86
+ const asked = env.PORT ? Number(env.PORT) : null
87
+ const portTaken = asked && !(await portFree(asked)) ? asked : null
88
+ const port = asked && !portTaken ? asked : await freePort()
89
+ const sep = process.platform === "win32" ? ";" : ":"
90
+ /** @type {NodeJS.ProcessEnv} */
91
+ const childEnv = { ...env, PORT: String(port), PATH: [...binPath(cwd), env.PATH || env.Path || ""].join(sep) }
92
+ // Astro (7.x) moves `astro dev` into a detached background process when it
93
+ // thinks a coding agent ran it: out of this process
94
+ // group, so stopping Retake left it running, and the next run found "already
95
+ // running" and exited. Retake is the foreground owner; this is how Astro's own
96
+ // background child tells it not to background again.
97
+ if (childEnv.ASTRO_DEV_BACKGROUND == null) childEnv.ASTRO_DEV_BACKGROUND = "1"
98
+ if (process.stdout.isTTY && childEnv.FORCE_COLOR == null && childEnv.NO_COLOR == null) childEnv.FORCE_COLOR = "1"
99
+ const win = process.platform === "win32"
100
+ const child = spawn(command, { cwd, env: childEnv, shell: true, detached: !win, stdio: ["inherit", "pipe", "pipe"], windowsHide: true })
101
+
102
+ /** @type {string | null} */
103
+ let printed = null
104
+ let pending = ""
105
+ const scan = (chunk) => {
106
+ if (printed) return
107
+ pending = (pending + chunk.toString("utf8")).slice(-4096)
108
+ const u = localUrlIn(pending)
109
+ if (u) printed = u
110
+ }
111
+ const pass = (name) => (chunk) => {
112
+ scan(chunk)
113
+ if (onOutput) onOutput(chunk, name)
114
+ else (name === "stderr" ? process.stderr : process.stdout).write(chunk)
115
+ }
116
+ child.stdout.on("data", pass("stdout"))
117
+ child.stderr.on("data", pass("stderr"))
118
+
119
+ /** @type {number | null} */
120
+ let exitCode = null
121
+ const exited = new Promise((resolve) => {
122
+ child.on("exit", (code, signal) => resolve((exitCode = code ?? (signal ? 1 : 0))))
123
+ child.on("error", () => resolve((exitCode = 127)))
124
+ })
125
+
126
+ // The printed URL (once it answers) wins; PORT answering first means the
127
+ // tool honoured it.
128
+ const url = (async () => {
129
+ const fallback = `http://localhost:${port}`
130
+ for (;;) {
131
+ if (exitCode != null) throw new Error(`the dev command exited (code ${exitCode}) before it started a server`)
132
+ if (printed) {
133
+ const origin = new URL(printed).origin
134
+ if (await answers(origin)) return origin
135
+ } else if (await answers(fallback, 500)) return fallback
136
+ await new Promise((r) => setTimeout(r, 250))
137
+ }
138
+ })()
139
+ url.catch(() => {})
140
+
141
+ // The command's process group: its leader (the shell, a package manager) can
142
+ // exit while what it started is still stopping.
143
+ let signalled = false
144
+ const groupAlive = () => {
145
+ if (win || child.pid == null) return false
146
+ try {
147
+ process.kill(-child.pid, 0)
148
+ return true
149
+ } catch (e) {
150
+ return e.code === "EPERM"
151
+ }
152
+ }
153
+ const stop = (/** @type {NodeJS.Signals} */ signal = "SIGTERM") => {
154
+ if (child.pid == null || (exitCode != null && !groupAlive())) return
155
+ signalled = true
156
+ try {
157
+ if (win) spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" })
158
+ else process.kill(-child.pid, signal)
159
+ } catch {
160
+ try {
161
+ child.kill(signal)
162
+ } catch {}
163
+ }
164
+ }
165
+ const reap = async (ms = 5000) => {
166
+ await exited
167
+ if (!groupAlive()) return
168
+ if (!signalled) stop("SIGTERM")
169
+ const end = Date.now() + ms
170
+ while (groupAlive() && Date.now() < end) await new Promise((r) => setTimeout(r, 100))
171
+ if (groupAlive()) stop("SIGKILL")
172
+ }
173
+ return { child, port, portTaken, url, exited, stop, reap }
174
+ }
@@ -0,0 +1,167 @@
1
+ // What kind of project `retake <dir>` was pointed at, and how to run it.
2
+ // front: a framework with its own dev server (Next, Nuxt, React Router,
3
+ // Remix, SvelteKit, Astro...). Retake runs its dev command and sits in
4
+ // front of it (server/front.js).
5
+ // vite: a Vite single-page app (index.html). Retake runs the project's own
6
+ // Vite with the plugin added, from a wrapper config (bin/retake.js).
7
+ import fs from "node:fs"
8
+ import path from "node:path"
9
+
10
+ // Dependency → framework, and the framework's own dev command (the last resort,
11
+ // when package.json has no dev script).
12
+ export const FRAMEWORKS = {
13
+ next: { name: "next", cli: "next dev" },
14
+ nuxt: { name: "nuxt", cli: "nuxt dev" },
15
+ "@remix-run/dev": { name: "remix", cli: "remix vite:dev" },
16
+ "@react-router/dev": { name: "react-router", cli: "react-router dev" },
17
+ "@sveltejs/kit": { name: "sveltekit", cli: "vite dev" },
18
+ astro: { name: "astro", cli: "astro dev" },
19
+ "@tanstack/react-start": { name: "tanstack-start", cli: "vite dev" },
20
+ "@solidjs/start": { name: "solid-start", cli: "vinxi dev" },
21
+ vike: { name: "vike", cli: "vike dev" },
22
+ waku: { name: "waku", cli: "waku dev" },
23
+ "@analogjs/platform": { name: "analog", cli: "vite" },
24
+ }
25
+ const VITE_CONFIGS = ["vite.config.ts", "vite.config.mts", "vite.config.cts", "vite.config.js", "vite.config.mjs", "vite.config.cjs"]
26
+ const LOCKS = [
27
+ ["pnpm-lock.yaml", "pnpm"],
28
+ ["yarn.lock", "yarn"],
29
+ ["bun.lock", "bun"],
30
+ ["bun.lockb", "bun"],
31
+ ["package-lock.json", "npm"],
32
+ ]
33
+
34
+ // The package manager: package.json's `packageManager`, else the nearest lockfile
35
+ // (a workspace's is at its root), else npm.
36
+ export function packageManager(dir, pkg = readPkg(dir)) {
37
+ const declared = pkg && typeof pkg.packageManager === "string" && /^(pnpm|yarn|bun|npm)@/.exec(pkg.packageManager)
38
+ if (declared) return declared[1]
39
+ for (let d = path.resolve(dir); ; d = path.dirname(d)) {
40
+ for (const [file, pm] of LOCKS) if (fs.existsSync(path.join(d, file))) return pm
41
+ if (path.dirname(d) === d) return "npm"
42
+ }
43
+ }
44
+
45
+ function readPkg(dir) {
46
+ try {
47
+ return JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"))
48
+ } catch {
49
+ return null
50
+ }
51
+ }
52
+
53
+ /**
54
+ * @param {string} dir the project folder
55
+ * @returns {{ mode: "front" | "vite" | null, framework: string | null, command: string | null, pm: string | null, reason?: string }}
56
+ */
57
+ export function detectProject(dir) {
58
+ const pkg = readPkg(dir)
59
+ if (!pkg) return { mode: null, framework: null, command: null, pm: null, reason: `no package.json in ${dir}` }
60
+ const deps = { ...pkg.peerDependencies, ...pkg.optionalDependencies, ...pkg.devDependencies, ...pkg.dependencies }
61
+ const dep = Object.keys(FRAMEWORKS).find((d) => d in deps)
62
+ const pm = packageManager(dir, pkg)
63
+ if (dep) {
64
+ const fw = FRAMEWORKS[dep]
65
+ const scripts = pkg.scripts || {}
66
+ /** @type {string | null} */
67
+ let command = null
68
+ if (scripts.dev) command = `${pm} run dev`
69
+ // A start script that runs a dev server (never a production `next start`).
70
+ else if (scripts.start && /\bdev\b|^\s*vite(\s|$)/.test(scripts.start) && !/\bnext\s+start\b/.test(scripts.start)) command = `${pm} run start`
71
+ else command = `${pm === "npm" ? "npx --no-install" : `${pm} exec`} ${fw.cli}`
72
+ return { mode: "front", framework: fw.name, command, pm }
73
+ }
74
+ if (VITE_CONFIGS.some((f) => fs.existsSync(path.join(dir, f))) || fs.existsSync(path.join(dir, "index.html"))) {
75
+ return { mode: "vite", framework: "vite", command: null, pm }
76
+ }
77
+ return { mode: null, framework: null, command: null, pm, reason: `no framework or Vite app found in ${dir}` }
78
+ }
79
+
80
+ // The dev server's own traffic, by URL (regular expressions on path + query),
81
+ // for the runtime to leave alone (RT.exemptUrls, see isExemptUrl in the
82
+ // runtime): HMR sockets, overlays and hot updates. A call stack can't always
83
+ // tell (Turbopack bundles Next's router and its HMR client into one chunk).
84
+ const VITE_TRAFFIC = ["^/@vite/client", "^/@react-refresh"]
85
+ export const DEV_TRAFFIC = {
86
+ next: ["^/_next/webpack-hmr", "^/_next/hmr\\b", "^/__nextjs_", "\\.hot-update\\."],
87
+ nuxt: ["^/__nuxt_devtools__", "^/_nuxt/@vite/client", ...VITE_TRAFFIC],
88
+ astro: ["^/__astro_dev_toolbar", ...VITE_TRAFFIC],
89
+ vite: VITE_TRAFFIC,
90
+ }
91
+ export const devTraffic = (framework) => DEV_TRAFFIC[framework] || VITE_TRAFFIC
92
+
93
+ // The version of Next installed for the project (null: not found): its
94
+ // node_modules or a parent folder's, as Node would find it from there, but not
95
+ // through NODE_PATH, where pnpm puts every package of the workspace Retake was
96
+ // started from (F74).
97
+ export function nextVersion(dir) {
98
+ for (let d = path.resolve(dir); ; d = path.dirname(d)) {
99
+ try {
100
+ return JSON.parse(fs.readFileSync(path.join(d, "node_modules", "next", "package.json"), "utf8")).version || null
101
+ } catch {}
102
+ if (path.dirname(d) === d) return null
103
+ }
104
+ }
105
+
106
+ // The major version of Next the project uses (null: not found).
107
+ export function nextMajor(dir) {
108
+ try {
109
+ const v = nextVersion(dir)
110
+ return Number(v.split(".")[0]) || null
111
+ } catch {
112
+ return null
113
+ }
114
+ }
115
+
116
+ /**
117
+ * How the runtime runs behind the front server (RT, see core.js) for a
118
+ * framework (null: not known yet; the front server sniffs Next itself).
119
+ * The clock starts at the window's load (F47) and scripts added later are
120
+ * held to their recorded moments (F48). `next`: the Next major version the
121
+ * debug-channel adapter is for (F49; only 16 is known), "auto" when the
122
+ * runtime has to ask Next's client (no project folder to look in).
123
+ * @param {string | null} framework
124
+ * @param {string | null} [dir]
125
+ * @returns {import("../../types/index.js").RuntimeConfig}
126
+ */
127
+ export function frontRuntime(framework, dir) {
128
+ /** @type {import("../../types/index.js").RuntimeConfig} */
129
+ const rt = { bootAt: "load", holdScripts: true, exemptUrls: devTraffic(framework) }
130
+ // (No project folder, or Next not found from it: the runtime reads Next's own version, "auto".)
131
+ if (framework === "next") rt.next = (dir && nextMajor(dir)) || "auto"
132
+ return rt
133
+ }
134
+
135
+ // Extra arguments for a detected dev command (`retake . -- --turbopack`).
136
+ export function withArgs(command, args, pm) {
137
+ if (!args || !args.length) return command
138
+ const quoted = args.map(shellQuote).join(" ")
139
+ // `npm run dev --foo` would give --foo to npm itself.
140
+ return pm === "npm" && /^npm run /.test(command) ? `${command} -- ${quoted}` : `${command} ${quoted}`
141
+ }
142
+
143
+ export const shellQuote = (a) => (/^[\w@%+=:,./-]+$/.test(a) ? a : process.platform === "win32" ? `"${a.replace(/"/g, '\\"')}"` : `'${a.replace(/'/g, "'\\''")}'`)
144
+
145
+ // Next 16 sends React's debug data for every page request over its HMR socket
146
+ // (`experimental.reactDebugChannel`, on by default), and a replayed request
147
+ // never gets it, so replayed navigations and server actions never show. Read
148
+ // as text, never evaluated. Returns the warning, or null.
149
+ export function nextDebugChannelWarning(dir) {
150
+ /** @type {string | null} */
151
+ let version = null
152
+ try {
153
+ version = nextVersion(dir)
154
+ } catch {
155
+ return null
156
+ }
157
+ if (!version || Number(version.split(".")[0]) < 16) return null
158
+ const config = ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs", "next.config.mts"].map((f) => path.join(dir, f)).find((f) => fs.existsSync(f))
159
+ let text = ""
160
+ try {
161
+ text = config ? fs.readFileSync(config, "utf8") : ""
162
+ } catch {}
163
+ if (/reactDebugChannel\s*:\s*false/.test(text)) return null
164
+ // Retake speaks Next 16's debug channel itself (runtime/37-next.js).
165
+ if (Number(version.split(".")[0]) === 16) return null
166
+ return `Next ${version}: if replayed navigations and server actions don't show, set experimental: { reactDebugChannel: false } in ${config ? path.basename(config) : "next.config"} (Retake knows Next 16's debug channel, not this version's).`
167
+ }