webflow-mcp 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.webflow/flows/_template/index.ts +14 -0
- package/.webflow/flows/_template/schema.json +11 -0
- package/.webflow/skills/README.md +3 -0
- package/README.md +215 -0
- package/dist/src/anti-block/provider-gate.d.ts +19 -0
- package/dist/src/anti-block/provider-gate.js +116 -0
- package/dist/src/cli/commands/approvals.d.ts +5 -0
- package/dist/src/cli/commands/approvals.js +69 -0
- package/dist/src/cli/commands/config.d.ts +4 -0
- package/dist/src/cli/commands/config.js +56 -0
- package/dist/src/cli/commands/create-flow.d.ts +4 -0
- package/dist/src/cli/commands/create-flow.js +27 -0
- package/dist/src/cli/commands/doctor.d.ts +19 -0
- package/dist/src/cli/commands/doctor.js +241 -0
- package/dist/src/cli/commands/fork.d.ts +4 -0
- package/dist/src/cli/commands/fork.js +19 -0
- package/dist/src/cli/commands/init.d.ts +4 -0
- package/dist/src/cli/commands/init.js +22 -0
- package/dist/src/cli/commands/login.d.ts +26 -0
- package/dist/src/cli/commands/login.js +145 -0
- package/dist/src/cli/commands/profiles.d.ts +40 -0
- package/dist/src/cli/commands/profiles.js +142 -0
- package/dist/src/cli/commands/run.d.ts +10 -0
- package/dist/src/cli/commands/run.js +13 -0
- package/dist/src/cli/commands/setup.d.ts +11 -0
- package/dist/src/cli/commands/setup.js +46 -0
- package/dist/src/cli/commands/worker.d.ts +29 -0
- package/dist/src/cli/commands/worker.js +30 -0
- package/dist/src/cli/index.d.ts +2 -0
- package/dist/src/cli/index.js +336 -0
- package/dist/src/config/webflow-config.d.ts +35 -0
- package/dist/src/config/webflow-config.js +119 -0
- package/dist/src/errors/self-healing.d.ts +62 -0
- package/dist/src/errors/self-healing.js +77 -0
- package/dist/src/mcp/cli.d.ts +2 -0
- package/dist/src/mcp/cli.js +36 -0
- package/dist/src/mcp/relay-gateway.d.ts +24 -0
- package/dist/src/mcp/relay-gateway.js +208 -0
- package/dist/src/mcp/server.d.ts +8 -0
- package/dist/src/mcp/server.js +62 -0
- package/dist/src/mcp/tool-registry.d.ts +10 -0
- package/dist/src/mcp/tool-registry.js +366 -0
- package/dist/src/profiles/profile-manager.d.ts +33 -0
- package/dist/src/profiles/profile-manager.js +37 -0
- package/dist/src/profiles/profile-state.d.ts +31 -0
- package/dist/src/profiles/profile-state.js +122 -0
- package/dist/src/repertoire/local-repertoire.d.ts +3 -0
- package/dist/src/repertoire/local-repertoire.js +59 -0
- package/dist/src/runner/browser-session.d.ts +23 -0
- package/dist/src/runner/browser-session.js +58 -0
- package/dist/src/runner/cdp-session.d.ts +25 -0
- package/dist/src/runner/cdp-session.js +156 -0
- package/dist/src/runner/chrome.d.ts +18 -0
- package/dist/src/runner/chrome.js +56 -0
- package/dist/src/runner/flow-loader.d.ts +11 -0
- package/dist/src/runner/flow-loader.js +115 -0
- package/dist/src/runner/flow-runner.d.ts +70 -0
- package/dist/src/runner/flow-runner.js +378 -0
- package/dist/src/runner/types.d.ts +112 -0
- package/dist/src/runner/types.js +1 -0
- package/dist/src/schema/translator.d.ts +5 -0
- package/dist/src/schema/translator.js +104 -0
- package/dist/src/schema/types.d.ts +32 -0
- package/dist/src/schema/types.js +1 -0
- package/dist/src/security/flow-approvals.d.ts +34 -0
- package/dist/src/security/flow-approvals.js +324 -0
- package/dist/src/security/flow-release.d.ts +82 -0
- package/dist/src/security/flow-release.js +392 -0
- package/dist/src/security/official-flow-trust.d.ts +2 -0
- package/dist/src/security/official-flow-trust.js +16 -0
- package/dist/src/security/trusted-flow-keys.d.ts +8 -0
- package/dist/src/security/trusted-flow-keys.js +132 -0
- package/dist/src/shared/credentials.d.ts +21 -0
- package/dist/src/shared/credentials.js +99 -0
- package/dist/src/shared/errors.d.ts +6 -0
- package/dist/src/shared/errors.js +16 -0
- package/dist/src/shared/fs.d.ts +5 -0
- package/dist/src/shared/fs.js +23 -0
- package/dist/src/shared/paths.d.ts +112 -0
- package/dist/src/shared/paths.js +257 -0
- package/dist/src/shared/semaphore.d.ts +8 -0
- package/dist/src/shared/semaphore.js +23 -0
- package/dist/src/shared/taxonomy.d.ts +21 -0
- package/dist/src/shared/taxonomy.js +20 -0
- package/dist/src/worker/connection.d.ts +72 -0
- package/dist/src/worker/connection.js +427 -0
- package/dist/src/worker/lifecycle.d.ts +50 -0
- package/dist/src/worker/lifecycle.js +154 -0
- package/dist/src/worker/protocol.d.ts +188 -0
- package/dist/src/worker/protocol.js +156 -0
- package/dist/src/worker/worker-service.d.ts +112 -0
- package/dist/src/worker/worker-service.js +258 -0
- package/package.json +50 -0
- package/taxonomy.json +99 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type BrowserContext, type Page } from 'playwright-core';
|
|
2
|
+
export interface BrowserSessionOptions {
|
|
3
|
+
userDataDir: string;
|
|
4
|
+
headless?: boolean;
|
|
5
|
+
startUrl?: string;
|
|
6
|
+
viewport?: {
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
} | null;
|
|
10
|
+
channel?: string;
|
|
11
|
+
stealth?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface BrowserSession {
|
|
14
|
+
context: BrowserContext;
|
|
15
|
+
page: Page;
|
|
16
|
+
close(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
export declare function launchBrowserSession(options: BrowserSessionOptions): Promise<BrowserSession>;
|
|
19
|
+
export declare function captureDomSnapshot(page: Page | undefined): Promise<{
|
|
20
|
+
html?: string;
|
|
21
|
+
text?: string;
|
|
22
|
+
url?: string;
|
|
23
|
+
} | undefined>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { chromium } from 'playwright-core';
|
|
2
|
+
export async function launchBrowserSession(options) {
|
|
3
|
+
const stealth = options.stealth ?? true;
|
|
4
|
+
const headless = options.headless ?? true;
|
|
5
|
+
const args = [
|
|
6
|
+
'--hide-crash-restore-bubble',
|
|
7
|
+
'--disable-features=BubbleSessionRestore'
|
|
8
|
+
];
|
|
9
|
+
if (!headless) {
|
|
10
|
+
args.push('--start-maximized');
|
|
11
|
+
}
|
|
12
|
+
const viewport = options.viewport ?? (headless ? { width: 1280, height: 800 } : null);
|
|
13
|
+
const context = await chromium.launchPersistentContext(options.userDataDir, {
|
|
14
|
+
channel: options.channel ?? 'chrome',
|
|
15
|
+
headless,
|
|
16
|
+
viewport,
|
|
17
|
+
args,
|
|
18
|
+
slowMo: headless ? undefined : 800,
|
|
19
|
+
ignoreDefaultArgs: stealth ? ['--enable-automation', '--no-sandbox'] : ['--no-sandbox']
|
|
20
|
+
});
|
|
21
|
+
if (stealth) {
|
|
22
|
+
await context.addInitScript(() => {
|
|
23
|
+
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
const existingPages = context.pages();
|
|
27
|
+
const page = existingPages[0] ?? (await context.newPage());
|
|
28
|
+
if (options.startUrl) {
|
|
29
|
+
await page.goto(options.startUrl, { waitUntil: 'domcontentloaded' });
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
context,
|
|
33
|
+
page,
|
|
34
|
+
async close() {
|
|
35
|
+
try {
|
|
36
|
+
await context.close();
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// best-effort close — context may already be gone
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export async function captureDomSnapshot(page) {
|
|
45
|
+
if (!page)
|
|
46
|
+
return undefined;
|
|
47
|
+
try {
|
|
48
|
+
const [html, text, url] = await Promise.all([
|
|
49
|
+
page.content().catch(() => undefined),
|
|
50
|
+
page.evaluate(() => document.body?.innerText ?? '').catch(() => undefined),
|
|
51
|
+
Promise.resolve(page.url()).catch(() => undefined)
|
|
52
|
+
]);
|
|
53
|
+
return { html, text, url };
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type Browser, type BrowserContext, type Page } from 'playwright-core';
|
|
2
|
+
export interface CdpSessionOptions {
|
|
3
|
+
/** Dedicated user-data-dir to launch Chrome against (the global "AI Chrome" profile). */
|
|
4
|
+
userDataDir: string;
|
|
5
|
+
startUrl?: string;
|
|
6
|
+
/** Attach to an already-running Chrome instead of launching one. */
|
|
7
|
+
cdpUrl?: string;
|
|
8
|
+
chromePath?: string;
|
|
9
|
+
/** Override the remote-debugging port; a free port is chosen when omitted. */
|
|
10
|
+
port?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface CdpSession {
|
|
13
|
+
browser: Browser;
|
|
14
|
+
context: BrowserContext;
|
|
15
|
+
page: Page;
|
|
16
|
+
cdpUrl: string;
|
|
17
|
+
/** Detach from Chrome WITHOUT closing the user's browser window. */
|
|
18
|
+
close(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Connect Playwright to a real Chrome over CDP, launching it against a dedicated
|
|
22
|
+
* profile dir when needed. Returns a page ready for automation. Closing the session
|
|
23
|
+
* only disconnects Playwright; it never closes the user's Chrome window.
|
|
24
|
+
*/
|
|
25
|
+
export declare function connectCdpSession(options: CdpSessionOptions): Promise<CdpSession>;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { createServer } from 'node:net';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { get as httpGet } from 'node:http';
|
|
4
|
+
import { chromium } from 'playwright-core';
|
|
5
|
+
import { resolveChromeExecutable } from './chrome.js';
|
|
6
|
+
function isCdpOpen(cdpUrl) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
const req = httpGet(`${cdpUrl.replace(/\/$/, '')}/json/version`, (res) => {
|
|
9
|
+
resolve(res.statusCode === 200);
|
|
10
|
+
res.resume();
|
|
11
|
+
});
|
|
12
|
+
req.on('error', () => resolve(false));
|
|
13
|
+
req.setTimeout(800, () => {
|
|
14
|
+
req.destroy();
|
|
15
|
+
resolve(false);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
async function waitForCdp(cdpUrl, maxWaitMs = 25000) {
|
|
20
|
+
const start = Date.now();
|
|
21
|
+
while (Date.now() - start < maxWaitMs) {
|
|
22
|
+
if (await isCdpOpen(cdpUrl))
|
|
23
|
+
return true;
|
|
24
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
function findFreePort() {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const srv = createServer();
|
|
31
|
+
srv.on('error', reject);
|
|
32
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
33
|
+
const address = srv.address();
|
|
34
|
+
const port = typeof address === 'object' && address ? address.port : 0;
|
|
35
|
+
srv.close(() => resolve(port));
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** Terminate only the specific Chrome process the runner launched (by pid, never by image). */
|
|
40
|
+
function killProcessTree(pid) {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
if (process.platform === 'win32') {
|
|
43
|
+
const killer = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
|
|
44
|
+
killer.on('exit', () => resolve());
|
|
45
|
+
killer.on('error', () => resolve());
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
process.kill(-pid, 'SIGTERM');
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
try {
|
|
53
|
+
process.kill(pid, 'SIGTERM');
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// process already gone
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
resolve();
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
async function ensureChromeCdp(options) {
|
|
63
|
+
if (options.cdpUrl) {
|
|
64
|
+
if (await isCdpOpen(options.cdpUrl))
|
|
65
|
+
return { cdpUrl: options.cdpUrl, launched: false };
|
|
66
|
+
throw new Error(`No Chrome with remote debugging was found at ${options.cdpUrl}.`);
|
|
67
|
+
}
|
|
68
|
+
const port = options.port ?? (await findFreePort());
|
|
69
|
+
const cdpUrl = `http://127.0.0.1:${port}`;
|
|
70
|
+
if (await isCdpOpen(cdpUrl))
|
|
71
|
+
return { cdpUrl, launched: false };
|
|
72
|
+
// Launch real Chrome against the dedicated profile dir. No automation flags and a
|
|
73
|
+
// dedicated user-data-dir, so it does not fight the user's everyday Chrome and is
|
|
74
|
+
// accepted by strict providers.
|
|
75
|
+
const args = [
|
|
76
|
+
`--remote-debugging-port=${port}`,
|
|
77
|
+
'--remote-allow-origins=*',
|
|
78
|
+
`--user-data-dir=${options.userDataDir}`,
|
|
79
|
+
'--no-first-run',
|
|
80
|
+
'--no-default-browser-check'
|
|
81
|
+
];
|
|
82
|
+
if (options.startUrl)
|
|
83
|
+
args.push(options.startUrl);
|
|
84
|
+
const child = spawn(resolveChromeExecutable(options.chromePath), args, { detached: true, stdio: 'ignore' });
|
|
85
|
+
child.unref();
|
|
86
|
+
if (!(await waitForCdp(cdpUrl))) {
|
|
87
|
+
throw new Error(`Chrome did not expose remote debugging at ${cdpUrl}. ` +
|
|
88
|
+
'If a Chrome using this profile is already open without debugging, close it and run again.');
|
|
89
|
+
}
|
|
90
|
+
return { cdpUrl, child, launched: true };
|
|
91
|
+
}
|
|
92
|
+
function pickPage(contexts, startUrl) {
|
|
93
|
+
if (contexts.length === 0) {
|
|
94
|
+
throw new Error('No Chrome contexts found over CDP. Open Chrome with your signed-in profile and try again.');
|
|
95
|
+
}
|
|
96
|
+
if (startUrl) {
|
|
97
|
+
for (const context of contexts) {
|
|
98
|
+
const match = context.pages().find((page) => page.url().startsWith(startUrl));
|
|
99
|
+
if (match)
|
|
100
|
+
return { context, page: match };
|
|
101
|
+
}
|
|
102
|
+
const origin = (() => {
|
|
103
|
+
try {
|
|
104
|
+
return new URL(startUrl).origin;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
})();
|
|
110
|
+
if (origin) {
|
|
111
|
+
for (const context of contexts) {
|
|
112
|
+
const match = context.pages().find((page) => page.url().startsWith(origin));
|
|
113
|
+
if (match)
|
|
114
|
+
return { context, page: match };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { context: contexts[0], page: contexts[0].pages()[0] };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Connect Playwright to a real Chrome over CDP, launching it against a dedicated
|
|
122
|
+
* profile dir when needed. Returns a page ready for automation. Closing the session
|
|
123
|
+
* only disconnects Playwright; it never closes the user's Chrome window.
|
|
124
|
+
*/
|
|
125
|
+
export async function connectCdpSession(options) {
|
|
126
|
+
const { cdpUrl, child, launched } = await ensureChromeCdp(options);
|
|
127
|
+
const browser = await chromium.connectOverCDP(cdpUrl);
|
|
128
|
+
// Close the browser window ONLY if the runner launched it. When attaching to a
|
|
129
|
+
// Chrome the user already had open (via cdpUrl), we just disconnect and leave it.
|
|
130
|
+
const shutdown = async () => {
|
|
131
|
+
try {
|
|
132
|
+
await browser.close();
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// connectOverCDP's close() only detaches; ignore disconnect errors
|
|
136
|
+
}
|
|
137
|
+
if (launched && child?.pid) {
|
|
138
|
+
await killProcessTree(child.pid);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
try {
|
|
142
|
+
const { context, page: existing } = pickPage(browser.contexts(), options.startUrl);
|
|
143
|
+
let page = existing;
|
|
144
|
+
if (!page) {
|
|
145
|
+
page = await context.newPage();
|
|
146
|
+
}
|
|
147
|
+
if (options.startUrl && !page.url().startsWith(options.startUrl)) {
|
|
148
|
+
await page.goto(options.startUrl, { waitUntil: 'domcontentloaded' });
|
|
149
|
+
}
|
|
150
|
+
return { browser, context, page, cdpUrl, close: shutdown };
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
await shutdown();
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locate a real Google Chrome executable. The user can always override with an
|
|
3
|
+
* explicit path. We intentionally prefer real Chrome over bundled Chromium because
|
|
4
|
+
* strict providers (Google, Adobe) reject automation-flavored browsers at login time.
|
|
5
|
+
*/
|
|
6
|
+
export declare function resolveChromeExecutable(inputPath?: string): string;
|
|
7
|
+
export interface LoginLaunchOptions {
|
|
8
|
+
userDataDir: string;
|
|
9
|
+
startUrl?: string;
|
|
10
|
+
executablePath?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Launch a plain, real Chrome window pointed at a dedicated user-data-dir for a
|
|
14
|
+
* one-time manual login. No remote-debugging port and no automation flags, so the
|
|
15
|
+
* OAuth login step is not blocked as an "unsafe browser". The promise resolves when
|
|
16
|
+
* the user closes the window; the persisted session is then reused by automated runs.
|
|
17
|
+
*/
|
|
18
|
+
export declare function launchChromeForLogin(options: LoginLaunchOptions): Promise<void>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
/**
|
|
5
|
+
* Locate a real Google Chrome executable. The user can always override with an
|
|
6
|
+
* explicit path. We intentionally prefer real Chrome over bundled Chromium because
|
|
7
|
+
* strict providers (Google, Adobe) reject automation-flavored browsers at login time.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveChromeExecutable(inputPath) {
|
|
10
|
+
if (inputPath)
|
|
11
|
+
return inputPath;
|
|
12
|
+
if (process.platform === 'win32') {
|
|
13
|
+
const candidates = [
|
|
14
|
+
process.env.ProgramFiles ? join(process.env.ProgramFiles, 'Google', 'Chrome', 'Application', 'chrome.exe') : undefined,
|
|
15
|
+
process.env['ProgramFiles(x86)'] ? join(process.env['ProgramFiles(x86)'], 'Google', 'Chrome', 'Application', 'chrome.exe') : undefined,
|
|
16
|
+
process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'Google', 'Chrome', 'Application', 'chrome.exe') : undefined
|
|
17
|
+
].filter((candidate) => Boolean(candidate));
|
|
18
|
+
const found = candidates.find((candidate) => existsSync(candidate));
|
|
19
|
+
if (found)
|
|
20
|
+
return found;
|
|
21
|
+
}
|
|
22
|
+
return process.platform === 'darwin'
|
|
23
|
+
? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
|
24
|
+
: 'google-chrome';
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Launch a plain, real Chrome window pointed at a dedicated user-data-dir for a
|
|
28
|
+
* one-time manual login. No remote-debugging port and no automation flags, so the
|
|
29
|
+
* OAuth login step is not blocked as an "unsafe browser". The promise resolves when
|
|
30
|
+
* the user closes the window; the persisted session is then reused by automated runs.
|
|
31
|
+
*/
|
|
32
|
+
export function launchChromeForLogin(options) {
|
|
33
|
+
const executable = resolveChromeExecutable(options.executablePath);
|
|
34
|
+
const args = [
|
|
35
|
+
`--user-data-dir=${options.userDataDir}`,
|
|
36
|
+
'--no-first-run',
|
|
37
|
+
'--no-default-browser-check'
|
|
38
|
+
];
|
|
39
|
+
if (options.startUrl)
|
|
40
|
+
args.push(options.startUrl);
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const child = spawn(executable, args, { detached: false, stdio: 'ignore' });
|
|
43
|
+
const startedAt = Date.now();
|
|
44
|
+
child.on('error', (error) => reject(error));
|
|
45
|
+
child.on('exit', () => {
|
|
46
|
+
// If Chrome handed off to an already-running instance it exits immediately and we
|
|
47
|
+
// cannot observe the window closing. Surface that so the caller can advise the user.
|
|
48
|
+
if (Date.now() - startedAt < 1500) {
|
|
49
|
+
reject(new Error('Chrome exited immediately, likely because another Chrome instance is already running. ' +
|
|
50
|
+
'Close all Chrome windows and run setup again, or pass an explicit chromePath.'));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
resolve();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FlowDiscoveryResult, FlowModule } from './types.js';
|
|
2
|
+
export declare function discoverFlows(projectRoot: string): Promise<FlowDiscoveryResult>;
|
|
3
|
+
export declare function loadFlowModule(entryFile: string): Promise<FlowModule>;
|
|
4
|
+
/**
|
|
5
|
+
* Loads a flow module from already-bundled JS source text instead of a file on
|
|
6
|
+
* disk (RFC 0001, Fase 3: relay-pushed flows). The code never lands anywhere
|
|
7
|
+
* persistent — it's written to a throwaway temp file only because Node's ESM loader
|
|
8
|
+
* needs a real module specifier to `import()`, then the temp directory is deleted
|
|
9
|
+
* immediately after the import resolves (the module is already in memory by then).
|
|
10
|
+
*/
|
|
11
|
+
export declare function loadFlowModuleFromSource(code: string, label?: string): Promise<FlowModule>;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { statSync } from 'node:fs';
|
|
3
|
+
import { basename, resolve } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { pathExists } from '../shared/fs.js';
|
|
7
|
+
import { getWorkspacePaths, resolveFlowsProjectRoot, sanitizeName } from '../shared/paths.js';
|
|
8
|
+
function isReservedFlowFolder(name) {
|
|
9
|
+
return name.startsWith('.') || name.startsWith('_');
|
|
10
|
+
}
|
|
11
|
+
export async function discoverFlows(projectRoot) {
|
|
12
|
+
// Flows come from the resolved flows root (this package by default for cross-repo use);
|
|
13
|
+
// downloads still target the caller's projectRoot via ctx.projectRoot in the runner.
|
|
14
|
+
const paths = getWorkspacePaths(resolveFlowsProjectRoot(projectRoot));
|
|
15
|
+
const diagnostics = [];
|
|
16
|
+
const flows = [];
|
|
17
|
+
if (!(await pathExists(paths.flowsDir))) {
|
|
18
|
+
return {
|
|
19
|
+
flows: [],
|
|
20
|
+
diagnostics: [`Workspace flows directory does not exist at ${paths.flowsDir}. Run webflow init in ${paths.projectRoot} or pass the correct projectRoot.`]
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const entries = await readdir(paths.flowsDir, { withFileTypes: true });
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
if (isReservedFlowFolder(entry.name)) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const dir = resolve(paths.flowsDir, entry.name);
|
|
29
|
+
// Windows reports directory junctions (and some symlinks) as isSymbolicLink()=true,
|
|
30
|
+
// isDirectory()=false — readdir's dirent type doesn't follow the reparse point. Resolve
|
|
31
|
+
// with statSync (which does follow it) before deciding whether this entry is a directory.
|
|
32
|
+
if (!entry.isDirectory()) {
|
|
33
|
+
if (!entry.isSymbolicLink()) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const resolved = statSync(dir, { throwIfNoEntry: false });
|
|
37
|
+
if (!resolved?.isDirectory()) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const entryFile = resolve(dir, 'index.ts');
|
|
42
|
+
const schemaFile = resolve(dir, 'schema.json');
|
|
43
|
+
const [entryExists, schemaExists] = await Promise.all([pathExists(entryFile), pathExists(schemaFile)]);
|
|
44
|
+
if (!entryExists || !schemaExists) {
|
|
45
|
+
diagnostics.push(`Ignored flow "${entry.name}" because it is missing ${!entryExists ? 'index.ts' : 'schema.json'}.`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
flows.push({ name: entry.name, dir, entryFile, schemaFile });
|
|
49
|
+
}
|
|
50
|
+
flows.sort((left, right) => left.name.localeCompare(right.name));
|
|
51
|
+
return { flows, diagnostics };
|
|
52
|
+
}
|
|
53
|
+
async function resolveModuleFromFile(outfile, label) {
|
|
54
|
+
const module = await import(`${pathToFileURL(outfile).href}?t=${Date.now()}`);
|
|
55
|
+
const candidate = (module.default ?? module);
|
|
56
|
+
const namespaceCapabilities = module.capabilities;
|
|
57
|
+
if (typeof candidate.run !== 'function') {
|
|
58
|
+
throw new Error(`Flow module ${label} must export an async run(ctx, input) function.`);
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
run: candidate.run.bind(candidate),
|
|
62
|
+
capabilities: candidate.capabilities ?? namespaceCapabilities
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
// esbuild is only needed for local flow dev (`webflow create flow`/`webflow run`), never
|
|
66
|
+
// for the relay-driven worker path (loadFlowModuleFromSource below, which loads
|
|
67
|
+
// already-bundled source). Loaded lazily so `worker start`/`setup` never need esbuild
|
|
68
|
+
// installed at all — its native postinstall script is known to fail when nested inside a
|
|
69
|
+
// git-based `npm install -g` (npm bug, not ours to fix), which is exactly how the worker
|
|
70
|
+
// gets distributed.
|
|
71
|
+
async function loadEsbuild() {
|
|
72
|
+
try {
|
|
73
|
+
return await import('esbuild');
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new Error('esbuild is required for local flow dev (webflow create flow / webflow run) but is not installed. Run: npm install esbuild');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export async function loadFlowModule(entryFile) {
|
|
80
|
+
await stat(entryFile);
|
|
81
|
+
const { build } = await loadEsbuild();
|
|
82
|
+
const tempDir = await mkdtemp(resolve(tmpdir(), 'webflow-flow-'));
|
|
83
|
+
const outfile = resolve(tempDir, `${basename(entryFile, '.ts')}.mjs`);
|
|
84
|
+
await build({
|
|
85
|
+
entryPoints: [entryFile],
|
|
86
|
+
outfile,
|
|
87
|
+
bundle: true,
|
|
88
|
+
format: 'esm',
|
|
89
|
+
platform: 'node',
|
|
90
|
+
target: 'node20',
|
|
91
|
+
packages: 'external'
|
|
92
|
+
});
|
|
93
|
+
return resolveModuleFromFile(outfile, entryFile);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Loads a flow module from already-bundled JS source text instead of a file on
|
|
97
|
+
* disk (RFC 0001, Fase 3: relay-pushed flows). The code never lands anywhere
|
|
98
|
+
* persistent — it's written to a throwaway temp file only because Node's ESM loader
|
|
99
|
+
* needs a real module specifier to `import()`, then the temp directory is deleted
|
|
100
|
+
* immediately after the import resolves (the module is already in memory by then).
|
|
101
|
+
*/
|
|
102
|
+
export async function loadFlowModuleFromSource(code, label = 'remote') {
|
|
103
|
+
// The label can come from a relay-supplied flow name — never trust it into a path
|
|
104
|
+
// unsanitized (RFC 0001, section 5: don't trust the relay blindly).
|
|
105
|
+
const safeLabel = sanitizeName(label);
|
|
106
|
+
const tempDir = await mkdtemp(resolve(tmpdir(), `webflow-flow-${safeLabel}-`));
|
|
107
|
+
const outfile = resolve(tempDir, 'module.mjs');
|
|
108
|
+
try {
|
|
109
|
+
await writeFile(outfile, code, 'utf8');
|
|
110
|
+
return await resolveModuleFromFile(outfile, safeLabel);
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { ProfileManager } from '../profiles/profile-manager.js';
|
|
2
|
+
import type { CompiledSchema, SupportedSchema } from '../schema/types.js';
|
|
3
|
+
import type { BrowserVisibility, WebflowConfig } from '../config/webflow-config.js';
|
|
4
|
+
import type { BrowserCapability, BrowserMode, FlowCapabilities, FlowDefinition, FlowExecutionResult, RunnableFlow } from './types.js';
|
|
5
|
+
export interface FlowRunOptions {
|
|
6
|
+
profileName?: string;
|
|
7
|
+
schema?: CompiledSchema;
|
|
8
|
+
headed?: boolean;
|
|
9
|
+
browserMode?: BrowserMode;
|
|
10
|
+
browserVisibility?: BrowserVisibility;
|
|
11
|
+
}
|
|
12
|
+
type ResolvedBrowserMode = 'none' | BrowserMode;
|
|
13
|
+
export declare function resolveBrowserMode(params: {
|
|
14
|
+
capability: BrowserCapability | undefined;
|
|
15
|
+
supportedBrowserModes?: BrowserMode[];
|
|
16
|
+
requestedMode?: BrowserMode;
|
|
17
|
+
flowName: string;
|
|
18
|
+
config: WebflowConfig;
|
|
19
|
+
}): ResolvedBrowserMode;
|
|
20
|
+
/**
|
|
21
|
+
* Visibility precedence (see Project-Context/modules/browser-runner.md):
|
|
22
|
+
* 1. requiresVisibleBrowser (mandatory, e.g. strict providers) -> visible
|
|
23
|
+
* 2. --headed / worker --headed (options.headed) -> visible
|
|
24
|
+
* 3. one-off browserVisibility option -> as set
|
|
25
|
+
* 4. config.browserVisibility.flows[flowName] (explicit override) -> as set
|
|
26
|
+
* 5. config.browserVisibility.default, if not 'inherit' -> as set
|
|
27
|
+
* 6. capabilities.headless (legacy, per-flow code) -> as set
|
|
28
|
+
* 7. fallback -> hidden
|
|
29
|
+
* `browser: 'cdp'` never reaches this function — it has no headless concept, it
|
|
30
|
+
* connects to an already-visible real Chrome by construction.
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveHeadless(params: {
|
|
33
|
+
requiresVisibleBrowser: boolean | undefined;
|
|
34
|
+
optionsHeaded: boolean | undefined;
|
|
35
|
+
optionsVisibility?: BrowserVisibility;
|
|
36
|
+
legacyHeadless: boolean | undefined;
|
|
37
|
+
flowName: string;
|
|
38
|
+
config: WebflowConfig;
|
|
39
|
+
}): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Whether to nudge the calling agent to ask the human for a standing visibility
|
|
42
|
+
* preference for this specific flow. Never fires for flows with a mandatory-visible
|
|
43
|
+
* exception (nothing to configure), and never fires again once an explicit per-flow
|
|
44
|
+
* override already exists.
|
|
45
|
+
*/
|
|
46
|
+
export declare function shouldNudgeVisibility(params: {
|
|
47
|
+
requiresVisibleBrowser: boolean | undefined;
|
|
48
|
+
optionsVisibility?: BrowserVisibility;
|
|
49
|
+
flowName: string;
|
|
50
|
+
config: WebflowConfig;
|
|
51
|
+
}): boolean;
|
|
52
|
+
export declare class FlowRunner {
|
|
53
|
+
private readonly projectRoot;
|
|
54
|
+
readonly profileManager: ProfileManager;
|
|
55
|
+
private readonly providerGate;
|
|
56
|
+
private readonly browserSlots;
|
|
57
|
+
constructor(projectRoot: string, profileManager?: ProfileManager);
|
|
58
|
+
compile(definition: FlowDefinition): Promise<RunnableFlow>;
|
|
59
|
+
run(definition: FlowDefinition, input: unknown, options?: FlowRunOptions): Promise<FlowExecutionResult>;
|
|
60
|
+
/**
|
|
61
|
+
* Executes a flow whose code was NOT loaded from this machine's disk (RFC 0001,
|
|
62
|
+
* Fase 3): the relay bundles and pushes the flow's source per job so that no
|
|
63
|
+
* automation code — free or paid — ever persists on the worker's machine. Reuses
|
|
64
|
+
* every bit of session/profile/error-handling logic that `run()` uses; the only
|
|
65
|
+
* difference is where the `FlowModule` comes from.
|
|
66
|
+
*/
|
|
67
|
+
runRemote(flowName: string, code: string, schemaJson: SupportedSchema, input: unknown, options?: FlowRunOptions, expectedCapabilities?: FlowCapabilities): Promise<FlowExecutionResult>;
|
|
68
|
+
private executeModule;
|
|
69
|
+
}
|
|
70
|
+
export {};
|