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.
Files changed (94) hide show
  1. package/.webflow/flows/_template/index.ts +14 -0
  2. package/.webflow/flows/_template/schema.json +11 -0
  3. package/.webflow/skills/README.md +3 -0
  4. package/README.md +215 -0
  5. package/dist/src/anti-block/provider-gate.d.ts +19 -0
  6. package/dist/src/anti-block/provider-gate.js +116 -0
  7. package/dist/src/cli/commands/approvals.d.ts +5 -0
  8. package/dist/src/cli/commands/approvals.js +69 -0
  9. package/dist/src/cli/commands/config.d.ts +4 -0
  10. package/dist/src/cli/commands/config.js +56 -0
  11. package/dist/src/cli/commands/create-flow.d.ts +4 -0
  12. package/dist/src/cli/commands/create-flow.js +27 -0
  13. package/dist/src/cli/commands/doctor.d.ts +19 -0
  14. package/dist/src/cli/commands/doctor.js +241 -0
  15. package/dist/src/cli/commands/fork.d.ts +4 -0
  16. package/dist/src/cli/commands/fork.js +19 -0
  17. package/dist/src/cli/commands/init.d.ts +4 -0
  18. package/dist/src/cli/commands/init.js +22 -0
  19. package/dist/src/cli/commands/login.d.ts +26 -0
  20. package/dist/src/cli/commands/login.js +145 -0
  21. package/dist/src/cli/commands/profiles.d.ts +40 -0
  22. package/dist/src/cli/commands/profiles.js +142 -0
  23. package/dist/src/cli/commands/run.d.ts +10 -0
  24. package/dist/src/cli/commands/run.js +13 -0
  25. package/dist/src/cli/commands/setup.d.ts +11 -0
  26. package/dist/src/cli/commands/setup.js +46 -0
  27. package/dist/src/cli/commands/worker.d.ts +29 -0
  28. package/dist/src/cli/commands/worker.js +30 -0
  29. package/dist/src/cli/index.d.ts +2 -0
  30. package/dist/src/cli/index.js +336 -0
  31. package/dist/src/config/webflow-config.d.ts +35 -0
  32. package/dist/src/config/webflow-config.js +119 -0
  33. package/dist/src/errors/self-healing.d.ts +62 -0
  34. package/dist/src/errors/self-healing.js +77 -0
  35. package/dist/src/mcp/cli.d.ts +2 -0
  36. package/dist/src/mcp/cli.js +36 -0
  37. package/dist/src/mcp/relay-gateway.d.ts +24 -0
  38. package/dist/src/mcp/relay-gateway.js +208 -0
  39. package/dist/src/mcp/server.d.ts +8 -0
  40. package/dist/src/mcp/server.js +62 -0
  41. package/dist/src/mcp/tool-registry.d.ts +10 -0
  42. package/dist/src/mcp/tool-registry.js +366 -0
  43. package/dist/src/profiles/profile-manager.d.ts +33 -0
  44. package/dist/src/profiles/profile-manager.js +37 -0
  45. package/dist/src/profiles/profile-state.d.ts +31 -0
  46. package/dist/src/profiles/profile-state.js +122 -0
  47. package/dist/src/repertoire/local-repertoire.d.ts +3 -0
  48. package/dist/src/repertoire/local-repertoire.js +59 -0
  49. package/dist/src/runner/browser-session.d.ts +23 -0
  50. package/dist/src/runner/browser-session.js +58 -0
  51. package/dist/src/runner/cdp-session.d.ts +25 -0
  52. package/dist/src/runner/cdp-session.js +156 -0
  53. package/dist/src/runner/chrome.d.ts +18 -0
  54. package/dist/src/runner/chrome.js +56 -0
  55. package/dist/src/runner/flow-loader.d.ts +11 -0
  56. package/dist/src/runner/flow-loader.js +115 -0
  57. package/dist/src/runner/flow-runner.d.ts +70 -0
  58. package/dist/src/runner/flow-runner.js +378 -0
  59. package/dist/src/runner/types.d.ts +112 -0
  60. package/dist/src/runner/types.js +1 -0
  61. package/dist/src/schema/translator.d.ts +5 -0
  62. package/dist/src/schema/translator.js +104 -0
  63. package/dist/src/schema/types.d.ts +32 -0
  64. package/dist/src/schema/types.js +1 -0
  65. package/dist/src/security/flow-approvals.d.ts +34 -0
  66. package/dist/src/security/flow-approvals.js +324 -0
  67. package/dist/src/security/flow-release.d.ts +82 -0
  68. package/dist/src/security/flow-release.js +392 -0
  69. package/dist/src/security/official-flow-trust.d.ts +2 -0
  70. package/dist/src/security/official-flow-trust.js +16 -0
  71. package/dist/src/security/trusted-flow-keys.d.ts +8 -0
  72. package/dist/src/security/trusted-flow-keys.js +132 -0
  73. package/dist/src/shared/credentials.d.ts +21 -0
  74. package/dist/src/shared/credentials.js +99 -0
  75. package/dist/src/shared/errors.d.ts +6 -0
  76. package/dist/src/shared/errors.js +16 -0
  77. package/dist/src/shared/fs.d.ts +5 -0
  78. package/dist/src/shared/fs.js +23 -0
  79. package/dist/src/shared/paths.d.ts +112 -0
  80. package/dist/src/shared/paths.js +257 -0
  81. package/dist/src/shared/semaphore.d.ts +8 -0
  82. package/dist/src/shared/semaphore.js +23 -0
  83. package/dist/src/shared/taxonomy.d.ts +21 -0
  84. package/dist/src/shared/taxonomy.js +20 -0
  85. package/dist/src/worker/connection.d.ts +72 -0
  86. package/dist/src/worker/connection.js +427 -0
  87. package/dist/src/worker/lifecycle.d.ts +50 -0
  88. package/dist/src/worker/lifecycle.js +154 -0
  89. package/dist/src/worker/protocol.d.ts +188 -0
  90. package/dist/src/worker/protocol.js +156 -0
  91. package/dist/src/worker/worker-service.d.ts +112 -0
  92. package/dist/src/worker/worker-service.js +258 -0
  93. package/package.json +50 -0
  94. package/taxonomy.json +99 -0
