feat: add record command

This commit is contained in:
cloudycotton
2026-03-10 17:46:24 +05:30
parent 7c3162af45
commit ebe5c2d8f5
6 changed files with 304 additions and 2 deletions
+11
View File
@@ -11,4 +11,15 @@ await build({
external: ["puppeteer-core"],
})
await build({
entryPoints: ["src/recorder-daemon.ts"],
bundle: true,
platform: "node",
target: "node18",
format: "esm",
outfile: "dist/recorder-daemon.js",
external: ["puppeteer-core"],
})
console.log("Built dist/mb.js")
console.log("Built dist/recorder-daemon.js")
+162
View File
@@ -0,0 +1,162 @@
import { spawn } from "node:child_process"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
import type { Flags } from "../lib/flags"
import {
readState,
writeState,
clearState,
isProcessRunning,
} from "../lib/recorder-state"
const DEFAULT_FPS = 30
const DEFAULT_SCALE = 1
const getDaemonPath = () => {
// In compiled mode, both mb.js and recorder-daemon.js are in dist/
const currentFile = fileURLToPath(import.meta.url)
return resolve(dirname(currentFile), "recorder-daemon.js")
}
interface StartInput {
path: string
tab: number
fps: number
scale: number
}
const start = async ({ path, tab, fps, scale }: StartInput) => {
const existing = readState()
if (existing && isProcessRunning(existing.pid)) {
throw new Error(
`Already recording to ${existing.path}. Run 'mb record stop' first.`
)
}
// Clear stale state if process is dead
if (existing) clearState()
const outputPath = resolve(path)
const ext = outputPath.split(".").pop()?.toLowerCase()
if (!ext || !["webm", "mp4", "gif"].includes(ext)) {
throw new Error("Output file must end in .webm, .mp4, or .gif")
}
if (fps <= 0) throw new Error("--fps must be a positive number")
if (scale <= 0) throw new Error("--scale must be a positive number")
const config = { path: outputPath, tab, fps, scale }
const daemonPath = getDaemonPath()
const child = spawn(process.execPath, [daemonPath], {
detached: true,
stdio: "ignore",
env: {
...process.env,
MB_RECORDER_CONFIG: JSON.stringify(config),
},
})
child.unref()
if (!child.pid) {
throw new Error("Failed to spawn recorder daemon")
}
writeState({
pid: child.pid,
path: outputPath,
tab,
fps,
scale,
startedAt: Date.now(),
})
// Give daemon time to start and catch errors
await new Promise((r) => setTimeout(r, 2000))
// Verify it's still running
if (!isProcessRunning(child.pid)) {
clearState()
throw new Error("Recorder daemon failed to start. Is Chrome running?")
}
console.log(`Recording to ${outputPath}`)
}
const stop = async () => {
const state = readState()
if (!state) {
throw new Error("No recording in progress")
}
if (!isProcessRunning(state.pid)) {
clearState()
throw new Error("Recording process died unexpectedly")
}
// Send SIGTERM to daemon
process.kill(state.pid, "SIGTERM")
// Wait for process to exit
const maxWait = 10000
const startTime = Date.now()
while (isProcessRunning(state.pid) && Date.now() - startTime < maxWait) {
await new Promise((r) => setTimeout(r, 100))
}
if (isProcessRunning(state.pid)) {
// Force kill if still running
process.kill(state.pid, "SIGKILL")
clearState()
throw new Error("Recorder did not stop gracefully, force killed")
}
clearState()
console.log(state.path)
}
const status = () => {
const state = readState()
if (!state || !isProcessRunning(state.pid)) {
if (state) clearState()
console.log("Not recording")
return
}
const elapsed = ((Date.now() - state.startedAt) / 1000).toFixed(1)
console.log(`Recording to ${state.path} (${elapsed}s elapsed)`)
}
export const record = async (args: string[], flags: Flags) => {
const [subcommand, ...rest] = args
switch (subcommand) {
case "start": {
const path = rest[0]
if (!path) {
throw new Error("Usage: mb record start <file.webm|mp4|gif> [--fps N] [--scale N]")
}
await start({
path,
tab: flags.tab,
fps: flags.fps ?? DEFAULT_FPS,
scale: flags.scale ?? DEFAULT_SCALE,
})
break
}
case "stop":
await stop()
break
case "status":
status()
break
default:
throw new Error(
"Usage: mb record <start|stop|status>\n" +
" start <file> Start recording\n" +
" stop Stop recording and save\n" +
" status Check recording status"
)
}
}
+10 -2
View File
@@ -18,8 +18,9 @@ import { wait } from "./commands/wait"
import { audit } from "./commands/audit"
import { tab } from "./commands/tab"
import { logs } from "./commands/logs"
import { record } from "./commands/record"
const commands = { go, url, back, forward, shot, snap, text, click, type, fill, key, move, drag, scroll, js, wait, audit, tab, logs }
const commands = { go, url, back, forward, shot, snap, text, click, type, fill, key, move, drag, scroll, js, wait, audit, tab, logs, record }
const help = `mb — Browser CLI for Agents
@@ -45,6 +46,11 @@ Interact:
drag <x1> <y1> <x2> <y2> Drag
scroll <dir> [px] Scroll up/down/left/right
Recording:
record start <file> Start recording (.webm, .mp4, .gif)
record stop Stop recording and save
record status Check recording status
Other:
js <code> Run JavaScript
wait <target> Wait for ms/selector/networkidle/url:...
@@ -60,7 +66,9 @@ Flags:
--timeout <ms> Timeout (default: 30000)
--tab <n> Tab index (default: 0)
--json JSON output
--right/--double Right/double click`
--right/--double Right/double click
--fps <n> Recording frame rate (default: 30)
--scale <n> Recording scale factor (default: 1)`
const main = async () => {
try {
+4
View File
@@ -43,6 +43,8 @@ export const parse = (argv: string[]) => {
right: { type: "boolean", default: false },
double: { type: "boolean", default: false },
help: { type: "boolean", short: "h", default: false },
fps: { type: "string" },
scale: { type: "string" },
},
})
} catch (error) {
@@ -61,6 +63,8 @@ export const parse = (argv: string[]) => {
right: values.right!,
double: values.double!,
help: values.help!,
fps: values.fps ? parseNumericFlag({ name: "fps", value: values.fps }) : undefined,
scale: values.scale ? parseNumericFlag({ name: "scale", value: values.scale }) : undefined,
},
}
}
+45
View File
@@ -0,0 +1,45 @@
import { homedir } from "node:os"
import { join } from "node:path"
import { readFileSync, writeFileSync, unlinkSync, existsSync } from "node:fs"
const STATE_FILE = join(homedir(), ".mb-recorder.json")
export interface RecorderState {
pid: number
path: string
tab: number
fps: number
scale: number
startedAt: number
}
export const getStatePath = () => STATE_FILE
export const readState = (): RecorderState | null => {
if (!existsSync(STATE_FILE)) return null
try {
const content = readFileSync(STATE_FILE, "utf-8")
return JSON.parse(content) as RecorderState
} catch {
return null
}
}
export const writeState = (state: RecorderState) => {
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
}
export const clearState = () => {
if (existsSync(STATE_FILE)) {
unlinkSync(STATE_FILE)
}
}
export const isProcessRunning = (pid: number) => {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env node
/**
* Recorder daemon process - spawned by `mb record start`
* Stays alive and records until SIGTERM is received
*/
import { connect } from "./lib/browser"
import { clearState, writeState, readState } from "./lib/recorder-state"
interface DaemonConfig {
path: string
tab: number
fps: number
scale: number
}
const main = async () => {
const configEnv = process.env.MB_RECORDER_CONFIG
if (!configEnv) {
process.exit(1)
}
const config: DaemonConfig = JSON.parse(configEnv)
const { page, close } = await connect(config.tab)
// Bring tab to front to ensure Chrome sends screencast frames
await page.bringToFront()
// Warm up CDP screencast to trigger initial frame
// (page.screencast waits for first frame which won't come without activity)
const client = await page.createCDPSession()
await client.send("Page.startScreencast", { format: "png" })
await client.send("Page.stopScreencast")
await client.detach()
const recorder = await page.screencast({
path: config.path as `${string}.webm` | `${string}.mp4` | `${string}.gif`,
fps: config.fps,
scale: config.scale,
})
// Update state to indicate recording has started
const state = readState()
if (state) {
writeState({ ...state, startedAt: Date.now() })
}
let stopping = false
const stop = async () => {
if (stopping) return
stopping = true
try {
await recorder.stop()
await close()
clearState()
} catch {
// Ignore errors during cleanup
}
process.exit(0)
}
process.on("SIGTERM", stop)
process.on("SIGINT", stop)
// Keep process alive
setInterval(() => {}, 60000)
}
main().catch(() => {
clearState()
process.exit(1)
})