b91b29d516
- Add UI resources and mappings for groups, windows, sessions, storage, performance, extension, and more tab/DOM/page actions. - Remove the synthetic Gateway Health operation so exposed operations match real browser-cli commands. - Document the expanded command/policy matrix and cover the new request mappings with tests. - Cap the node SVG icon at 60x60, bump the n8n package to 0.3.0, and advertise client protocol version 0.16.0.
869 lines
35 KiB
TypeScript
869 lines
35 KiB
TypeScript
import type {
|
|
ICredentialsDecrypted,
|
|
ICredentialTestFunctions,
|
|
IExecuteFunctions,
|
|
IDataObject,
|
|
IDisplayOptions,
|
|
INodeCredentialTestResult,
|
|
INodeExecutionData,
|
|
INodeType,
|
|
INodeTypeDescription,
|
|
} from 'n8n-workflow';
|
|
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
|
|
|
import { buildCommand, type CommandParams } from './request';
|
|
import { sendServeCommand, type ServeConnectOptions } from './serveClient';
|
|
|
|
/** Only show a property for the given resource/operation combinations. */
|
|
function showFor(resource: string, operations: string[]): NonNullable<IDisplayOptions['show']> {
|
|
return { resource: [resource], operation: operations };
|
|
}
|
|
|
|
export class BrowserCli implements INodeType {
|
|
description: INodeTypeDescription = {
|
|
displayName: 'Browser CLI',
|
|
name: 'browserCli',
|
|
icon: 'file:browserCli.svg',
|
|
group: ['transform'],
|
|
version: 1,
|
|
subtitle: '={{({ tab: "Tab", page: "Page", dom: "DOM", group: "Group", window: "Window", session: "Session", storage: "Storage", perf: "Perf", extension: "Extension", client: "Client", command: "Command" }[$parameter["resource"]] || $parameter["resource"]) + ": " + $parameter["operation"]}}',
|
|
description: 'Control a remote browser by talking directly to a browser-cli serve endpoint',
|
|
defaults: { name: 'Browser CLI' },
|
|
inputs: [NodeConnectionTypes.Main],
|
|
outputs: [NodeConnectionTypes.Main],
|
|
usableAsTool: true,
|
|
credentials: [{ name: 'browserCliApi', required: true }],
|
|
properties: [
|
|
{
|
|
displayName: 'Resource',
|
|
name: 'resource',
|
|
type: 'options',
|
|
noDataExpression: true,
|
|
options: [
|
|
{ name: 'Tab', value: 'tab' },
|
|
{ name: 'Page', value: 'page' },
|
|
{ name: 'DOM', value: 'dom' },
|
|
{ name: 'Group', value: 'group' },
|
|
{ name: 'Window', value: 'window' },
|
|
{ name: 'Session', value: 'session' },
|
|
{ name: 'Storage', value: 'storage' },
|
|
{ name: 'Performance', value: 'perf' },
|
|
{ name: 'Extension', value: 'extension' },
|
|
{ name: 'Client', value: 'client' },
|
|
{ name: 'Command', value: 'command' },
|
|
],
|
|
default: 'tab',
|
|
},
|
|
|
|
// --- Tab operations ---------------------------------------------------
|
|
{
|
|
displayName: 'Operation',
|
|
name: 'operation',
|
|
type: 'options',
|
|
noDataExpression: true,
|
|
displayOptions: { show: { resource: ['tab'] } },
|
|
options: [
|
|
{ name: 'List', value: 'list', action: 'List open tabs', description: 'tabs.list (safe)' },
|
|
{ name: 'Query', value: 'query', action: 'Search tabs by text', description: 'tabs.query (safe)' },
|
|
{ name: 'Get', value: 'get', action: 'Get a tab status', description: 'tabs.status (safe)' },
|
|
{ name: 'Count', value: 'count', action: 'Count open tabs', description: 'tabs.count (safe)' },
|
|
{ name: 'Filter', value: 'filter', action: 'Filter tabs by URL pattern', description: 'tabs.filter (safe)' },
|
|
{ name: 'Active in Window', value: 'activeInWindow', action: 'Get active tab in a window', description: 'tabs.active_in_window (safe)' },
|
|
{ name: 'Open', value: 'open', action: 'Open a URL in a new tab', description: 'navigate.open (needs --allow-control)' },
|
|
{ name: 'Close', value: 'close', action: 'Close tabs', description: 'tabs.close (needs --allow-control)' },
|
|
{ name: 'Get HTML', value: 'getHtml', action: 'Get a tab raw HTML', description: 'tabs.html (needs --allow-read-page)' },
|
|
{ name: 'Activate', value: 'activate', action: 'Switch focus to a tab', description: 'tabs.active (needs --allow-control)' },
|
|
{ name: 'Move', value: 'move', action: 'Move a tab', description: 'tabs.move (needs --allow-control)' },
|
|
{ name: 'Navigate To', value: 'navigateTo', action: 'Navigate a tab to a URL', description: 'navigate.to (needs --allow-control)' },
|
|
{ name: 'Reload', value: 'reload', action: 'Reload a tab', description: 'navigate.reload (needs --allow-control)' },
|
|
{ name: 'Hard Reload', value: 'hardReload', action: 'Hard reload a tab', description: 'navigate.hard_reload (needs --allow-control)' },
|
|
{ name: 'Back', value: 'back', action: 'Go back in history', description: 'navigate.back (needs --allow-control)' },
|
|
{ name: 'Forward', value: 'forward', action: 'Go forward in history', description: 'navigate.forward (needs --allow-control)' },
|
|
{ name: 'Mute', value: 'mute', action: 'Mute a tab', description: 'tabs.mute (needs --allow-control)' },
|
|
{ name: 'Unmute', value: 'unmute', action: 'Unmute a tab', description: 'tabs.unmute (needs --allow-control)' },
|
|
{ name: 'Pin', value: 'pin', action: 'Pin a tab', description: 'tabs.pin (needs --allow-control)' },
|
|
{ name: 'Unpin', value: 'unpin', action: 'Unpin a tab', description: 'tabs.unpin (needs --allow-control)' },
|
|
{ name: 'Dedupe', value: 'dedupe', action: 'Close duplicate tabs', description: 'tabs.dedupe (needs --allow-control)' },
|
|
{ name: 'Sort', value: 'sort', action: 'Sort tabs within windows', description: 'tabs.sort (needs --allow-control)' },
|
|
{ name: 'Merge Windows', value: 'mergeWindows', action: 'Merge all tabs into one window', description: 'tabs.merge_windows (needs --allow-control)' },
|
|
{ name: 'Screenshot', value: 'screenshot', action: 'Capture a tab screenshot', description: 'tabs.screenshot (needs --allow-dangerous)' },
|
|
],
|
|
default: 'list',
|
|
},
|
|
|
|
// --- Page operations --------------------------------------------------
|
|
{
|
|
displayName: 'Operation',
|
|
name: 'operation',
|
|
type: 'options',
|
|
noDataExpression: true,
|
|
displayOptions: { show: { resource: ['page'] } },
|
|
options: [
|
|
{ name: 'Get Info', value: 'info', action: 'Get page info', description: 'page.info (safe)' },
|
|
{ name: 'Extract Text', value: 'extractText', action: 'Extract visible text', description: 'extract.text (needs --allow-read-page)' },
|
|
{ name: 'Extract Links', value: 'extractLinks', action: 'Extract links', description: 'extract.links (needs --allow-read-page)' },
|
|
{ name: 'Extract Images', value: 'extractImages', action: 'Extract images', description: 'extract.images (needs --allow-read-page)' },
|
|
{ name: 'Extract HTML', value: 'extractHtml', action: 'Extract HTML', description: 'extract.html (needs --allow-read-page)' },
|
|
{ name: 'Extract Markdown', value: 'extractMarkdown', action: 'Extract Markdown payload', description: 'extract.markdown — returns the raw page payload (not SDK-rendered) (needs --allow-read-page)' },
|
|
{ name: 'Extract JSON', value: 'extractJson', action: 'Extract JSON-LD / structured data', description: 'extract.json (needs --allow-read-page)' },
|
|
],
|
|
default: 'extractText',
|
|
},
|
|
|
|
// --- DOM operations ---------------------------------------------------
|
|
{
|
|
displayName: 'Operation',
|
|
name: 'operation',
|
|
type: 'options',
|
|
noDataExpression: true,
|
|
displayOptions: { show: { resource: ['dom'] } },
|
|
options: [
|
|
{ name: 'Query', value: 'query', action: 'Query elements by selector', description: 'dom.query (needs --allow-read-page)' },
|
|
{ name: 'Text', value: 'text', action: 'Get element text', description: 'dom.text (needs --allow-read-page)' },
|
|
{ name: 'Attribute', value: 'attr', action: 'Get an element attribute', description: 'dom.attr (needs --allow-read-page)' },
|
|
{ name: 'Exists', value: 'exists', action: 'Check if an element exists', description: 'dom.exists (needs --allow-read-page)' },
|
|
{ name: 'Click', value: 'click', action: 'Click an element', description: 'dom.click (needs --allow-control)' },
|
|
{ name: 'Type', value: 'type', action: 'Type into an element', description: 'dom.type (needs --allow-control)' },
|
|
{ name: 'Select', value: 'select', action: 'Select a dropdown option', description: 'dom.select (needs --allow-control)' },
|
|
{ name: 'Hover', value: 'hover', action: 'Hover over an element', description: 'dom.hover (needs --allow-control)' },
|
|
{ name: 'Focus', value: 'focus', action: 'Focus an element', description: 'dom.focus (needs --allow-control)' },
|
|
{ name: 'Check', value: 'check', action: 'Check a checkbox', description: 'dom.check (needs --allow-control)' },
|
|
{ name: 'Uncheck', value: 'uncheck', action: 'Uncheck a checkbox', description: 'dom.uncheck (needs --allow-control)' },
|
|
{ name: 'Clear', value: 'clear', action: 'Clear an input', description: 'dom.clear (needs --allow-control)' },
|
|
{ name: 'Submit', value: 'submit', action: 'Submit a form', description: 'dom.submit (needs --allow-control)' },
|
|
{ name: 'Scroll', value: 'scroll', action: 'Scroll to an element or position', description: 'dom.scroll (needs --allow-control)' },
|
|
{ name: 'Key', value: 'key', action: 'Send a keyboard key', description: 'dom.key (needs --allow-control)' },
|
|
{ name: 'Eval', value: 'eval', action: 'Evaluate JavaScript', description: 'dom.eval (needs --allow-dangerous)' },
|
|
],
|
|
default: 'query',
|
|
},
|
|
|
|
// --- Group operations -------------------------------------------------
|
|
{
|
|
displayName: 'Operation',
|
|
name: 'operation',
|
|
type: 'options',
|
|
noDataExpression: true,
|
|
displayOptions: { show: { resource: ['group'] } },
|
|
options: [
|
|
{ name: 'List', value: 'list', action: 'List tab groups', description: 'group.list (safe)' },
|
|
{ name: 'Query', value: 'query', action: 'Search groups by name', description: 'group.query (safe)' },
|
|
{ name: 'Tabs', value: 'tabs', action: 'List tabs in a group', description: 'group.tabs (safe)' },
|
|
{ name: 'Count', value: 'count', action: 'Count tab groups', description: 'group.count (needs --allow-control)' },
|
|
{ name: 'Create', value: 'create', action: 'Create a tab group', description: 'group.open (needs --allow-control)' },
|
|
{ name: 'Add Tab', value: 'addTab', action: 'Add a tab to a group', description: 'group.add_tab (needs --allow-control)' },
|
|
{ name: 'Move', value: 'move', action: 'Move a group forward/backward', description: 'group.move (needs --allow-control)' },
|
|
{ name: 'Close', value: 'close', action: 'Close a tab group', description: 'group.close (needs --allow-control)' },
|
|
],
|
|
default: 'list',
|
|
},
|
|
|
|
// --- Window operations ------------------------------------------------
|
|
{
|
|
displayName: 'Operation',
|
|
name: 'operation',
|
|
type: 'options',
|
|
noDataExpression: true,
|
|
displayOptions: { show: { resource: ['window'] } },
|
|
options: [
|
|
{ name: 'List', value: 'list', action: 'List browser windows', description: 'windows.list (safe)' },
|
|
{ name: 'Open', value: 'open', action: 'Open a new window', description: 'windows.open (needs --allow-control)' },
|
|
{ name: 'Close', value: 'close', action: 'Close a window', description: 'windows.close (needs --allow-control)' },
|
|
{ name: 'Rename', value: 'rename', action: 'Rename a window', description: 'windows.rename (needs --allow-control)' },
|
|
],
|
|
default: 'list',
|
|
},
|
|
|
|
// --- Session operations -----------------------------------------------
|
|
{
|
|
displayName: 'Operation',
|
|
name: 'operation',
|
|
type: 'options',
|
|
noDataExpression: true,
|
|
displayOptions: { show: { resource: ['session'] } },
|
|
options: [
|
|
{ name: 'List', value: 'list', action: 'List saved sessions', description: 'session.list (needs --allow-control)' },
|
|
{ name: 'Save', value: 'save', action: 'Save the current session', description: 'session.save (needs --allow-control)' },
|
|
{ name: 'Load', value: 'load', action: 'Load a saved session', description: 'session.load (needs --allow-control)' },
|
|
{ name: 'Remove', value: 'remove', action: 'Delete a saved session', description: 'session.remove (needs --allow-control)' },
|
|
{ name: 'Export', value: 'export', action: 'Export a session as JSON', description: 'session.export (needs --allow-control)' },
|
|
{ name: 'Diff', value: 'diff', action: 'Diff two sessions', description: 'session.diff (needs --allow-control)' },
|
|
{ name: 'Auto Save', value: 'autoSave', action: 'Toggle session auto-save', description: 'session.auto_save (needs --allow-control)' },
|
|
],
|
|
default: 'list',
|
|
},
|
|
|
|
// --- Storage operations -----------------------------------------------
|
|
{
|
|
displayName: 'Operation',
|
|
name: 'operation',
|
|
type: 'options',
|
|
noDataExpression: true,
|
|
displayOptions: { show: { resource: ['storage'] } },
|
|
options: [
|
|
{ name: 'Get', value: 'get', action: 'Read localStorage / sessionStorage', description: 'storage.get (needs --allow-dangerous)' },
|
|
{ name: 'Set', value: 'set', action: 'Write localStorage / sessionStorage', description: 'storage.set (needs --allow-dangerous)' },
|
|
],
|
|
default: 'get',
|
|
},
|
|
|
|
// --- Performance operations -------------------------------------------
|
|
{
|
|
displayName: 'Operation',
|
|
name: 'operation',
|
|
type: 'options',
|
|
noDataExpression: true,
|
|
displayOptions: { show: { resource: ['perf'] } },
|
|
options: [
|
|
{ name: 'Status', value: 'status', action: 'Get performance status', description: 'perf.status (safe)' },
|
|
],
|
|
default: 'status',
|
|
},
|
|
|
|
// --- Extension operations ---------------------------------------------
|
|
{
|
|
displayName: 'Operation',
|
|
name: 'operation',
|
|
type: 'options',
|
|
noDataExpression: true,
|
|
displayOptions: { show: { resource: ['extension'] } },
|
|
options: [
|
|
{ name: 'Info', value: 'info', action: 'Get extension info', description: 'extension.info (safe)' },
|
|
{ name: 'Capabilities', value: 'capabilities', action: 'List extension capabilities', description: 'extension.capabilities (safe)' },
|
|
{ name: 'Reload', value: 'reload', action: 'Reload the extension', description: 'extension.reload (needs --allow-control)' },
|
|
],
|
|
default: 'info',
|
|
},
|
|
|
|
// --- Client operations ------------------------------------------------
|
|
{
|
|
displayName: 'Operation',
|
|
name: 'operation',
|
|
type: 'options',
|
|
noDataExpression: true,
|
|
displayOptions: { show: { resource: ['client'] } },
|
|
options: [
|
|
{ name: 'List', value: 'list', action: 'List connected browser clients', description: 'clients.list (safe)' },
|
|
],
|
|
default: 'list',
|
|
},
|
|
|
|
// --- Command operations -----------------------------------------------
|
|
{
|
|
displayName: 'Operation',
|
|
name: 'operation',
|
|
type: 'options',
|
|
noDataExpression: true,
|
|
displayOptions: { show: { resource: ['command'] } },
|
|
options: [
|
|
{ name: 'Execute', value: 'execute', action: 'Execute a raw browser-cli command', description: 'Any command name (subject to server policy)' },
|
|
],
|
|
default: 'execute',
|
|
},
|
|
|
|
|
|
// --- Shared parameter fields -----------------------------------------
|
|
{
|
|
displayName: 'URL',
|
|
name: 'url',
|
|
type: 'string',
|
|
default: '',
|
|
required: true,
|
|
placeholder: 'https://example.com',
|
|
displayOptions: { show: showFor('tab', ['open', 'navigateTo']) },
|
|
},
|
|
{
|
|
displayName: 'URL',
|
|
name: 'url',
|
|
type: 'string',
|
|
default: '',
|
|
placeholder: 'https://example.com',
|
|
description: 'Optional URL to open. Leave empty for a blank window/tab.',
|
|
displayOptions: { show: { resource: ['window', 'group'], operation: ['open', 'addTab'] } },
|
|
},
|
|
{
|
|
displayName: 'Focus Tab',
|
|
name: 'focus',
|
|
type: 'boolean',
|
|
default: false,
|
|
description: 'Whether to focus the new tab/window (steals OS focus). Off opens in the background.',
|
|
displayOptions: { show: showFor('tab', ['open']) },
|
|
},
|
|
{
|
|
displayName: 'Close By',
|
|
name: 'mode',
|
|
type: 'options',
|
|
default: 'ids',
|
|
options: [
|
|
{ name: 'Tab IDs', value: 'ids' },
|
|
{ name: 'Inactive Tabs', value: 'inactive' },
|
|
{ name: 'Duplicate Tabs', value: 'duplicates' },
|
|
],
|
|
displayOptions: { show: showFor('tab', ['close']) },
|
|
},
|
|
{
|
|
displayName: 'Tab IDs',
|
|
name: 'tabIds',
|
|
type: 'string',
|
|
default: '',
|
|
placeholder: '123, 456',
|
|
description: 'Comma/space separated tab IDs, or a JSON array',
|
|
displayOptions: { show: { resource: ['tab'], operation: ['close'], mode: ['ids'] } },
|
|
},
|
|
{
|
|
displayName: 'Tab ID',
|
|
name: 'tabId',
|
|
type: 'number',
|
|
default: 0,
|
|
description: 'Target tab ID. Leave 0 for the active tab.',
|
|
displayOptions: {
|
|
show: {
|
|
resource: ['tab', 'dom'],
|
|
operation: ['getHtml', 'get', 'reload', 'hardReload', 'back', 'forward', 'mute', 'unmute', 'pin', 'unpin', 'screenshot', 'eval'],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
displayName: 'Tab ID',
|
|
name: 'tabId',
|
|
type: 'number',
|
|
default: 0,
|
|
required: true,
|
|
description: 'Target tab ID (required — no active-tab fallback)',
|
|
displayOptions: { show: showFor('tab', ['activate', 'move', 'navigateTo']) },
|
|
},
|
|
{
|
|
displayName: 'Tab ID',
|
|
name: 'tabId',
|
|
type: 'number',
|
|
default: 0,
|
|
description: 'Target tab ID. Leave 0 for the active tab.',
|
|
displayOptions: { show: showFor('storage', ['get', 'set']) },
|
|
},
|
|
{
|
|
displayName: 'Search',
|
|
name: 'search',
|
|
type: 'string',
|
|
default: '',
|
|
required: true,
|
|
placeholder: 'github',
|
|
description: 'Substring to match against tab/group titles and URLs',
|
|
displayOptions: { show: { resource: ['tab', 'group'], operation: ['query'] } },
|
|
},
|
|
{
|
|
displayName: 'URL Pattern',
|
|
name: 'pattern',
|
|
type: 'string',
|
|
default: '',
|
|
placeholder: '*.github.com/*',
|
|
description: 'URL glob pattern. Required for Filter; optional for Count (omit to count all).',
|
|
displayOptions: { show: showFor('tab', ['filter', 'count']) },
|
|
},
|
|
{
|
|
displayName: 'Window ID',
|
|
name: 'windowId',
|
|
type: 'number',
|
|
default: 0,
|
|
required: true,
|
|
displayOptions: { show: showFor('tab', ['activeInWindow']) },
|
|
},
|
|
{
|
|
displayName: 'Window ID',
|
|
name: 'windowId',
|
|
type: 'number',
|
|
default: 0,
|
|
required: true,
|
|
displayOptions: { show: showFor('window', ['close', 'rename']) },
|
|
},
|
|
{
|
|
displayName: 'Window ID',
|
|
name: 'windowId',
|
|
type: 'number',
|
|
default: 0,
|
|
description: 'Move the tab to this window. Leave 0 to keep it in the current window.',
|
|
displayOptions: { show: showFor('tab', ['move']) },
|
|
},
|
|
{
|
|
displayName: 'Index',
|
|
name: 'index',
|
|
type: 'number',
|
|
default: 0,
|
|
description: 'Target position within the window (0-based)',
|
|
displayOptions: { show: showFor('tab', ['move']) },
|
|
},
|
|
{
|
|
displayName: 'Format',
|
|
name: 'format',
|
|
type: 'options',
|
|
default: 'png',
|
|
options: [
|
|
{ name: 'PNG', value: 'png' },
|
|
{ name: 'JPEG', value: 'jpeg' },
|
|
],
|
|
displayOptions: { show: showFor('tab', ['screenshot']) },
|
|
},
|
|
{
|
|
displayName: 'Quality',
|
|
name: 'quality',
|
|
type: 'number',
|
|
default: 80,
|
|
description: 'JPEG quality 0-100 (ignored for PNG)',
|
|
displayOptions: { show: { resource: ['tab'], operation: ['screenshot'], format: ['jpeg'] } },
|
|
},
|
|
{
|
|
displayName: 'Gentle Mode',
|
|
name: 'gentleMode',
|
|
type: 'options',
|
|
default: 'auto',
|
|
description: 'How aggressively to rearrange tabs',
|
|
options: [
|
|
{ name: 'Auto', value: 'auto' },
|
|
{ name: 'On', value: 'on' },
|
|
{ name: 'Off', value: 'off' },
|
|
],
|
|
displayOptions: { show: showFor('tab', ['dedupe', 'sort', 'mergeWindows']) },
|
|
},
|
|
{
|
|
displayName: 'Gentle Mode',
|
|
name: 'gentleMode',
|
|
type: 'options',
|
|
default: 'auto',
|
|
options: [
|
|
{ name: 'Auto', value: 'auto' },
|
|
{ name: 'On', value: 'on' },
|
|
{ name: 'Off', value: 'off' },
|
|
],
|
|
displayOptions: { show: showFor('group', ['close']) },
|
|
},
|
|
{
|
|
displayName: 'Sort By',
|
|
name: 'by',
|
|
type: 'options',
|
|
default: 'domain',
|
|
options: [
|
|
{ name: 'Domain', value: 'domain' },
|
|
{ name: 'Title', value: 'title' },
|
|
{ name: 'Time', value: 'time' },
|
|
],
|
|
displayOptions: { show: showFor('tab', ['sort']) },
|
|
},
|
|
{
|
|
displayName: 'Selector',
|
|
name: 'selector',
|
|
type: 'string',
|
|
default: '',
|
|
placeholder: '#main, .content',
|
|
description: 'CSS selector. Leave empty on extract operations to use the whole page.',
|
|
displayOptions: {
|
|
show: {
|
|
resource: ['dom', 'page'],
|
|
operation: [
|
|
'query', 'text', 'attr', 'exists', 'click', 'type', 'select', 'hover', 'focus',
|
|
'check', 'uncheck', 'clear', 'submit', 'scroll', 'key',
|
|
'extractText', 'extractLinks', 'extractImages', 'extractHtml', 'extractMarkdown', 'extractJson',
|
|
],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
displayName: 'Text',
|
|
name: 'text',
|
|
type: 'string',
|
|
default: '',
|
|
required: true,
|
|
displayOptions: { show: showFor('dom', ['type']) },
|
|
},
|
|
{
|
|
displayName: 'Attribute',
|
|
name: 'attr',
|
|
type: 'string',
|
|
default: '',
|
|
required: true,
|
|
placeholder: 'href',
|
|
description: 'Attribute name to read from the matched element',
|
|
displayOptions: { show: showFor('dom', ['attr']) },
|
|
},
|
|
{
|
|
displayName: 'Value',
|
|
name: 'value',
|
|
type: 'string',
|
|
default: '',
|
|
required: true,
|
|
description: 'Option value to select in the dropdown',
|
|
displayOptions: { show: showFor('dom', ['select']) },
|
|
},
|
|
{
|
|
displayName: 'Key',
|
|
name: 'key',
|
|
type: 'string',
|
|
default: '',
|
|
required: true,
|
|
placeholder: 'Enter',
|
|
description: 'Keyboard key to send, e.g. Enter, Escape, ArrowDown',
|
|
displayOptions: { show: showFor('dom', ['key']) },
|
|
},
|
|
{
|
|
displayName: 'X',
|
|
name: 'x',
|
|
type: 'number',
|
|
default: 0,
|
|
description: 'Horizontal scroll position (used when no selector is given)',
|
|
displayOptions: { show: showFor('dom', ['scroll']) },
|
|
},
|
|
{
|
|
displayName: 'Y',
|
|
name: 'y',
|
|
type: 'number',
|
|
default: 0,
|
|
description: 'Vertical scroll position (used when no selector is given)',
|
|
displayOptions: { show: showFor('dom', ['scroll']) },
|
|
},
|
|
{
|
|
displayName: 'JavaScript',
|
|
name: 'code',
|
|
type: 'string',
|
|
typeOptions: { rows: 4 },
|
|
default: '',
|
|
required: true,
|
|
placeholder: 'return document.title',
|
|
description: 'Evaluated in the page (dom.eval). The gateway must be started with --allow-dangerous.',
|
|
displayOptions: { show: showFor('dom', ['eval']) },
|
|
},
|
|
{
|
|
displayName: 'Group ID',
|
|
name: 'groupId',
|
|
type: 'number',
|
|
default: 0,
|
|
required: true,
|
|
displayOptions: { show: showFor('group', ['tabs', 'close']) },
|
|
},
|
|
{
|
|
displayName: 'Group',
|
|
name: 'group',
|
|
type: 'string',
|
|
default: '',
|
|
required: true,
|
|
placeholder: 'Research or 12',
|
|
description: 'Target group by name or numeric ID',
|
|
displayOptions: { show: showFor('group', ['addTab', 'move']) },
|
|
},
|
|
{
|
|
displayName: 'Direction',
|
|
name: 'direction',
|
|
type: 'options',
|
|
default: 'forward',
|
|
options: [
|
|
{ name: 'Forward', value: 'forward' },
|
|
{ name: 'Backward', value: 'backward' },
|
|
],
|
|
displayOptions: { show: showFor('group', ['move']) },
|
|
},
|
|
{
|
|
displayName: 'Name',
|
|
name: 'name',
|
|
type: 'string',
|
|
default: '',
|
|
description: 'Name for the group/window/session. Required for all but Export (which dumps the active session when empty).',
|
|
displayOptions: {
|
|
show: {
|
|
resource: ['group', 'window', 'session'],
|
|
operation: ['create', 'rename', 'save', 'load', 'remove', 'export'],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
displayName: 'Session A',
|
|
name: 'nameA',
|
|
type: 'string',
|
|
default: '',
|
|
required: true,
|
|
displayOptions: { show: showFor('session', ['diff']) },
|
|
},
|
|
{
|
|
displayName: 'Session B',
|
|
name: 'nameB',
|
|
type: 'string',
|
|
default: '',
|
|
required: true,
|
|
displayOptions: { show: showFor('session', ['diff']) },
|
|
},
|
|
{
|
|
displayName: 'Enabled',
|
|
name: 'enabled',
|
|
type: 'boolean',
|
|
default: true,
|
|
description: 'Whether to turn session auto-save on',
|
|
displayOptions: { show: showFor('session', ['autoSave']) },
|
|
},
|
|
{
|
|
displayName: 'Storage Key',
|
|
name: 'key',
|
|
type: 'string',
|
|
default: '',
|
|
description: 'Storage key. Required for Set; on Get, omit to dump all keys.',
|
|
displayOptions: { show: showFor('storage', ['get', 'set']) },
|
|
},
|
|
{
|
|
displayName: 'Storage Value',
|
|
name: 'value',
|
|
type: 'string',
|
|
default: '',
|
|
required: true,
|
|
displayOptions: { show: showFor('storage', ['set']) },
|
|
},
|
|
{
|
|
displayName: 'Storage Type',
|
|
name: 'storeType',
|
|
type: 'options',
|
|
default: 'local',
|
|
options: [
|
|
{ name: 'localStorage', value: 'local' },
|
|
{ name: 'sessionStorage', value: 'session' },
|
|
],
|
|
displayOptions: { show: showFor('storage', ['get', 'set']) },
|
|
},
|
|
{
|
|
displayName: 'Command',
|
|
name: 'command',
|
|
type: 'string',
|
|
default: '',
|
|
required: true,
|
|
placeholder: 'tabs.list',
|
|
description: 'Raw browser-cli command name, e.g. tabs.list, navigate.open, extract.markdown',
|
|
displayOptions: { show: showFor('command', ['execute']) },
|
|
},
|
|
{
|
|
displayName: 'Arguments',
|
|
name: 'args',
|
|
type: 'json',
|
|
default: '{}',
|
|
description: 'JSON object of command arguments',
|
|
displayOptions: { show: showFor('command', ['execute']) },
|
|
},
|
|
],
|
|
};
|
|
|
|
methods = {
|
|
credentialTest: {
|
|
// Runs a real authenticated handshake so "Test" in the credential UI
|
|
// reflects the actual serve protocol, not a stand-in HTTP request.
|
|
async browserCliApiTest(
|
|
this: ICredentialTestFunctions,
|
|
credential: ICredentialsDecrypted,
|
|
): Promise<INodeCredentialTestResult> {
|
|
const options = connectOptionsFromCredentials((credential.data ?? {}) as IDataObject);
|
|
options.timeoutMs = 10_000;
|
|
try {
|
|
const response = await sendServeCommand(options, 'clients.list', {});
|
|
if (response && response.success === false) {
|
|
const message = String(response.error ?? 'serve returned an error');
|
|
// A rejected key is a real credential failure; any other server-side
|
|
// message means the handshake + auth already succeeded.
|
|
if (/unauthorized|untrusted|invalid signature|pubkey auth required/i.test(message)) {
|
|
return { status: 'Error', message };
|
|
}
|
|
return { status: 'OK', message: `Connected and authenticated. Note: ${message.split('\n')[0]}` };
|
|
}
|
|
return { status: 'OK', message: 'Connected to browser-cli serve' };
|
|
} catch (error) {
|
|
return { status: 'Error', message: (error as Error).message };
|
|
}
|
|
},
|
|
},
|
|
};
|
|
|
|
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
|
const items = this.getInputData();
|
|
const returnData: INodeExecutionData[] = [];
|
|
|
|
const connectOptions = connectOptionsFromCredentials(
|
|
(await this.getCredentials('browserCliApi')) as IDataObject,
|
|
);
|
|
|
|
for (let i = 0; i < items.length; i++) {
|
|
try {
|
|
const resource = this.getNodeParameter('resource', i) as string;
|
|
const operation = this.getNodeParameter('operation', i) as string;
|
|
|
|
const params = collectParams(this, resource, operation, i);
|
|
const { command, args } = buildCommand(resource, operation, params);
|
|
|
|
const response = await sendServeCommand(connectOptions, command, args);
|
|
if (response && response.success === false) {
|
|
throw new Error(String(response.error || 'serve command failed'));
|
|
}
|
|
const data = response && typeof response === 'object' && 'data' in response ? response.data : response;
|
|
|
|
const rows = Array.isArray(data) ? data : [data];
|
|
for (const row of rows) {
|
|
returnData.push({
|
|
json: isObject(row) ? (row as IDataObject) : { result: row },
|
|
pairedItem: { item: i },
|
|
});
|
|
}
|
|
} catch (error) {
|
|
if (this.continueOnFail()) {
|
|
returnData.push({ json: { error: (error as Error).message }, pairedItem: { item: i } });
|
|
continue;
|
|
}
|
|
throw new NodeOperationError(this.getNode(), error as Error, { itemIndex: i });
|
|
}
|
|
}
|
|
|
|
return [returnData];
|
|
}
|
|
}
|
|
|
|
function isObject(value: unknown): boolean {
|
|
return typeof value === 'object' && value !== null;
|
|
}
|
|
|
|
/** Map decrypted credential fields to serve connection options. */
|
|
function connectOptionsFromCredentials(creds: IDataObject): ServeConnectOptions {
|
|
return {
|
|
host: String(creds.host || '127.0.0.1'),
|
|
port: Number(creds.port || 8765),
|
|
tls: Boolean(creds.tls),
|
|
rejectUnauthorized: !creds.allowUnauthorizedCerts,
|
|
privateKeyPem: creds.privateKey ? String(creds.privateKey) : null,
|
|
route: creds.browser ? String(creds.browser) : null,
|
|
};
|
|
}
|
|
|
|
/** Read the UI fields relevant to this operation into a plain params object. */
|
|
function collectParams(
|
|
ctx: IExecuteFunctions,
|
|
resource: string,
|
|
operation: string,
|
|
i: number,
|
|
): CommandParams {
|
|
const get = (name: string, fallback: unknown = undefined) => ctx.getNodeParameter(name, i, fallback);
|
|
const key = `${resource}:${operation}`;
|
|
|
|
switch (key) {
|
|
case 'command:execute': {
|
|
const raw = get('args', {});
|
|
let args: Record<string, unknown> = {};
|
|
if (typeof raw === 'string') {
|
|
const trimmed = raw.trim();
|
|
args = trimmed ? JSON.parse(trimmed) : {};
|
|
} else if (isObject(raw)) {
|
|
args = raw as Record<string, unknown>;
|
|
}
|
|
return { command: get('command'), args };
|
|
}
|
|
// --- Tabs -------------------------------------------------------------
|
|
case 'tab:open':
|
|
return { url: get('url'), focus: get('focus', false) };
|
|
case 'tab:navigateTo':
|
|
return { tabId: get('tabId', 0), url: get('url') };
|
|
case 'tab:close':
|
|
return { mode: get('mode', 'ids'), tabIds: get('tabIds', '') };
|
|
case 'tab:query':
|
|
return { search: get('search', '') };
|
|
case 'tab:filter':
|
|
case 'tab:count':
|
|
return { pattern: get('pattern', '') };
|
|
case 'tab:activeInWindow':
|
|
return { windowId: get('windowId', 0) };
|
|
case 'tab:activate':
|
|
return { tabId: get('tabId', 0) };
|
|
case 'tab:move':
|
|
return { tabId: get('tabId', 0), windowId: get('windowId', 0), index: get('index', '') };
|
|
case 'tab:get':
|
|
case 'tab:getHtml':
|
|
case 'tab:reload':
|
|
case 'tab:hardReload':
|
|
case 'tab:back':
|
|
case 'tab:forward':
|
|
case 'tab:mute':
|
|
case 'tab:unmute':
|
|
case 'tab:pin':
|
|
case 'tab:unpin':
|
|
return { tabId: get('tabId', 0) };
|
|
case 'tab:dedupe':
|
|
case 'tab:mergeWindows':
|
|
return { gentleMode: get('gentleMode', 'auto') };
|
|
case 'tab:sort':
|
|
return { by: get('by', 'domain'), gentleMode: get('gentleMode', 'auto') };
|
|
case 'tab:screenshot':
|
|
return { tabId: get('tabId', 0), format: get('format', 'png'), quality: get('quality', '') };
|
|
|
|
// --- DOM --------------------------------------------------------------
|
|
case 'dom:query':
|
|
case 'dom:text':
|
|
case 'dom:exists':
|
|
case 'dom:click':
|
|
case 'dom:hover':
|
|
case 'dom:focus':
|
|
case 'dom:check':
|
|
case 'dom:uncheck':
|
|
case 'dom:clear':
|
|
case 'dom:submit':
|
|
return { selector: get('selector', '') };
|
|
case 'dom:type':
|
|
return { selector: get('selector', ''), text: get('text', '') };
|
|
case 'dom:attr':
|
|
return { selector: get('selector', ''), attr: get('attr', '') };
|
|
case 'dom:select':
|
|
return { selector: get('selector', ''), value: get('value', '') };
|
|
case 'dom:key':
|
|
return { selector: get('selector', ''), key: get('key', '') };
|
|
case 'dom:scroll':
|
|
return { selector: get('selector', ''), x: get('x', ''), y: get('y', '') };
|
|
case 'dom:eval':
|
|
return { code: get('code', ''), tabId: get('tabId', 0) };
|
|
|
|
// --- Page / extraction ------------------------------------------------
|
|
case 'page:extractText':
|
|
case 'page:extractLinks':
|
|
case 'page:extractImages':
|
|
case 'page:extractHtml':
|
|
case 'page:extractMarkdown':
|
|
case 'page:extractJson':
|
|
return { selector: get('selector', '') };
|
|
|
|
// --- Groups -----------------------------------------------------------
|
|
case 'group:query':
|
|
return { search: get('search', '') };
|
|
case 'group:tabs':
|
|
return { groupId: get('groupId', 0) };
|
|
case 'group:close':
|
|
return { groupId: get('groupId', 0), gentleMode: get('gentleMode', 'auto') };
|
|
case 'group:create':
|
|
return { name: get('name', '') };
|
|
case 'group:addTab':
|
|
return { group: get('group', ''), url: get('url', '') };
|
|
case 'group:move':
|
|
return { group: get('group', ''), direction: get('direction', 'forward') };
|
|
|
|
// --- Windows ----------------------------------------------------------
|
|
case 'window:open':
|
|
return { url: get('url', '') };
|
|
case 'window:close':
|
|
return { windowId: get('windowId', 0) };
|
|
case 'window:rename':
|
|
return { windowId: get('windowId', 0), name: get('name', '') };
|
|
|
|
// --- Sessions ---------------------------------------------------------
|
|
case 'session:save':
|
|
case 'session:load':
|
|
case 'session:remove':
|
|
case 'session:export':
|
|
return { name: get('name', '') };
|
|
case 'session:diff':
|
|
return { nameA: get('nameA', ''), nameB: get('nameB', '') };
|
|
case 'session:autoSave':
|
|
return { enabled: get('enabled', true) };
|
|
|
|
// --- Storage ----------------------------------------------------------
|
|
case 'storage:get':
|
|
return { key: get('key', ''), storeType: get('storeType', 'local'), tabId: get('tabId', 0) };
|
|
case 'storage:set':
|
|
return { key: get('key', ''), value: get('value', ''), storeType: get('storeType', 'local'), tabId: get('tabId', 0) };
|
|
|
|
default:
|
|
return {};
|
|
}
|
|
}
|