/** * `dench browser` — CLI control for Dench Browser. * * The CLI does not launch Chrome locally and never receives provider * credentials. It sends bearer-authenticated JSON commands to Dench Cloud. */ import { createHash } from "node:crypto"; import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { hasFlag } from "./lib/cli-args"; type RuntimeBundle = { host: string; bearerToken: string; gatewayBaseUrl?: string; gatewayBearerToken?: string; gatewayHeaders?: Record; }; type BrowserCliContext = { runtime: RuntimeBundle; args: string[]; jsonOutput: boolean; }; type BrowserState = { sessions?: Record< string, { threadId: string; updatedAt: number; } >; }; type BrowserResponse = Record & { action?: string; currentUrl?: string | null; error?: string; hint?: string; ok?: boolean; pageTitle?: string | null; screenshot?: { data?: string; mimeType?: string }; text?: string | null; threadId?: string; threadUrl?: string; }; type DenchBrowserSessionResponse = { provider?: string; sessionId?: string; connectUrl?: string; liveViewUrl?: string; status?: string; contextId?: string; }; class BrowserCliError extends Error {} const ACTIONS = new Set([ "open", "state", "inspect", "navigate", "click", "clickAt", "fill", "type", "key", "scroll", "wait", "evaluate", "tabs", "newTab", "activateTab", "closeTab", "screenshot", "stop", ]); function browserHelp(): void { console.log(browserHelpText()); } export function browserHelpText(): string { return `Usage: dench browser [options] Control Dench Browser without opening dench.com locally. Commands authenticate with the active dench signin session or DENCH_API_KEY. Common: dench browser open [url] [--json] dench browser inspect [--json] dench browser navigate dench browser click --selector dench browser click --text "Button text" dench browser click-at dench browser fill --label "Email" --value "me@example.com" dench browser type "text for focused field" dench browser key Enter dench browser scroll [up|down] [--pixels N] dench browser wait --text "Done" [--timeout-ms N] dench browser evaluate "return document.title" dench browser screenshot [--output page.jpg] [--full-page] dench browser tabs dench browser new-tab dench browser activate-tab dench browser close-tab dench browser stop dench browser reset Playwright sessions: dench browser session create --json dench browser session status --json dench browser session stop --json State: The CLI remembers the latest browser thread per Dench host/session. Use --thread to control a specific thread, or \`dench browser reset\` to forget the local remembered thread. `; } function consumeFlagValue(args: string[], name: string): string | undefined { const idx = args.indexOf(name); if (idx === -1) return undefined; const value = args[idx + 1]; args.splice(idx, 2); return value; } function consumeFlag(args: string[], name: string): boolean { const idx = args.indexOf(name); if (idx === -1) return false; args.splice(idx, 1); return true; } function parseNumberFlag(args: string[], name: string): number | undefined { const raw = consumeFlagValue(args, name); if (raw === undefined) return undefined; const value = Number(raw); if (!Number.isFinite(value)) { throw new BrowserCliError(`Invalid ${name} value: ${raw}`); } return value; } function statePath(): string { return ( process.env.DENCH_BROWSER_STATE_PATH?.trim() || join(homedir(), ".dench", "browser-state.json") ); } function stateKey(runtime: RuntimeBundle): string { const tokenHash = createHash("sha256") .update(runtime.bearerToken) .digest("hex") .slice(0, 24); return `${runtime.host.replace(/\/+$/, "")}#${tokenHash}`; } async function readState(): Promise { try { const raw = await readFile(statePath(), "utf8"); const parsed = JSON.parse(raw) as unknown; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return {}; } return parsed as BrowserState; } catch { return {}; } } async function writeState(state: BrowserState): Promise { const target = statePath(); await mkdir(dirname(target), { recursive: true }); await writeFile(target, JSON.stringify(state, null, 2), "utf8"); } async function readRememberedThreadId( runtime: RuntimeBundle, ): Promise { const state = await readState(); const session = state.sessions?.[stateKey(runtime)]; return session?.threadId; } async function rememberThreadId( runtime: RuntimeBundle, threadId: string, ): Promise { const state = await readState(); await writeState({ ...state, sessions: { ...(state.sessions ?? {}), [stateKey(runtime)]: { threadId, updatedAt: Date.now() }, }, }); } async function forgetThreadId(runtime: RuntimeBundle): Promise { const state = await readState(); const sessions = { ...(state.sessions ?? {}) }; const key = stateKey(runtime); const existed = Boolean(sessions[key]); delete sessions[key]; if (Object.keys(sessions).length === 0) { try { await unlink(statePath()); } catch { // Ignore a concurrent/manual delete. } return existed; } await writeState({ ...state, sessions }); return existed; } function normalizeBrowserUrl(input: string | undefined): string | undefined { const value = input?.trim(); if (!value) return undefined; if (/^https?:\/\//i.test(value)) return value; if (!/\s/.test(value) && value.includes(".")) return `https://${value}`; return `https://www.google.com/search?q=${encodeURIComponent(value)}`; } function buildCliBrowserUrl(host: string): string { return `${host.replace(/\/+$/, "")}/api/browser/cli`; } function normalizeGatewayBaseUrl(value: string | undefined): string { const explicit = value?.trim(); if (explicit) return explicit.replace(/\/+$/, "").replace(/\/v1$/, ""); if ("DENCH_GATEWAY_URL" in process.env || "GATEWAY_URL" in process.env) { const env = process.env.DENCH_GATEWAY_URL?.trim() || process.env.GATEWAY_URL?.trim(); if (env) return env.replace(/\/+$/, "").replace(/\/v1$/, ""); throw new BrowserCliError( "DENCH_GATEWAY_URL is set but empty — configure a gateway URL or unset the variable", ); } if (/localhost|127\.0\.0\.1/.test(process.env.DENCH_HOST ?? "")) { return "http://localhost:8787"; } return "https://gateway.merseoriginals.com"; } function buildGatewayBrowserUrl( runtime: RuntimeBundle, path = "/v1/browser/sessions", ): string { return `${normalizeGatewayBaseUrl(runtime.gatewayBaseUrl)}${path}`; } function buildRegisterSessionUrl(runtime: RuntimeBundle): string { const convex = ( process.env.CONVEX_URL || process.env.NEXT_PUBLIC_CONVEX_URL || "" ).trim(); if (convex) { const siteBase = convex .replace(/\/+$/, "") .replace(/\.convex\.cloud$/i, ".convex.site"); return `${siteBase}/api/browser/register-session`; } const apiBase = process.env.DENCH_API_URL?.trim() || process.env.DENCH_HOST?.trim() || runtime.host; return `${apiBase.replace(/\/+$/, "")}/api/browser/register-session`; } async function postJson( url: string, bearer: string, body: unknown, extraHeaders: Record = {}, ): Promise<{ ok: boolean; payload: unknown; status: number }> { const response = await fetch(url, { method: "POST", headers: { accept: "application/json", authorization: `Bearer ${bearer}`, "content-type": "application/json", ...extraHeaders, }, body: JSON.stringify(body), }); const text = await response.text(); let payload: unknown; try { payload = text ? JSON.parse(text) : null; } catch { payload = { raw: text }; } return { ok: response.ok, payload, status: response.status }; } async function getJson( url: string, bearer: string, extraHeaders: Record = {}, ): Promise<{ ok: boolean; payload: unknown; status: number }> { const response = await fetch(url, { method: "GET", headers: { accept: "application/json", authorization: `Bearer ${bearer}`, ...extraHeaders, }, }); const text = await response.text(); let payload: unknown; try { payload = text ? JSON.parse(text) : null; } catch { payload = { raw: text }; } return { ok: response.ok, payload, status: response.status }; } async function deleteJson( url: string, bearer: string, extraHeaders: Record = {}, ): Promise<{ ok: boolean; payload: unknown; status: number }> { const response = await fetch(url, { method: "DELETE", headers: { accept: "application/json", authorization: `Bearer ${bearer}`, ...extraHeaders, }, }); const text = await response.text(); let payload: unknown; try { payload = text ? JSON.parse(text) : null; } catch { payload = { raw: text }; } return { ok: response.ok, payload, status: response.status }; } function payloadError(payload: unknown): string { if (payload && typeof payload === "object" && !Array.isArray(payload)) { const error = (payload as { error?: unknown }).error; if (typeof error === "string" && error.trim()) return error; if (error && typeof error === "object" && !Array.isArray(error)) { const message = (error as { message?: unknown }).message; if (typeof message === "string" && message.trim()) return message; } } return "Cloud browser request failed"; } function gatewayBearerToken(runtime: RuntimeBundle): string { return runtime.gatewayBearerToken?.trim() || runtime.bearerToken; } function gatewayBrowserHeaders(runtime: RuntimeBundle): Record { const runId = process.env.DENCH_RUN_ID?.trim(); return { ...(runtime.gatewayHeaders ?? {}), ...(runId ? { "x-dench-run-id": runId } : {}), }; } async function registerScriptSession(args: { runtime: RuntimeBundle; session: DenchBrowserSessionResponse; status: string; }): Promise { const runId = process.env.DENCH_RUN_ID?.trim(); const sessionId = args.session.sessionId?.trim(); if (!runId || !sessionId) return; try { await postJson( buildRegisterSessionUrl(args.runtime), args.runtime.bearerToken, { runId, sessionId, liveViewUrl: args.session.liveViewUrl, status: args.status, }, ); } catch { // Best-effort: browser automation must not fail because UI registration // could not link the session to the run. } } function parseSessionCreateBody(args: string[]): Record { // The per-user browser profile is resolved server-side by the gateway // from the authenticated Dench API key plus DENCH_RUN_ID when present // (Convex `browserIdentities`). // `--context-id` stays only as an explicit override for manual testing; // there is no env-var fallback. const contextId = consumeFlagValue(args, "--context-id") || consumeFlagValue(args, "--contextId") || ""; const width = parseNumberFlag(args, "--width"); const height = parseNumberFlag(args, "--height"); return { ...(contextId ? { contextId } : {}), ...(width || height ? { viewport: { ...(width ? { width } : {}), ...(height ? { height } : {}), }, } : {}), }; } async function runSessionCommand(ctx: BrowserCliContext): Promise { ctx.args.shift(); // session const subcommand = ctx.args.shift() ?? "create"; const bearerToken = gatewayBearerToken(ctx.runtime); const gatewayHeaders = gatewayBrowserHeaders(ctx.runtime); if (subcommand === "create") { const body = parseSessionCreateBody(ctx.args); const result = await postJson( buildGatewayBrowserUrl(ctx.runtime), bearerToken, body, gatewayHeaders, ); if (!result.ok) { throw new BrowserCliError( `${payloadError(result.payload)} (${result.status})`, ); } const session = result.payload && typeof result.payload === "object" ? (result.payload as DenchBrowserSessionResponse) : {}; await registerScriptSession({ runtime: ctx.runtime, session, status: session.status ?? "live", }); output( ctx, ctx.jsonOutput ? session : `Browser session ${session.sessionId}`, ); return; } if (subcommand === "status") { const sessionId = ctx.args[0]?.trim(); if (!sessionId) { throw new BrowserCliError( "dench browser session status requires a session id", ); } const result = await getJson( buildGatewayBrowserUrl( ctx.runtime, `/v1/browser/sessions/${encodeURIComponent(sessionId)}`, ), bearerToken, gatewayHeaders, ); if (!result.ok) { throw new BrowserCliError( `${payloadError(result.payload)} (${result.status})`, ); } output( ctx, ctx.jsonOutput ? result.payload : JSON.stringify(result.payload, null, 2), ); return; } if (subcommand === "stop") { const sessionId = ctx.args[0]?.trim(); if (!sessionId) { throw new BrowserCliError( "dench browser session stop requires a session id", ); } const result = await deleteJson( buildGatewayBrowserUrl( ctx.runtime, `/v1/browser/sessions/${encodeURIComponent(sessionId)}`, ), bearerToken, gatewayHeaders, ); if (!result.ok) { throw new BrowserCliError( `${payloadError(result.payload)} (${result.status})`, ); } await registerScriptSession({ runtime: ctx.runtime, session: { sessionId }, status: "ended", }); output( ctx, ctx.jsonOutput ? result.payload : `Stopped browser session ${sessionId}`, ); return; } throw new BrowserCliError(`Unknown browser session command: ${subcommand}`); } async function maybeWriteScreenshot( response: BrowserResponse, outputPath: string | undefined, ): Promise { const data = response.screenshot?.data; if (!outputPath || typeof data !== "string") return undefined; await writeFile(outputPath, Buffer.from(data, "base64")); return outputPath; } function formatInspectElements(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.slice(0, 10).flatMap((item, index) => { if (!item || typeof item !== "object") return []; const record = item as { selector?: unknown; tagName?: unknown; text?: unknown; }; const label = typeof record.text === "string" && record.text.trim() ? ` ${record.text.trim()}` : ""; const selector = typeof record.selector === "string" ? ` ${record.selector}` : ""; const tag = typeof record.tagName === "string" ? record.tagName : "el"; return [`${index + 1}. ${tag}${label}${selector}`]; }); } function formatHuman(response: BrowserResponse, screenshotPath?: string) { const lines: string[] = []; const action = response.action ?? "browser"; if (response.ok === false) { lines.push( `Browser ${action} failed: ${response.error ?? "unknown error"}`, ); } else { lines.push(`Browser ${action} ok.`); } if (response.threadId) lines.push(`threadId: ${response.threadId}`); if (response.currentUrl) lines.push(`url: ${response.currentUrl}`); if (response.pageTitle) lines.push(`title: ${response.pageTitle}`); if (screenshotPath) lines.push(`screenshot: ${screenshotPath}`); if (response.screenshot?.data && !screenshotPath) { lines.push("screenshot captured; pass --output to save it."); } if (typeof response.text === "string" && response.text.trim()) { const text = response.text.trim(); lines.push(""); lines.push(text.length > 2000 ? `${text.slice(0, 2000)}...` : text); } const elements = formatInspectElements(response.elements); if (elements.length > 0) { lines.push(""); lines.push("Elements:"); lines.push(...elements); } if (response.hint) { lines.push(""); lines.push(String(response.hint)); } if (response.threadUrl) { lines.push(""); lines.push(`Open in Dench: ${response.threadUrl}`); } return lines.join("\n"); } function output(ctx: BrowserCliContext, value: unknown): void { if (ctx.jsonOutput) { console.log(JSON.stringify(value, null, 2)); return; } console.log(String(value)); } function parseActionAndBody(ctx: BrowserCliContext): { body: Record; outputPath?: string; } { const actionArg = ctx.args.shift(); const action = actionArg === "new-tab" ? "newTab" : actionArg === "activate-tab" ? "activateTab" : actionArg === "close-tab" ? "closeTab" : actionArg === "click-at" ? "clickAt" : (actionArg ?? "open"); if (!ACTIONS.has(action)) { throw new BrowserCliError(`Unknown browser action: ${actionArg}`); } const threadId = consumeFlagValue(ctx.args, "--thread"); const selector = consumeFlagValue(ctx.args, "--selector"); const text = consumeFlagValue(ctx.args, "--text"); const label = consumeFlagValue(ctx.args, "--label"); const value = consumeFlagValue(ctx.args, "--value"); const key = consumeFlagValue(ctx.args, "--key"); const targetId = consumeFlagValue(ctx.args, "--target-id") || consumeFlagValue(ctx.args, "--targetId"); const pixels = parseNumberFlag(ctx.args, "--pixels"); const timeoutMs = parseNumberFlag(ctx.args, "--timeout-ms") ?? parseNumberFlag(ctx.args, "--timeoutMs"); const x = parseNumberFlag(ctx.args, "--x"); const y = parseNumberFlag(ctx.args, "--y"); const outputPath = consumeFlagValue(ctx.args, "--output"); const fullPage = consumeFlag(ctx.args, "--full-page"); if (action === "open" || action === "navigate" || action === "newTab") { const url = normalizeBrowserUrl( consumeFlagValue(ctx.args, "--url") ?? ctx.args.join(" "), ); ctx.args.length = 0; return { body: { action, threadId, url }, outputPath, }; } if (action === "type") { return { body: { action, threadId, value: value ?? ctx.args.join(" ") }, outputPath, }; } if (action === "key") { return { body: { action, threadId, key: key ?? ctx.args.join(" ") }, outputPath, }; } if (action === "scroll") { const direction = ctx.args[0] === "up" ? "up" : "down"; return { body: { action, direction, pixels, threadId }, outputPath, }; } if (action === "evaluate") { const script = consumeFlagValue(ctx.args, "--script") ?? ctx.args.join(" "); return { body: { action, script, threadId }, outputPath, }; } if (action === "clickAt") { const positionalX = ctx.args[0] !== undefined ? Number(ctx.args[0]) : x; const positionalY = ctx.args[1] !== undefined ? Number(ctx.args[1]) : y; return { body: { action, threadId, x: positionalX, y: positionalY }, outputPath, }; } if (action === "activateTab" || action === "closeTab") { return { body: { action, targetId: targetId ?? ctx.args[0], threadId }, outputPath, }; } return { body: { action, fullPage, label, pixels, selector, targetId, text, threadId, timeoutMs, value, }, outputPath, }; } export async function runBrowserCommand(opts: { args: string[]; runtime: RuntimeBundle; }): Promise { const args = [...opts.args]; const jsonOutput = hasFlag(args, "--json"); const ctx: BrowserCliContext = { args, jsonOutput, runtime: opts.runtime, }; const first = args[0]; if (!first || first === "help" || first === "--help" || first === "-h") { browserHelp(); return; } if (first === "reset") { await forgetThreadId(opts.runtime); output( ctx, jsonOutput ? { ok: true, reset: true } : "Forgot CLI browser thread.", ); return; } if (first === "session") { await runSessionCommand(ctx); return; } const { body, outputPath } = parseActionAndBody(ctx); if (!body.threadId) { body.threadId = await readRememberedThreadId(opts.runtime); } if (body.action === "stop" && !body.threadId) { output( ctx, ctx.jsonOutput ? { ok: true, action: "stop", stopped: false, reason: "no_thread" } : "No remembered CLI browser thread. Pass --thread to stop a specific one.", ); return; } const result = await postJson( buildCliBrowserUrl(opts.runtime.host), opts.runtime.bearerToken, body, ); if (!result.ok) { throw new BrowserCliError( `${payloadError(result.payload)} (${result.status})`, ); } const response = result.payload && typeof result.payload === "object" ? (result.payload as BrowserResponse) : ({} as BrowserResponse); if (typeof response.threadId === "string" && response.threadId.trim()) { await rememberThreadId(opts.runtime, response.threadId.trim()); } const screenshotPath = await maybeWriteScreenshot(response, outputPath); if (ctx.jsonOutput) { output(ctx, screenshotPath ? { ...response, screenshotPath } : response); return; } const printable = screenshotPath && response.screenshot ? { ...response, screenshot: undefined } : response; output(ctx, formatHuman(printable, screenshotPath)); } export { BrowserCliError };