/** * Pure (resource, operation) -> browser-cli command mapping. * * This module imports nothing from n8n so it can be unit-tested with a plain * esbuild/node toolchain. The node layer collects UI parameters into a plain * object and asks here for the command + args to run over the `serve` socket * (see `serveClient.ts`). Every operation maps to one raw extension command; * what the server returns is the *raw* command result (no SDK-side rendering), * still subject to the server's --allow-* policy noted per operation. * * Command names and argument shapes mirror the Python SDK (browser_cli/sdk/*) * and the server-side policy in browser_cli/command_security.py. Gating per * operation is documented in BrowserCli.node.ts next to each operation. */ export type CommandParams = Record; export interface BrowserCommand { /** Raw browser-cli command name, e.g. "tabs.list" or "navigate.open". */ command: string; /** Argument object forwarded to the command. */ args: Record; } function str(params: CommandParams, key: string): string { const value = params[key]; return value === undefined || value === null ? '' : String(value); } /** Drop keys whose value is undefined/null/"" so we don't send empty args. */ function compact(args: Record): Record { const out: Record = {}; for (const [key, value] of Object.entries(args)) { if (value !== undefined && value !== null && value !== '') out[key] = value; } return out; } /** * Map a (resource, operation) pair plus collected parameters to a single raw * browser-cli command. Throws on an unknown pairing so the node fails loudly * rather than silently issuing a wrong call. */ export function buildCommand( resource: string, operation: string, params: CommandParams, ): BrowserCommand { const key = `${resource}:${operation}`; switch (key) { // --- Raw escape hatch ------------------------------------------------- case 'command:execute': return { command: str(params, 'command'), args: (params.args as Record) ?? {} }; // --- Tabs ------------------------------------------------------------- case 'tab:list': return { command: 'tabs.list', args: {} }; case 'tab:query': return { command: 'tabs.query', args: { search: str(params, 'search') } }; case 'tab:get': return { command: 'tabs.status', args: compact({ tabId: tabIdArg(params.tabId) }) }; case 'tab:count': return { command: 'tabs.count', args: compact({ pattern: str(params, 'pattern') }) }; case 'tab:filter': return { command: 'tabs.filter', args: { pattern: str(params, 'pattern') } }; case 'tab:activeInWindow': return { command: 'tabs.active_in_window', args: { windowId: numArg(params.windowId) } }; case 'tab:open': { const focus = Boolean(params.focus); return { command: 'navigate.open', args: compact({ url: str(params, 'url'), focus, background: !focus }) }; } case 'tab:close': { const mode = str(params, 'mode') || 'ids'; if (mode === 'inactive') return { command: 'tabs.close', args: { inactive: true } }; if (mode === 'duplicates') return { command: 'tabs.close', args: { duplicates: true } }; return { command: 'tabs.close', args: { tabIds: parseTabIds(params.tabIds) } }; } case 'tab:getHtml': return { command: 'tabs.html', args: compact({ tabId: tabIdArg(params.tabId) }) }; case 'tab:activate': return { command: 'tabs.active', args: { tabId: numArg(params.tabId) } }; case 'tab:move': return { command: 'tabs.move', args: compact({ tabId: numArg(params.tabId), windowId: tabIdArg(params.windowId), index: indexArg(params.index), }), }; case 'tab:reload': return { command: 'navigate.reload', args: compact({ tabId: tabIdArg(params.tabId) }) }; case 'tab:hardReload': return { command: 'navigate.hard_reload', args: compact({ tabId: tabIdArg(params.tabId) }) }; case 'tab:back': return { command: 'navigate.back', args: compact({ tabId: tabIdArg(params.tabId) }) }; case 'tab:forward': return { command: 'navigate.forward', args: compact({ tabId: tabIdArg(params.tabId) }) }; case 'tab:navigateTo': return { command: 'navigate.to', args: { tabId: numArg(params.tabId), url: str(params, 'url') } }; case 'tab:mute': return { command: 'tabs.mute', args: compact({ tabId: tabIdArg(params.tabId) }) }; case 'tab:unmute': return { command: 'tabs.unmute', args: compact({ tabId: tabIdArg(params.tabId) }) }; case 'tab:pin': return { command: 'tabs.pin', args: compact({ tabId: tabIdArg(params.tabId) }) }; case 'tab:unpin': return { command: 'tabs.unpin', args: compact({ tabId: tabIdArg(params.tabId) }) }; case 'tab:dedupe': return { command: 'tabs.dedupe', args: { gentleMode: str(params, 'gentleMode') || 'auto' } }; case 'tab:sort': return { command: 'tabs.sort', args: { by: str(params, 'by') || 'domain', gentleMode: str(params, 'gentleMode') || 'auto' } }; case 'tab:mergeWindows': return { command: 'tabs.merge_windows', args: { gentleMode: str(params, 'gentleMode') || 'auto' } }; case 'tab:screenshot': return { command: 'tabs.screenshot', args: compact({ tabId: tabIdArg(params.tabId), format: str(params, 'format') || 'png', quality: indexArg(params.quality), }), }; // --- Page / extraction ------------------------------------------------ case 'page:info': return { command: 'page.info', args: {} }; case 'page:extractText': return { command: 'extract.text', args: compact({ selector: str(params, 'selector') }) }; case 'page:extractLinks': return { command: 'extract.links', args: compact({ selector: str(params, 'selector') }) }; case 'page:extractImages': return { command: 'extract.images', args: compact({ selector: str(params, 'selector') }) }; case 'page:extractHtml': return { command: 'extract.html', args: compact({ selector: str(params, 'selector') }) }; case 'page:extractMarkdown': return { command: 'extract.markdown', args: compact({ selector: str(params, 'selector') }) }; case 'page:extractJson': return { command: 'extract.json', args: { selector: str(params, 'selector') } }; // --- DOM -------------------------------------------------------------- case 'dom:query': return { command: 'dom.query', args: { selector: str(params, 'selector') } }; case 'dom:click': return { command: 'dom.click', args: { selector: str(params, 'selector') } }; case 'dom:type': return { command: 'dom.type', args: { selector: str(params, 'selector'), text: str(params, 'text') } }; case 'dom:attr': return { command: 'dom.attr', args: { selector: str(params, 'selector'), attr: str(params, 'attr') } }; case 'dom:text': return { command: 'dom.text', args: { selector: str(params, 'selector') } }; case 'dom:exists': return { command: 'dom.exists', args: { selector: str(params, 'selector') } }; case 'dom:scroll': return { command: 'dom.scroll', args: compact({ selector: str(params, 'selector'), x: indexArg(params.x), y: indexArg(params.y) }), }; case 'dom:select': return { command: 'dom.select', args: { selector: str(params, 'selector'), value: str(params, 'value') } }; case 'dom:hover': return { command: 'dom.hover', args: { selector: str(params, 'selector') } }; case 'dom:check': return { command: 'dom.check', args: { selector: str(params, 'selector') } }; case 'dom:uncheck': return { command: 'dom.uncheck', args: { selector: str(params, 'selector') } }; case 'dom:clear': return { command: 'dom.clear', args: { selector: str(params, 'selector') } }; case 'dom:focus': return { command: 'dom.focus', args: { selector: str(params, 'selector') } }; case 'dom:submit': return { command: 'dom.submit', args: { selector: str(params, 'selector') } }; case 'dom:key': return { command: 'dom.key', args: compact({ key: str(params, 'key'), selector: str(params, 'selector') }) }; case 'dom:eval': return { command: 'dom.eval', args: compact({ code: str(params, 'code'), tabId: tabIdArg(params.tabId) }) }; // --- Groups ----------------------------------------------------------- case 'group:list': return { command: 'group.list', args: {} }; case 'group:query': return { command: 'group.query', args: { search: str(params, 'search') } }; case 'group:tabs': return { command: 'group.tabs', args: { groupId: numArg(params.groupId) } }; case 'group:count': return { command: 'group.count', args: {} }; case 'group:create': return { command: 'group.open', args: { name: str(params, 'name') } }; case 'group:addTab': return { command: 'group.add_tab', args: compact({ group: str(params, 'group'), url: str(params, 'url') }) }; case 'group:move': { const direction = str(params, 'direction'); return { command: 'group.move', args: { group: str(params, 'group'), forward: direction === 'forward', backward: direction === 'backward' }, }; } case 'group:close': return { command: 'group.close', args: { groupId: numArg(params.groupId), gentleMode: str(params, 'gentleMode') || 'auto' } }; // --- Windows ---------------------------------------------------------- case 'window:list': return { command: 'windows.list', args: {} }; case 'window:open': return { command: 'windows.open', args: compact({ url: str(params, 'url') }) }; case 'window:close': return { command: 'windows.close', args: { windowId: numArg(params.windowId) } }; case 'window:rename': return { command: 'windows.rename', args: { windowId: numArg(params.windowId), name: str(params, 'name') } }; // --- Sessions --------------------------------------------------------- case 'session:list': return { command: 'session.list', args: {} }; case 'session:save': return { command: 'session.save', args: { name: str(params, 'name') } }; case 'session:load': return { command: 'session.load', args: { name: str(params, 'name') } }; case 'session:remove': return { command: 'session.remove', args: { name: str(params, 'name') } }; case 'session:export': return { command: 'session.export', args: compact({ name: str(params, 'name') }) }; case 'session:diff': return { command: 'session.diff', args: { nameA: str(params, 'nameA'), nameB: str(params, 'nameB') } }; case 'session:autoSave': return { command: 'session.auto_save', args: { enabled: Boolean(params.enabled) } }; // --- Storage ---------------------------------------------------------- case 'storage:get': return { command: 'storage.get', args: compact({ key: str(params, 'key'), type: str(params, 'storeType') || 'local', tabId: tabIdArg(params.tabId) }), }; case 'storage:set': return { command: 'storage.set', args: compact({ key: str(params, 'key'), value: str(params, 'value'), type: str(params, 'storeType') || 'local', tabId: tabIdArg(params.tabId), }), }; // --- Performance ------------------------------------------------------ case 'perf:status': return { command: 'perf.status', args: {} }; // --- Extension -------------------------------------------------------- case 'extension:info': return { command: 'extension.info', args: {} }; case 'extension:capabilities': return { command: 'extension.capabilities', args: {} }; case 'extension:reload': return { command: 'extension.reload', args: {} }; // --- Clients ---------------------------------------------------------- case 'client:list': return { command: 'clients.list', args: {} }; default: throw new Error(`Unsupported operation "${operation}" for resource "${resource}"`); } } /** A tab ID arg; 0 (the UI default) means "active tab", so it is omitted. */ function tabIdArg(value: unknown): number | undefined { if (value === undefined || value === null || value === '') return undefined; const n = Number(value); return Number.isFinite(n) && n > 0 ? n : undefined; } /** A required numeric arg; non-finite values fall through as 0. */ function numArg(value: unknown): number { const n = Number(value); return Number.isFinite(n) ? n : 0; } /** An optional numeric arg that keeps 0 (a meaningful index/coordinate). */ function indexArg(value: unknown): number | undefined { if (value === undefined || value === null || value === '') return undefined; const n = Number(value); return Number.isFinite(n) ? n : undefined; } /** Accept an array, a JSON array string, or a comma/space separated list. */ export function parseTabIds(value: unknown): number[] { if (Array.isArray(value)) return value.map(Number).filter(Number.isFinite); if (typeof value === 'number') return [value]; const raw = String(value ?? '').trim(); if (!raw) return []; if (raw.startsWith('[')) { try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed.map(Number).filter(Number.isFinite); } catch { /* fall through to split */ } } return raw .split(/[\s,]+/) .map((part) => Number(part)) .filter(Number.isFinite); }