@@ -0,0 +1,241 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import { chromium } from 'playwright-core';
4
+ import { discoverFlows, loadFlowModule } from '../../runner/flow-loader.js';
5
+ import { translateSchema } from '../../schema/translator.js';
6
+ import { pathExists, readJsonFile } from '../../shared/fs.js';
7
+ import { MIN_NODE_MAJOR, getPackageRoot, getWorkspacePaths, resolveFlowsProjectRoot } from '../../shared/paths.js';
8
+ import { loadTaxonomy, isValidSector, isValidService } from '../../shared/taxonomy.js';
9
+ const dangerousFlowPatterns = [
10
+ { name: 'absolute Windows path', pattern: /[A-Za-z]:\\/, message: 'contains an absolute Windows path' },
11
+ { name: 'taskkill', pattern: /\btaskkill\b/i, message: 'kills global processes with taskkill' },
12
+ { name: 'fixed CDP port', pattern: /--remote-debugging-port=9222/i, message: 'uses fixed CDP port 9222' },
13
+ { name: 'real Chrome profile', pattern: /Profile 1/, message: 'references a real Chrome Profile 1 directory' },
14
+ { name: 'process.cwd()', pattern: /process\.cwd\(\)/, message: 'depends on process.cwd()' },
15
+ { name: 'connectOverCDP', pattern: /connectOverCDP/, message: 'bypasses the central browser runner with connectOverCDP' }
16
+ ];
17
+ function add(checks, level, message) {
18
+ checks.push({ level, message });
19
+ }
20
+ function nodeMajor(version) {
21
+ return Number.parseInt(version.split('.')[0] ?? '', 10);
22
+ }
23
+ async function isDirectory(path) {
24
+ try {
25
+ return (await stat(path)).isDirectory();
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ async function checkFlowFiles(projectRoot, checks) {
32
+ const paths = getWorkspacePaths(resolveFlowsProjectRoot(projectRoot));
33
+ const invalidFlows = [];
34
+ const taxonomy = await loadTaxonomy();
35
+ if (!(await isDirectory(paths.flowsDir))) {
36
+ add(checks, 'error', `Workspace flows directory is missing. Run webflow init in ${projectRoot} or pass the correct projectRoot.`);
37
+ return invalidFlows;
38
+ }
39
+ const entries = await readdir(paths.flowsDir, { withFileTypes: true });
40
+ for (const entry of entries) {
41
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name.startsWith('_')) {
42
+ continue;
43
+ }
44
+ const flowDir = resolve(paths.flowsDir, entry.name);
45
+ const entryFile = resolve(flowDir, 'index.ts');
46
+ const schemaFile = resolve(flowDir, 'schema.json');
47
+ const entryExists = await pathExists(entryFile);
48
+ const schemaExists = await pathExists(schemaFile);
49
+ if (!entryExists || !schemaExists) {
50
+ invalidFlows.push(entry.name);
51
+ add(checks, 'error', `Flow ${entry.name} is missing ${!entryExists ? 'index.ts' : 'schema.json'}.`);
52
+ continue;
53
+ }
54
+ try {
55
+ translateSchema(await readJsonFile(schemaFile));
56
+ }
57
+ catch (error) {
58
+ invalidFlows.push(entry.name);
59
+ add(checks, 'error', `Flow ${entry.name} has an invalid schema.json: ${error instanceof Error ? error.message : String(error)}`);
60
+ }
61
+ const source = await readFile(entryFile, 'utf8');
62
+ for (const dangerous of dangerousFlowPatterns) {
63
+ if (dangerous.pattern.test(source)) {
64
+ add(checks, 'warn', `Flow ${entry.name} ${dangerous.message}. Prefer ctx.page/ctx.browser and .webflow/profiles.`);
65
+ }
66
+ }
67
+ let capabilities;
68
+ try {
69
+ capabilities = (await loadFlowModule(entryFile)).capabilities;
70
+ }
71
+ catch {
72
+ // Already reported as an invalid flow above; nothing more to check here.
73
+ continue;
74
+ }
75
+ checkLoginNormalization(entry.name, capabilities, checks);
76
+ checkBrowserModes(entry.name, capabilities, checks);
77
+ checkTaxonomy(entry.name, capabilities, checks, taxonomy);
78
+ }
79
+ return invalidFlows;
80
+ }
81
+ /**
82
+ * Enforces the explicit `capabilities.requiresLogin` contract (RFC 0001 follow-up,
83
+ * 2026-07-06): whether a flow needs a real logged-in session must be declared, not
84
+ * inferred from `profile`/`startUrl`, so the worker catalog and any future
85
+ * dashboard can rely on it instead of every caller guessing.
86
+ */
87
+ function checkLoginNormalization(flowName, capabilities, checks) {
88
+ if (!capabilities?.browser) {
89
+ return;
90
+ }
91
+ if (capabilities.requiresLogin === undefined) {
92
+ add(checks, 'warn', `Flow ${flowName} uses a browser but does not declare capabilities.requiresLogin (true/false). Declare it explicitly.`);
93
+ return;
94
+ }
95
+ if (capabilities.requiresLogin && capabilities.startUrl === 'about:blank') {
96
+ add(checks, 'error', `Flow ${flowName} declares requiresLogin: true but startUrl is "about:blank" (no site to log into). This is a contradiction.`);
97
+ }
98
+ if (!capabilities.requiresLogin && capabilities.profile) {
99
+ add(checks, 'warn', `Flow ${flowName} declares requiresLogin: false but also declares capabilities.profile ("${capabilities.profile}"), which normally signals a login-backed session. Double-check which is right.`);
100
+ }
101
+ }
102
+ function defaultBrowserMode(capability) {
103
+ if (capability === true || capability === 'managed')
104
+ return 'managed';
105
+ if (capability === 'cdp')
106
+ return 'cdp';
107
+ return undefined;
108
+ }
109
+ function checkBrowserModes(flowName, capabilities, checks) {
110
+ const raw = capabilities?.supportedBrowserModes;
111
+ if (raw === undefined)
112
+ return;
113
+ const defaultMode = defaultBrowserMode(capabilities?.browser);
114
+ if (!defaultMode) {
115
+ add(checks, 'error', `Flow ${flowName} declares supportedBrowserModes but does not declare a browser mode.`);
116
+ return;
117
+ }
118
+ if (!Array.isArray(raw) || raw.length === 0) {
119
+ add(checks, 'error', `Flow ${flowName} declares supportedBrowserModes but it is not a non-empty array.`);
120
+ return;
121
+ }
122
+ const invalid = raw.filter((mode) => mode !== 'managed' && mode !== 'cdp');
123
+ if (invalid.length > 0) {
124
+ add(checks, 'error', `Flow ${flowName} declares unsupported browser mode(s): ${invalid.map(String).join(', ')}. Use only "managed" and "cdp".`);
125
+ return;
126
+ }
127
+ if (!raw.includes(defaultMode)) {
128
+ add(checks, 'error', `Flow ${flowName} declares browser default "${defaultMode}" but supportedBrowserModes does not include it.`);
129
+ }
130
+ }
131
+ /**
132
+ * Enforces that every flow's sector/service (top two levels of the catalog-browsing hierarchy,
133
+ * see `browse_catalog` in the relay) resolve against the canonical `taxonomy.json` registry —
134
+ * unlike login normalization, this applies to every flow, not just browser-driven ones, since
135
+ * classification should cover future non-browser flows too. Unknown values are blocking errors
136
+ * (not warnings) to prevent near-duplicate/near-synonymous sectors or services from accumulating
137
+ * once the catalog has hundreds or thousands of flows; declaring nothing yet is just a warning.
138
+ */
139
+ function checkTaxonomy(flowName, capabilities, checks, taxonomy) {
140
+ const category = capabilities?.category;
141
+ const service = capabilities?.service;
142
+ if (!category && !service) {
143
+ add(checks, 'warn', `Flow ${flowName} does not declare capabilities.category/service. It will be filed under "sin-categorizar" until classified.`);
144
+ return;
145
+ }
146
+ if (service && !category) {
147
+ add(checks, 'error', `Flow ${flowName} declares capabilities.service ("${service}") without capabilities.category.`);
148
+ return;
149
+ }
150
+ if (category && !isValidSector(taxonomy, category)) {
151
+ add(checks, 'error', `Flow ${flowName} declares capabilities.category "${category}", which is not in the canonical registry (taxonomy.json).`);
152
+ return;
153
+ }
154
+ if (!service) {
155
+ add(checks, 'warn', `Flow ${flowName} declares capabilities.category ("${category}") but no capabilities.service.`);
156
+ return;
157
+ }
158
+ if (category && !isValidService(taxonomy, category, service)) {
159
+ add(checks, 'error', `Flow ${flowName} declares capabilities.service "${service}", which is not registered under sector "${category}" in taxonomy.json.`);
160
+ }
161
+ }
162
+ async function checkPlaywrightInstall(checks) {
163
+ add(checks, 'ok', 'Playwright module is installed.');
164
+ const executablePath = chromium.executablePath();
165
+ if (await pathExists(executablePath)) {
166
+ add(checks, 'ok', `Chromium executable is available at ${executablePath}.`);
167
+ }
168
+ else {
169
+ add(checks, 'warn', `Chromium executable was not found at ${executablePath}. Run npx playwright install chromium if browser flows fail.`);
170
+ }
171
+ }
172
+ export async function runDoctor(projectRoot, options = {}) {
173
+ const root = resolve(projectRoot);
174
+ const paths = getWorkspacePaths(root);
175
+ const checks = [];
176
+ const version = options.nodeVersion ?? process.versions.node;
177
+ const major = nodeMajor(version);
178
+ if (!Number.isNaN(major) && major >= MIN_NODE_MAJOR) {
179
+ add(checks, 'ok', `Node ${version} satisfies >=${MIN_NODE_MAJOR}.`);
180
+ }
181
+ else {
182
+ add(checks, 'error', `Node ${version} is unsupported. WebFlow MCP requires Node ${MIN_NODE_MAJOR}+.`);
183
+ }
184
+ if (await isDirectory(root)) {
185
+ add(checks, 'ok', `Project root exists: ${root}`);
186
+ }
187
+ else {
188
+ add(checks, 'error', `Project root does not exist or is not a directory: ${root}`);
189
+ }
190
+ if (await isDirectory(paths.workspaceRoot)) {
191
+ add(checks, 'ok', `Workspace directory exists: ${paths.workspaceRoot}`);
192
+ }
193
+ else {
194
+ add(checks, 'error', `Workspace directory is missing. Run webflow init in ${root} or pass the correct projectRoot.`);
195
+ }
196
+ const invalidFlows = await checkFlowFiles(root, checks);
197
+ const { flows, diagnostics } = await discoverFlows(root);
198
+ diagnostics.forEach((diagnostic) => add(checks, 'warn', diagnostic));
199
+ if (flows.length > 0) {
200
+ add(checks, 'ok', `Discovered ${flows.length} exposed flow(s).`);
201
+ }
202
+ else if (await isDirectory(paths.flowsDir)) {
203
+ add(checks, 'warn', 'No exposed flows were discovered. Create one with webflow create flow <name>.');
204
+ }
205
+ const packageRoot = options.packageRoot ?? getPackageRoot(import.meta.url);
206
+ const builtCli = resolve(packageRoot, 'dist/src/cli/index.js');
207
+ if (await pathExists(builtCli)) {
208
+ add(checks, 'ok', `Build output is available: ${builtCli}`);
209
+ }
210
+ else {
211
+ add(checks, 'warn', `Build output is missing at ${builtCli}. Run npm run build before using the packaged CLI/MCP.`);
212
+ }
213
+ if (options.checkPlaywright !== false) {
214
+ await checkPlaywrightInstall(checks);
215
+ }
216
+ return {
217
+ projectRoot: root,
218
+ checks,
219
+ exposedFlows: flows.map((flow) => flow.name),
220
+ invalidFlows,
221
+ exitCode: checks.some((check) => check.level === 'error') ? 1 : 0
222
+ };
223
+ }
224
+ export function formatDoctorResult(result) {
225
+ const lines = ['WebFlow Doctor', `Project root: ${result.projectRoot}`, '', 'Checks:'];
226
+ for (const check of result.checks) {
227
+ lines.push(`[${check.level}] ${check.message}`);
228
+ }
229
+ lines.push('', 'Exposed flows:');
230
+ if (result.exposedFlows.length === 0) {
231
+ lines.push('- none');
232
+ }
233
+ else {
234
+ result.exposedFlows.forEach((flow) => lines.push(`- ${flow}`));
235
+ }
236
+ if (result.invalidFlows.length > 0) {
237
+ lines.push('', 'Invalid flows:');
238
+ result.invalidFlows.forEach((flow) => lines.push(`- ${flow}`));
239
+ }
240
+ return `${lines.join('\n')}\n`;
241
+ }
@@ -0,0 +1,4 @@
1
+ export declare function forkFlow(projectRoot: string, source: string, target: string): Promise<{
2
+ source: string;
3
+ target: string;
4
+ }>;
@@ -0,0 +1,19 @@
1
+ import { resolve } from 'node:path';
2
+ import { copyDir, pathExists } from '../../shared/fs.js';
3
+ import { WebFlowError } from '../../shared/errors.js';
4
+ import { getWorkspacePaths, sanitizeName } from '../../shared/paths.js';
5
+ export async function forkFlow(projectRoot, source, target) {
6
+ const paths = getWorkspacePaths(projectRoot);
7
+ const sourceName = sanitizeName(source);
8
+ const targetName = sanitizeName(target);
9
+ const sourceDir = resolve(paths.flowsDir, sourceName);
10
+ const targetDir = resolve(paths.flowsDir, targetName);
11
+ if (!(await pathExists(sourceDir))) {
12
+ throw new WebFlowError('FLOW_NOT_FOUND', `Source flow "${sourceName}" does not exist.`);
13
+ }
14
+ if (await pathExists(targetDir)) {
15
+ throw new WebFlowError('FLOW_ALREADY_EXISTS', `Target flow "${targetName}" already exists.`);
16
+ }
17
+ await copyDir(sourceDir, targetDir);
18
+ return { source: sourceName, target: targetName };
19
+ }
@@ -0,0 +1,4 @@
1
+ export declare function initWorkspace(projectRoot: string, metaUrl: string): Promise<{
2
+ workspaceRoot: string;
3
+ created: boolean;
4
+ }>;
@@ -0,0 +1,22 @@
1
+ import { resolve } from 'node:path';
2
+ import { copyDir, ensureDir, pathExists } from '../../shared/fs.js';
3
+ import { getPackageRoot, getWorkspacePaths } from '../../shared/paths.js';
4
+ export async function initWorkspace(projectRoot, metaUrl) {
5
+ const paths = getWorkspacePaths(projectRoot);
6
+ const packageRoot = getPackageRoot(metaUrl);
7
+ const templateFlows = resolve(packageRoot, '.webflow/flows/_template');
8
+ const templateSkills = resolve(packageRoot, '.webflow/skills');
9
+ const flowTarget = resolve(paths.flowsDir, '_template');
10
+ const alreadyExists = await pathExists(paths.workspaceRoot);
11
+ await ensureDir(paths.flowsDir);
12
+ await ensureDir(paths.skillsDir);
13
+ await ensureDir(paths.profilesDir);
14
+ await ensureDir(paths.cacheDir);
15
+ if (!(await pathExists(flowTarget))) {
16
+ await copyDir(templateFlows, flowTarget);
17
+ }
18
+ if (!(await pathExists(resolve(paths.skillsDir, 'README.md')))) {
19
+ await copyDir(templateSkills, paths.skillsDir);
20
+ }
21
+ return { workspaceRoot: paths.workspaceRoot, created: !alreadyExists };
22
+ }
@@ -0,0 +1,26 @@
1
+ export declare const DEFAULT_APP_URL = "https://app.webflowmcp.es";
2
+ export interface LoginResult {
3
+ appUrl: string;
4
+ relayUrl: string;
5
+ workerId: string;
6
+ verificationUriComplete: string;
7
+ browserOpened: boolean;
8
+ }
9
+ export interface LoginStatus {
10
+ loggedIn: boolean;
11
+ appUrl?: string;
12
+ relayUrl?: string;
13
+ workerId?: string;
14
+ credentialId?: string;
15
+ }
16
+ export interface LoginOptions {
17
+ fetch?: typeof fetch;
18
+ openBrowser?: (url: string) => Promise<boolean>;
19
+ sleep?: (milliseconds: number) => Promise<void>;
20
+ timeoutMs?: number;
21
+ onVerification?: (verificationUriComplete: string, browserOpened: boolean) => void;
22
+ }
23
+ export declare function openUrlInBrowser(url: string, platform?: NodeJS.Platform): Promise<boolean>;
24
+ export declare function login(appUrl?: string, options?: LoginOptions): Promise<LoginResult>;
25
+ export declare function logout(): Promise<boolean>;
26
+ export declare function loginStatus(): Promise<LoginStatus>;
@@ -0,0 +1,145 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { hostname } from 'node:os';
4
+ import { assertSecureRelayUrl, clearCredentials, loadCredentials, loadOrCreateDeviceIdentity, saveCredentials } from '../../shared/credentials.js';
5
+ import { WebFlowError } from '../../shared/errors.js';
6
+ export const DEFAULT_APP_URL = 'https://app.webflowmcp.es';
7
+ const DEFAULT_POLL_INTERVAL_SECONDS = 5;
8
+ const DEFAULT_PAIRING_TIMEOUT_MS = 10 * 60_000;
9
+ function normalizeAppUrl(appUrl) {
10
+ let parsed;
11
+ try {
12
+ parsed = new URL(appUrl);
13
+ }
14
+ catch {
15
+ throw new WebFlowError('INVALID_COMMAND', `Invalid app URL: ${appUrl}`);
16
+ }
17
+ const isLoopback = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1' || parsed.hostname === '[::1]';
18
+ if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && isLoopback)) {
19
+ throw new WebFlowError('INVALID_COMMAND', 'The app URL must use https. Plain http is allowed only for local development on loopback.');
20
+ }
21
+ return parsed.toString().replace(/\/$/, '');
22
+ }
23
+ async function postJson(fetchImpl, url, body) {
24
+ let response;
25
+ try {
26
+ response = await fetchImpl(url, {
27
+ method: 'POST',
28
+ headers: { 'content-type': 'application/json' },
29
+ body: JSON.stringify(body)
30
+ });
31
+ }
32
+ catch (error) {
33
+ throw new WebFlowError('PAIRING_FAILED', `Could not reach ${url}: ${error instanceof Error ? error.message : String(error)}`);
34
+ }
35
+ if (!response.ok) {
36
+ const detail = await response.text().catch(() => '');
37
+ throw new WebFlowError('PAIRING_FAILED', `Pairing endpoint returned HTTP ${response.status}${detail ? `: ${detail}` : '.'}`);
38
+ }
39
+ try {
40
+ return await response.json();
41
+ }
42
+ catch {
43
+ throw new WebFlowError('PAIRING_FAILED', 'Pairing endpoint returned invalid JSON.');
44
+ }
45
+ }
46
+ export function openUrlInBrowser(url, platform = process.platform) {
47
+ let parsed;
48
+ try {
49
+ parsed = new URL(url);
50
+ }
51
+ catch {
52
+ return Promise.resolve(false);
53
+ }
54
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')
55
+ return Promise.resolve(false);
56
+ const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'rundll32' : 'xdg-open';
57
+ const args = platform === 'win32' ? ['url.dll,FileProtocolHandler', parsed.toString()] : [parsed.toString()];
58
+ return new Promise((resolveOpened) => {
59
+ let settled = false;
60
+ const child = spawn(command, args, { detached: true, stdio: 'ignore', shell: false });
61
+ const finish = (opened) => {
62
+ if (settled)
63
+ return;
64
+ settled = true;
65
+ resolveOpened(opened);
66
+ };
67
+ child.once('error', () => finish(false));
68
+ child.once('spawn', () => {
69
+ child.unref();
70
+ finish(true);
71
+ });
72
+ });
73
+ }
74
+ export async function login(appUrl = DEFAULT_APP_URL, options = {}) {
75
+ const normalizedAppUrl = normalizeAppUrl(appUrl);
76
+ const fetchImpl = options.fetch ?? fetch;
77
+ const sleep = options.sleep ?? ((milliseconds) => new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds)));
78
+ const identity = await loadOrCreateDeviceIdentity();
79
+ const credentialId = randomUUID();
80
+ const secret = randomBytes(32).toString('hex');
81
+ const secretHash = createHash('sha256').update(secret).digest('hex');
82
+ const start = await postJson(fetchImpl, `${normalizedAppUrl}/api/device-pairing/start`, {
83
+ deviceId: identity.deviceId,
84
+ deviceLabel: hostname(),
85
+ credentialId,
86
+ secretHash
87
+ });
88
+ if (!start.deviceCode || !start.verificationUriComplete || !start.relayUrl) {
89
+ throw new WebFlowError('PAIRING_FAILED', 'Pairing start response is missing deviceCode, verificationUriComplete, or relayUrl.');
90
+ }
91
+ assertSecureRelayUrl(start.relayUrl);
92
+ const browserOpened = await (options.openBrowser ?? openUrlInBrowser)(start.verificationUriComplete);
93
+ options.onVerification?.(start.verificationUriComplete, browserOpened);
94
+ const timeoutMs = Math.min(options.timeoutMs ?? DEFAULT_PAIRING_TIMEOUT_MS, (start.expiresIn ?? DEFAULT_PAIRING_TIMEOUT_MS / 1000) * 1000);
95
+ const deadline = Date.now() + timeoutMs;
96
+ let intervalSeconds = Math.max(1, start.interval ?? DEFAULT_POLL_INTERVAL_SECONDS);
97
+ while (Date.now() < deadline) {
98
+ await sleep(intervalSeconds * 1000);
99
+ if (Date.now() >= deadline)
100
+ break;
101
+ const status = await postJson(fetchImpl, `${normalizedAppUrl}/api/device-pairing/status`, { deviceCode: start.deviceCode });
102
+ if (status.status === 'pending') {
103
+ if (status.interval !== undefined)
104
+ intervalSeconds = Math.max(1, status.interval);
105
+ continue;
106
+ }
107
+ if (status.status === 'slow_down') {
108
+ intervalSeconds = Math.max(intervalSeconds + 5, status.interval ?? 0);
109
+ continue;
110
+ }
111
+ if (status.status === 'denied' || status.status === 'expired') {
112
+ throw new WebFlowError('PAIRING_FAILED', `Device pairing was ${status.status}. Run "webflow login" to try again.`);
113
+ }
114
+ if (status.status === 'approved') {
115
+ const credentials = {
116
+ version: 2,
117
+ appUrl: normalizedAppUrl,
118
+ relayUrl: start.relayUrl,
119
+ deviceId: identity.deviceId,
120
+ workerId: identity.workerId,
121
+ credentialId,
122
+ secret
123
+ };
124
+ await saveCredentials(credentials);
125
+ return { appUrl: normalizedAppUrl, relayUrl: start.relayUrl, workerId: identity.workerId, verificationUriComplete: start.verificationUriComplete, browserOpened };
126
+ }
127
+ throw new WebFlowError('PAIRING_FAILED', 'Pairing status response was not recognized.');
128
+ }
129
+ throw new WebFlowError('PAIRING_FAILED', 'Timed out waiting for device pairing approval. Run "webflow login" to try again.');
130
+ }
131
+ export async function logout() {
132
+ return clearCredentials();
133
+ }
134
+ export async function loginStatus() {
135
+ const credentials = await loadCredentials();
136
+ if (!credentials)
137
+ return { loggedIn: false };
138
+ return {
139
+ loggedIn: true,
140
+ appUrl: credentials.appUrl,
141
+ relayUrl: credentials.relayUrl,
142
+ workerId: credentials.workerId,
143
+ credentialId: credentials.credentialId
144
+ };
145
+ }
@@ -0,0 +1,40 @@
1
+ import { type ProfileStatusInfo } from '../../profiles/profile-state.js';
2
+ export interface FlowProfileStatus extends ProfileStatusInfo {
3
+ flow: string;
4
+ requiresLogin: boolean;
5
+ }
6
+ export interface LoginProfileOptions {
7
+ startUrl?: string;
8
+ managed?: boolean;
9
+ chromePath?: string;
10
+ }
11
+ export interface RefreshInstanceResult {
12
+ profile: string;
13
+ instance: string;
14
+ baseDir: string;
15
+ instanceDir: string;
16
+ }
17
+ export declare function listProfiles(): Promise<ProfileStatusInfo[]>;
18
+ export declare function statusProfile(profileName: string, options?: {
19
+ loginUrl?: string;
20
+ }): Promise<ProfileStatusInfo>;
21
+ export declare function statusFlowProfile(projectRoot: string, flowName: string): Promise<FlowProfileStatus>;
22
+ /**
23
+ * Fire-and-forget variant of loginProfile for the MCP tool path (tool-registry.ts):
24
+ * opens the browser and returns as soon as it's up, instead of blocking until the
25
+ * human closes the window (which the CLI's blocking loginProfile does, fine for a
26
+ * terminal command but far too long for a single MCP tool call). Always uses the
27
+ * managed launcher since it's the only path with a clean "the window is now open"
28
+ * checkpoint (launchChromeForLogin's raw spawn only resolves on exit).
29
+ */
30
+ export declare function startProfileLogin(profileName: string, options?: {
31
+ startUrl?: string;
32
+ }): Promise<ProfileStatusInfo>;
33
+ export declare function startFlowLogin(projectRoot: string, flowName: string): Promise<FlowProfileStatus>;
34
+ export declare function loginProfile(profileName: string, options?: LoginProfileOptions): Promise<ProfileStatusInfo>;
35
+ export declare function resetProfile(profileName: string): Promise<{
36
+ profile: string;
37
+ removed: string[];
38
+ }>;
39
+ export declare function refreshInstanceProfile(profileName: string, instance: string): Promise<RefreshInstanceResult>;
40
+ export declare function formatProfileStatus(status: ProfileStatusInfo | FlowProfileStatus): string;
@@ -0,0 +1,142 @@
1
+ import { readdir, rm } from 'node:fs/promises';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { discoverFlows, loadFlowModule } from '../../runner/flow-loader.js';
4
+ import { launchBrowserSession } from '../../runner/browser-session.js';
5
+ import { launchChromeForLogin } from '../../runner/chrome.js';
6
+ import { ProfileManager } from '../../profiles/profile-manager.js';
7
+ import { getProfileStatus, markInteractiveSetup } from '../../profiles/profile-state.js';
8
+ import { pathExists } from '../../shared/fs.js';
9
+ import { getProfilesRoot } from '../../shared/paths.js';
10
+ import { WebFlowError } from '../../shared/errors.js';
11
+ async function resolveFlowProfile(projectRoot, flowName) {
12
+ const { flows } = await discoverFlows(resolve(projectRoot));
13
+ const definition = flows.find((flow) => flow.name === flowName);
14
+ if (!definition) {
15
+ throw new WebFlowError('UNKNOWN_FLOW', `Flow "${flowName}" not found in .webflow/flows.`);
16
+ }
17
+ const module = await loadFlowModule(definition.entryFile);
18
+ return {
19
+ profileName: module.capabilities?.profile ?? `${flowName}-default`,
20
+ startUrl: module.capabilities?.startUrl,
21
+ requiresLogin: module.capabilities?.requiresLogin ?? false
22
+ };
23
+ }
24
+ export async function listProfiles() {
25
+ const profilesRoot = getProfilesRoot();
26
+ let entries;
27
+ try {
28
+ entries = await readdir(profilesRoot, { withFileTypes: true });
29
+ }
30
+ catch {
31
+ entries = [];
32
+ }
33
+ const manager = new ProfileManager(undefined, { profilesDir: profilesRoot });
34
+ const names = new Set(entries
35
+ .filter((entry) => entry.isDirectory() && entry.name !== '.webflow-state' && !entry.name.includes('__'))
36
+ .map((entry) => entry.name));
37
+ try {
38
+ const stateEntries = await readdir(resolve(profilesRoot, '.webflow-state'), { withFileTypes: true });
39
+ for (const entry of stateEntries) {
40
+ if (entry.isFile() && entry.name.endsWith('.json')) {
41
+ names.add(entry.name.slice(0, -'.json'.length));
42
+ }
43
+ }
44
+ }
45
+ catch {
46
+ // No state dir yet.
47
+ }
48
+ const statuses = [];
49
+ for (const name of [...names].sort((left, right) => left.localeCompare(right))) {
50
+ statuses.push(await getProfileStatus(await manager.resolve(name)));
51
+ }
52
+ return statuses;
53
+ }
54
+ export async function statusProfile(profileName, options = {}) {
55
+ const manager = new ProfileManager();
56
+ const profile = await manager.resolve(profileName);
57
+ return getProfileStatus(profile, { loginUrl: options.loginUrl });
58
+ }
59
+ export async function statusFlowProfile(projectRoot, flowName) {
60
+ const meta = await resolveFlowProfile(projectRoot, flowName);
61
+ const status = await statusProfile(meta.profileName, { loginUrl: meta.startUrl });
62
+ return { ...status, flow: flowName, requiresLogin: meta.requiresLogin };
63
+ }
64
+ /**
65
+ * Fire-and-forget variant of loginProfile for the MCP tool path (tool-registry.ts):
66
+ * opens the browser and returns as soon as it's up, instead of blocking until the
67
+ * human closes the window (which the CLI's blocking loginProfile does, fine for a
68
+ * terminal command but far too long for a single MCP tool call). Always uses the
69
+ * managed launcher since it's the only path with a clean "the window is now open"
70
+ * checkpoint (launchChromeForLogin's raw spawn only resolves on exit).
71
+ */
72
+ export async function startProfileLogin(profileName, options = {}) {
73
+ const manager = new ProfileManager();
74
+ const profile = await manager.ensure(profileName);
75
+ const session = await launchBrowserSession({ userDataDir: profile.dir, headless: false, startUrl: options.startUrl });
76
+ session.context.once('close', () => {
77
+ void markInteractiveSetup(profile);
78
+ });
79
+ return getProfileStatus(profile, { loginUrl: options.startUrl });
80
+ }
81
+ export async function startFlowLogin(projectRoot, flowName) {
82
+ const meta = await resolveFlowProfile(projectRoot, flowName);
83
+ const status = await startProfileLogin(meta.profileName, { startUrl: meta.startUrl });
84
+ return { ...status, flow: flowName, requiresLogin: meta.requiresLogin };
85
+ }
86
+ export async function loginProfile(profileName, options = {}) {
87
+ const manager = new ProfileManager();
88
+ const profile = await manager.ensure(profileName);
89
+ if (options.managed) {
90
+ const session = await launchBrowserSession({ userDataDir: profile.dir, headless: false, startUrl: options.startUrl });
91
+ await new Promise((done) => {
92
+ session.context.once('close', () => done());
93
+ });
94
+ }
95
+ else {
96
+ await launchChromeForLogin({ userDataDir: profile.dir, startUrl: options.startUrl, executablePath: options.chromePath });
97
+ }
98
+ await markInteractiveSetup(profile);
99
+ return getProfileStatus(profile, { loginUrl: options.startUrl });
100
+ }
101
+ export async function resetProfile(profileName) {
102
+ const manager = new ProfileManager();
103
+ const profile = await manager.resolve(profileName);
104
+ const root = dirname(profile.baseDir);
105
+ const removed = [];
106
+ let entries;
107
+ try {
108
+ entries = await readdir(root, { withFileTypes: true });
109
+ }
110
+ catch {
111
+ entries = [];
112
+ }
113
+ for (const entry of entries) {
114
+ if (!entry.isDirectory())
115
+ continue;
116
+ if (entry.name === profile.id || entry.name.startsWith(`${profile.id}__`)) {
117
+ const dir = resolve(root, entry.name);
118
+ await rm(dir, { recursive: true, force: true });
119
+ removed.push(dir);
120
+ }
121
+ }
122
+ const status = await getProfileStatus(profile);
123
+ await rm(status.stateFile, { force: true });
124
+ return { profile: profile.id, removed };
125
+ }
126
+ export async function refreshInstanceProfile(profileName, instance) {
127
+ const manager = new ProfileManager();
128
+ const base = await manager.resolve(profileName);
129
+ if (!(await pathExists(base.baseDir))) {
130
+ throw new WebFlowError('PROFILE_NOT_FOUND', `Base profile "${base.id}" does not exist. Run webflow profiles login ${base.id} first.`, {
131
+ profile: base.id
132
+ });
133
+ }
134
+ const target = await manager.resolve(profileName, undefined, { instance });
135
+ await rm(target.dir, { recursive: true, force: true });
136
+ const refreshed = await manager.ensure(profileName, undefined, { instance });
137
+ return { profile: refreshed.id, instance: refreshed.instance ?? instance, baseDir: refreshed.baseDir, instanceDir: refreshed.dir };
138
+ }
139
+ export function formatProfileStatus(status) {
140
+ const flow = 'flow' in status ? `Flow: ${status.flow}\nRequires login: ${status.requiresLogin ? 'yes' : 'no'}\n` : '';
141
+ return `${flow}Profile: ${status.profileId}\nStatus: ${status.status}\nProbably valid: ${status.probablyValid}\nDirectory: ${status.profileDir}\nSetup command: ${status.setupCommand}`;
142
+ }
@@ -0,0 +1,10 @@
1
+ import type { FlowExecutionResult } from '../../runner/types.js';
2
+ import type { BrowserVisibility } from '../../config/webflow-config.js';
3
+ import type { BrowserMode } from '../../runner/types.js';
4
+ export interface RunFlowOptions {
5
+ headed?: boolean;
6
+ profileName?: string;
7
+ browserMode?: BrowserMode;
8
+ browserVisibility?: BrowserVisibility;
9
+ }
10
+ export declare function runFlow(projectRoot: string, flowName: string, input: unknown, options?: RunFlowOptions): Promise<FlowExecutionResult>;