6270d8c956
Testing / remote-protocol-compat (0.16.0) (push) Successful in 1m1s
Testing / remote-protocol-compat (0.15.0) (push) Successful in 1m3s
Testing / test (push) Failing after 1m15s
Build & Publish Package / publish (push) Successful in 51s
Package Extension / package-extension (push) Successful in 1m6s
- Add SSH-style server identity keys and known-host verification for remote serve endpoints. - Add remote add/list/remove commands for explicit endpoint persistence. - Fix remote clients listing to fan out through target discovery instead of ambiguous auto-routing. - Add URL glob matching for tabs filter and count with extension tests. - Add n8n credential pinning for server public keys or SHA256 fingerprints. - Remove obsolete compat shim behavior while keeping empty compat seams for future protocol changes. - Bump browser-cli to 0.16.4 and n8n node to 0.3.1. - Cover known-hosts, remote registry, compat seams, n8n protocol verification, and URL matching with tests.
871 lines
36 KiB
TypeScript
871 lines
36 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: 'twitch.tv/* or twitch.tv',
|
|
description: 'Matched against the full tab URL. A plain string is a case-sensitive substring match ("twitch.tv"); a pattern with "*" or "?" is a glob ("twitch.tv/*", "*.twitch.tv"). Glob needs the serve-side extension at 0.16.4+; older extensions treat the whole pattern as a literal substring. 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,
|
|
serverIdentity: creds.serverIdentity ? String(creds.serverIdentity) : null,
|
|
allowUnknownServerIdentity: Boolean(creds.allowUnknownServerIdentity),
|
|
};
|
|
}
|
|
|
|
/** 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 {};
|
|
}
|
|
}
|