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.
- package/LICENSE +91 -0
- package/README.md +198 -0
- package/bin/retake.js +319 -0
- package/package.json +87 -0
- package/src/code-versions.js +357 -0
- package/src/core.js +265 -0
- package/src/plugin.js +137 -0
- package/src/runtime/00-core.js +308 -0
- package/src/runtime/10-animations.js +362 -0
- package/src/runtime/20-media.js +157 -0
- package/src/runtime/30-recorder.js +315 -0
- package/src/runtime/32-state.js +315 -0
- package/src/runtime/35-network.js +789 -0
- package/src/runtime/36-scripts.js +129 -0
- package/src/runtime/37-next.js +150 -0
- package/src/runtime/38-observers.js +252 -0
- package/src/runtime/40-input.js +795 -0
- package/src/runtime/45-hover.js +78 -0
- package/src/runtime/50-engine.js +341 -0
- package/src/runtime/60-preview.js +437 -0
- package/src/runtime/65-timeline.js +289 -0
- package/src/runtime/66-activity.js +114 -0
- package/src/runtime/67-csssource.js +226 -0
- package/src/runtime/70-boot.js +460 -0
- package/src/server/api.js +285 -0
- package/src/server/child.js +174 -0
- package/src/server/detect.js +167 -0
- package/src/server/front.js +647 -0
- package/src/server/mcp.js +332 -0
- package/src/shell/00-state.js +85 -0
- package/src/shell/05-api.js +136 -0
- package/src/shell/10-dock.js +754 -0
- package/src/shell/12-checkpoint.js +121 -0
- package/src/shell/15-session.js +291 -0
- package/src/shell/20-timeline.js +734 -0
- package/src/shell/25-input.js +537 -0
- package/src/shell/30-notes.js +870 -0
- package/src/shell/40-code.js +80 -0
- package/src/shell/90-handle.js +15 -0
- package/src/shell/shell.css +295 -0
- package/src/shell/shell.html +59 -0
- package/types/client.d.ts +73 -0
- 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
|
+
}
|