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,13 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { discoverFlows } from '../../runner/flow-loader.js';
|
|
3
|
+
import { FlowRunner } from '../../runner/flow-runner.js';
|
|
4
|
+
import { WebFlowError } from '../../shared/errors.js';
|
|
5
|
+
export async function runFlow(projectRoot, flowName, input, options = {}) {
|
|
6
|
+
const { flows } = await discoverFlows(resolve(projectRoot));
|
|
7
|
+
const definition = flows.find((flow) => flow.name === flowName);
|
|
8
|
+
if (!definition) {
|
|
9
|
+
throw new WebFlowError('UNKNOWN_FLOW', `Flow "${flowName}" not found in .webflow/flows.`);
|
|
10
|
+
}
|
|
11
|
+
const runner = new FlowRunner(projectRoot);
|
|
12
|
+
return runner.run(definition, input, options);
|
|
13
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface SetupFlowOptions {
|
|
2
|
+
startUrl?: string;
|
|
3
|
+
profileName?: string;
|
|
4
|
+
/** Use a Playwright-managed headed window instead of plain real Chrome. */
|
|
5
|
+
managed?: boolean;
|
|
6
|
+
/** Explicit Chrome executable path (real-Chrome login only). */
|
|
7
|
+
chromePath?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function setupFlow(projectRoot: string, flowName: string, options?: SetupFlowOptions): Promise<{
|
|
10
|
+
profileDir: string;
|
|
11
|
+
}>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { discoverFlows, loadFlowModule } from '../../runner/flow-loader.js';
|
|
3
|
+
import { launchBrowserSession } from '../../runner/browser-session.js';
|
|
4
|
+
import { launchChromeForLogin } from '../../runner/chrome.js';
|
|
5
|
+
import { ProfileManager } from '../../profiles/profile-manager.js';
|
|
6
|
+
import { markInteractiveSetup } from '../../profiles/profile-state.js';
|
|
7
|
+
import { WebFlowError } from '../../shared/errors.js';
|
|
8
|
+
export async function setupFlow(projectRoot, flowName, options = {}) {
|
|
9
|
+
const { flows } = await discoverFlows(resolve(projectRoot));
|
|
10
|
+
const definition = flows.find((flow) => flow.name === flowName);
|
|
11
|
+
if (!definition) {
|
|
12
|
+
throw new WebFlowError('UNKNOWN_FLOW', `Flow "${flowName}" not found in .webflow/flows.`);
|
|
13
|
+
}
|
|
14
|
+
const module = await loadFlowModule(definition.entryFile);
|
|
15
|
+
if (!module.capabilities?.browser) {
|
|
16
|
+
throw new WebFlowError('FLOW_NOT_BROWSER_AWARE', `Flow "${flowName}" does not declare a browser capability. Add export const capabilities = { browser: true } to its index.ts.`);
|
|
17
|
+
}
|
|
18
|
+
const profileManager = new ProfileManager(projectRoot);
|
|
19
|
+
const profile = await profileManager.ensure(options.profileName ?? module.capabilities.profile, flowName);
|
|
20
|
+
const startUrl = options.startUrl ?? module.capabilities.startUrl;
|
|
21
|
+
console.log(`\nProfile (global, shared across repos): ${profile.dir}`);
|
|
22
|
+
if (options.managed) {
|
|
23
|
+
// Playwright-managed headed window. Some providers block their OAuth login here.
|
|
24
|
+
const session = await launchBrowserSession({
|
|
25
|
+
userDataDir: profile.dir,
|
|
26
|
+
headless: false,
|
|
27
|
+
startUrl,
|
|
28
|
+
channel: module.capabilities.channel,
|
|
29
|
+
stealth: module.capabilities.stealth
|
|
30
|
+
});
|
|
31
|
+
console.log('Managed browser opened for manual setup (login, accept cookies, etc.).');
|
|
32
|
+
console.log('Close the browser window when you are done — your session will persist for this flow.\n');
|
|
33
|
+
await new Promise((done) => {
|
|
34
|
+
session.context.once('close', () => done());
|
|
35
|
+
});
|
|
36
|
+
await markInteractiveSetup(profile, flowName);
|
|
37
|
+
return { profileDir: profile.dir };
|
|
38
|
+
}
|
|
39
|
+
// Default: a plain real Chrome window with no automation flags, so strict providers
|
|
40
|
+
// (Google, Adobe) accept the login. The session persists in the dedicated profile dir.
|
|
41
|
+
console.log('Opening a real Chrome window for one-time login (no automation flags).');
|
|
42
|
+
console.log('Log in and accept any prompts, then CLOSE the Chrome window to finish.\n');
|
|
43
|
+
await launchChromeForLogin({ userDataDir: profile.dir, startUrl, executablePath: options.chromePath });
|
|
44
|
+
await markInteractiveSetup(profile, flowName);
|
|
45
|
+
return { profileDir: profile.dir };
|
|
46
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { WorkerConnection } from '../../worker/connection.js';
|
|
2
|
+
import type { BrowserVisibility } from '../../config/webflow-config.js';
|
|
3
|
+
import type { BrowserMode } from '../../runner/types.js';
|
|
4
|
+
import type { FlowTrustPolicyEntry } from '../../security/flow-release.js';
|
|
5
|
+
export interface WorkerStartOptions {
|
|
6
|
+
relayUrl: string;
|
|
7
|
+
credential: string;
|
|
8
|
+
workerId: string;
|
|
9
|
+
/** Watch mode: run this worker's flows with a visible Chrome window instead of headless. */
|
|
10
|
+
headed?: boolean;
|
|
11
|
+
/** Process-local default for this worker run; does not persist to ~/.webflow/config.json. */
|
|
12
|
+
browserMode?: BrowserMode;
|
|
13
|
+
/** Process-local default for this worker run; does not persist to ~/.webflow/config.json. */
|
|
14
|
+
browserVisibility?: BrowserVisibility;
|
|
15
|
+
trustPolicy?: readonly FlowTrustPolicyEntry[];
|
|
16
|
+
onLog?: (message: string) => void;
|
|
17
|
+
}
|
|
18
|
+
export interface WorkerHandle {
|
|
19
|
+
connection: WorkerConnection;
|
|
20
|
+
workerId: string;
|
|
21
|
+
stop(): void;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Wires WorkerService (local flow execution) to WorkerConnection (outbound socket
|
|
25
|
+
* to the relay) and starts connecting. Returns a handle immediately; the connection
|
|
26
|
+
* runs in the background until `stop()` is called (RFC 0001, Fase 1). The CLI
|
|
27
|
+
* dispatcher is the only caller that blocks the process on this indefinitely.
|
|
28
|
+
*/
|
|
29
|
+
export declare function startWorker(projectRoot: string, options: WorkerStartOptions): WorkerHandle;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { WebFlowError } from '../../shared/errors.js';
|
|
2
|
+
import { WorkerConnection } from '../../worker/connection.js';
|
|
3
|
+
import { WorkerService } from '../../worker/worker-service.js';
|
|
4
|
+
import { loadFlowTrustPolicy } from '../../security/trusted-flow-keys.js';
|
|
5
|
+
/**
|
|
6
|
+
* Wires WorkerService (local flow execution) to WorkerConnection (outbound socket
|
|
7
|
+
* to the relay) and starts connecting. Returns a handle immediately; the connection
|
|
8
|
+
* runs in the background until `stop()` is called (RFC 0001, Fase 1). The CLI
|
|
9
|
+
* dispatcher is the only caller that blocks the process on this indefinitely.
|
|
10
|
+
*/
|
|
11
|
+
export function startWorker(projectRoot, options) {
|
|
12
|
+
if (!options.relayUrl) {
|
|
13
|
+
throw new WebFlowError('INVALID_COMMAND', 'webflow worker start requires --relay <wss://...>');
|
|
14
|
+
}
|
|
15
|
+
if (!options.credential) {
|
|
16
|
+
throw new WebFlowError('INVALID_COMMAND', 'webflow worker start requires saved v2 credentials. Run "webflow login" to pair this device.');
|
|
17
|
+
}
|
|
18
|
+
const workerId = options.workerId;
|
|
19
|
+
const service = new WorkerService(projectRoot, { headed: options.headed, browserMode: options.browserMode, browserVisibility: options.browserVisibility });
|
|
20
|
+
const connection = new WorkerConnection({
|
|
21
|
+
relayUrl: options.relayUrl,
|
|
22
|
+
credential: options.credential,
|
|
23
|
+
workerId,
|
|
24
|
+
service,
|
|
25
|
+
trustPolicy: options.trustPolicy ?? loadFlowTrustPolicy(options.relayUrl),
|
|
26
|
+
onLog: options.onLog
|
|
27
|
+
});
|
|
28
|
+
connection.start();
|
|
29
|
+
return { connection, workerId, stop: () => connection.stop() };
|
|
30
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { createFlow } from './commands/create-flow.js';
|
|
6
|
+
import { forkFlow } from './commands/fork.js';
|
|
7
|
+
import { initWorkspace } from './commands/init.js';
|
|
8
|
+
import { setupFlow } from './commands/setup.js';
|
|
9
|
+
import { runFlow } from './commands/run.js';
|
|
10
|
+
import { formatConfig, getConfig, setConfigPreference } from './commands/config.js';
|
|
11
|
+
import { formatDoctorResult, runDoctor } from './commands/doctor.js';
|
|
12
|
+
import { startWorker } from './commands/worker.js';
|
|
13
|
+
import { approveFlow, formatApprovalState, revokeFlow } from './commands/approvals.js';
|
|
14
|
+
import { login, loginStatus, logout } from './commands/login.js';
|
|
15
|
+
import { formatProfileStatus, listProfiles, loginProfile, refreshInstanceProfile, resetProfile, statusFlowProfile, statusProfile } from './commands/profiles.js';
|
|
16
|
+
import { getWireCredential, loadCredentials } from '../shared/credentials.js';
|
|
17
|
+
import { toErrorMessage, WebFlowError } from '../shared/errors.js';
|
|
18
|
+
import { assertSupportedNode, isAuthorEnvironment } from '../shared/paths.js';
|
|
19
|
+
/**
|
|
20
|
+
* `init`/`create flow`/`fork`/`doctor` are flow-AUTHORING commands (scaffolding and
|
|
21
|
+
* diagnosing flows for the catalog) — not something an end user running automations
|
|
22
|
+
* should ever see, let alone reach. They only appear in the usage text (and only
|
|
23
|
+
* dispatch at all, see runCli()) inside the maintainer's own git checkout; an
|
|
24
|
+
* installed package never has them, see isAuthorEnvironment().
|
|
25
|
+
*/
|
|
26
|
+
function printUsage(authorMode) {
|
|
27
|
+
const authorLines = authorMode
|
|
28
|
+
? ` webflow init
|
|
29
|
+
webflow create flow <name>
|
|
30
|
+
webflow fork <source> <target>
|
|
31
|
+
webflow doctor [projectRoot]
|
|
32
|
+
`
|
|
33
|
+
: '';
|
|
34
|
+
console.log(`Usage:
|
|
35
|
+
${authorLines} webflow config get [--json]
|
|
36
|
+
webflow config browser-mode <default|flow> ...
|
|
37
|
+
webflow config browser-visibility <default|flow> ...
|
|
38
|
+
webflow setup <flow> [--profile <name>] [--managed] [--url <url>] [--chrome-path <path>]
|
|
39
|
+
webflow profiles list [--json]
|
|
40
|
+
webflow profiles status <profile> [--json]
|
|
41
|
+
webflow profiles status --flow <flow> [--json]
|
|
42
|
+
webflow profiles login <profile> [--url <url>] [--managed] [--chrome-path <path>]
|
|
43
|
+
webflow profiles reset <profile> --yes
|
|
44
|
+
webflow profiles refresh-instance <profile> --instance <id> --yes
|
|
45
|
+
webflow run <flow> [--headed] [--browser-mode <managed|cdp>] [--browser-visibility <visible|hidden>] [--profile <name>] [--input '<json>']
|
|
46
|
+
webflow login [--app <https://app.webflowmcp.es>]
|
|
47
|
+
webflow login --status
|
|
48
|
+
webflow logout
|
|
49
|
+
webflow approvals list [--json]
|
|
50
|
+
webflow approvals approve <flow> [--trust-domain <domain>]
|
|
51
|
+
webflow approvals revoke <flow> [--trust-domain <domain>]
|
|
52
|
+
webflow worker start [--headed] [--browser-mode <managed|cdp>] [--browser-visibility <visible|hidden>]
|
|
53
|
+
(relay, stable worker identity, and credential are loaded from "webflow login")`);
|
|
54
|
+
}
|
|
55
|
+
function parseBrowserMode(value) {
|
|
56
|
+
if (!value)
|
|
57
|
+
return undefined;
|
|
58
|
+
if (value === 'managed' || value === 'cdp')
|
|
59
|
+
return value;
|
|
60
|
+
throw new WebFlowError('INVALID_COMMAND', '--browser-mode must be "managed" or "cdp".');
|
|
61
|
+
}
|
|
62
|
+
function parseBrowserVisibility(value) {
|
|
63
|
+
if (!value)
|
|
64
|
+
return undefined;
|
|
65
|
+
if (value === 'visible' || value === 'hidden')
|
|
66
|
+
return value;
|
|
67
|
+
throw new WebFlowError('INVALID_COMMAND', '--browser-visibility must be "visible" or "hidden".');
|
|
68
|
+
}
|
|
69
|
+
function getFlagValue(args, flag) {
|
|
70
|
+
const index = args.indexOf(flag);
|
|
71
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
72
|
+
}
|
|
73
|
+
export async function runCli(argv = process.argv.slice(2), projectRoot = process.cwd(), nodeVersion = process.versions.node) {
|
|
74
|
+
assertSupportedNode(nodeVersion);
|
|
75
|
+
const [command, subcommand, ...rest] = argv;
|
|
76
|
+
const authorMode = isAuthorEnvironment(import.meta.url);
|
|
77
|
+
if (!command) {
|
|
78
|
+
printUsage(authorMode);
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
switch (command) {
|
|
82
|
+
case 'init': {
|
|
83
|
+
if (!authorMode)
|
|
84
|
+
throw new WebFlowError('INVALID_COMMAND', `Unknown command: ${command}`);
|
|
85
|
+
const result = await initWorkspace(resolve(projectRoot), import.meta.url);
|
|
86
|
+
console.log(result.created ? `Initialized ${result.workspaceRoot}` : `Workspace already initialized at ${result.workspaceRoot}`);
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
case 'create': {
|
|
90
|
+
if (!authorMode)
|
|
91
|
+
throw new WebFlowError('INVALID_COMMAND', `Unknown command: ${command}`);
|
|
92
|
+
if (subcommand !== 'flow' || rest.length !== 1) {
|
|
93
|
+
throw new WebFlowError('INVALID_COMMAND', 'Usage: webflow create flow <name>');
|
|
94
|
+
}
|
|
95
|
+
const result = await createFlow(resolve(projectRoot), rest[0]);
|
|
96
|
+
console.log(`Created flow ${result.name} at ${result.dir}`);
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
case 'fork': {
|
|
100
|
+
if (!authorMode)
|
|
101
|
+
throw new WebFlowError('INVALID_COMMAND', `Unknown command: ${command}`);
|
|
102
|
+
if (!subcommand || rest.length !== 1) {
|
|
103
|
+
throw new WebFlowError('INVALID_COMMAND', 'Usage: webflow fork <source> <target>');
|
|
104
|
+
}
|
|
105
|
+
const result = await forkFlow(resolve(projectRoot), subcommand, rest[0]);
|
|
106
|
+
console.log(`Forked flow ${result.source} -> ${result.target}`);
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
case 'config': {
|
|
110
|
+
if (!subcommand) {
|
|
111
|
+
throw new WebFlowError('INVALID_COMMAND', 'Usage: webflow config <get|browser-mode|browser-visibility> ...');
|
|
112
|
+
}
|
|
113
|
+
const json = rest.includes('--json');
|
|
114
|
+
const config = subcommand === 'get'
|
|
115
|
+
? await getConfig()
|
|
116
|
+
: await setConfigPreference(subcommand, rest[0], rest[1], rest[2]);
|
|
117
|
+
console.log(json ? JSON.stringify(config, null, 2) : formatConfig(config));
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
case 'setup': {
|
|
121
|
+
if (!subcommand) {
|
|
122
|
+
throw new WebFlowError('INVALID_COMMAND', 'Usage: webflow setup <flow> [--profile <name>]');
|
|
123
|
+
}
|
|
124
|
+
const profileName = getFlagValue(rest, '--profile');
|
|
125
|
+
const startUrl = getFlagValue(rest, '--url');
|
|
126
|
+
const chromePath = getFlagValue(rest, '--chrome-path');
|
|
127
|
+
const managed = rest.includes('--managed');
|
|
128
|
+
const result = await setupFlow(resolve(projectRoot), subcommand, { profileName, startUrl, chromePath, managed });
|
|
129
|
+
console.log(`Setup complete. Profile saved at ${result.profileDir}`);
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
case 'profiles': {
|
|
133
|
+
if (!subcommand) {
|
|
134
|
+
throw new WebFlowError('INVALID_COMMAND', 'Usage: webflow profiles <list|status|login|reset|refresh-instance>');
|
|
135
|
+
}
|
|
136
|
+
const json = rest.includes('--json');
|
|
137
|
+
const print = (value, text) => {
|
|
138
|
+
console.log(json ? JSON.stringify(value, null, 2) : text ?? JSON.stringify(value, null, 2));
|
|
139
|
+
};
|
|
140
|
+
switch (subcommand) {
|
|
141
|
+
case 'list': {
|
|
142
|
+
const profiles = await listProfiles();
|
|
143
|
+
print(profiles, profiles.length ? profiles.map(formatProfileStatus).join('\n\n') : 'No profiles found.');
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
case 'status': {
|
|
147
|
+
const flowIndex = rest.indexOf('--flow');
|
|
148
|
+
if (flowIndex >= 0) {
|
|
149
|
+
const flowName = rest[flowIndex + 1];
|
|
150
|
+
if (!flowName)
|
|
151
|
+
throw new WebFlowError('INVALID_COMMAND', 'Usage: webflow profiles status --flow <flow> [--json]');
|
|
152
|
+
const status = await statusFlowProfile(resolve(projectRoot), flowName);
|
|
153
|
+
print(status, formatProfileStatus(status));
|
|
154
|
+
return status.requiresLogin && status.probablyValid === false ? 1 : 0;
|
|
155
|
+
}
|
|
156
|
+
const profileName = rest.find((item) => !item.startsWith('--'));
|
|
157
|
+
if (!profileName)
|
|
158
|
+
throw new WebFlowError('INVALID_COMMAND', 'Usage: webflow profiles status <profile> [--json]');
|
|
159
|
+
const status = await statusProfile(profileName);
|
|
160
|
+
print(status, formatProfileStatus(status));
|
|
161
|
+
return status.probablyValid === false ? 1 : 0;
|
|
162
|
+
}
|
|
163
|
+
case 'login': {
|
|
164
|
+
const profileName = rest.find((item) => !item.startsWith('--'));
|
|
165
|
+
if (!profileName)
|
|
166
|
+
throw new WebFlowError('INVALID_COMMAND', 'Usage: webflow profiles login <profile> [--url <url>] [--managed] [--chrome-path <path>]');
|
|
167
|
+
const startUrl = getFlagValue(rest, '--url');
|
|
168
|
+
const chromePath = getFlagValue(rest, '--chrome-path');
|
|
169
|
+
const managed = rest.includes('--managed');
|
|
170
|
+
if (!json)
|
|
171
|
+
console.log(`Opening ${managed ? 'managed browser' : 'real Chrome'} for profile "${profileName}". Log in, then close the window to finish.`);
|
|
172
|
+
const status = await loginProfile(profileName, { startUrl, chromePath, managed });
|
|
173
|
+
print(status, formatProfileStatus(status));
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
case 'reset': {
|
|
177
|
+
const profileName = rest.find((item) => !item.startsWith('--'));
|
|
178
|
+
if (!profileName || !rest.includes('--yes'))
|
|
179
|
+
throw new WebFlowError('INVALID_COMMAND', 'Usage: webflow profiles reset <profile> --yes');
|
|
180
|
+
const result = await resetProfile(profileName);
|
|
181
|
+
print(result, `Removed profile ${result.profile} (${result.removed.length} director${result.removed.length === 1 ? 'y' : 'ies'}).`);
|
|
182
|
+
return 0;
|
|
183
|
+
}
|
|
184
|
+
case 'refresh-instance': {
|
|
185
|
+
const profileName = rest.find((item) => !item.startsWith('--'));
|
|
186
|
+
const instanceIndex = rest.indexOf('--instance');
|
|
187
|
+
const instance = instanceIndex >= 0 ? rest[instanceIndex + 1] : undefined;
|
|
188
|
+
if (!profileName || !instance || !rest.includes('--yes')) {
|
|
189
|
+
throw new WebFlowError('INVALID_COMMAND', 'Usage: webflow profiles refresh-instance <profile> --instance <id> --yes');
|
|
190
|
+
}
|
|
191
|
+
const result = await refreshInstanceProfile(profileName, instance);
|
|
192
|
+
print(result, `Refreshed ${result.profile}__${result.instance} from ${result.baseDir}.`);
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
default:
|
|
196
|
+
throw new WebFlowError('INVALID_COMMAND', `Unknown profiles command: ${subcommand}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
case 'doctor': {
|
|
200
|
+
if (!authorMode)
|
|
201
|
+
throw new WebFlowError('INVALID_COMMAND', `Unknown command: ${command}`);
|
|
202
|
+
const targetRoot = subcommand ? resolve(subcommand) : resolve(projectRoot);
|
|
203
|
+
const result = await runDoctor(targetRoot);
|
|
204
|
+
console.log(formatDoctorResult(result));
|
|
205
|
+
return result.exitCode;
|
|
206
|
+
}
|
|
207
|
+
case 'run': {
|
|
208
|
+
if (!subcommand) {
|
|
209
|
+
throw new WebFlowError('INVALID_COMMAND', 'Usage: webflow run <flow> [--headed] [--browser-mode <managed|cdp>] [--browser-visibility <visible|hidden>] [--profile <name>] [--input \'<json>\']');
|
|
210
|
+
}
|
|
211
|
+
const headed = rest.includes('--headed');
|
|
212
|
+
const inputJson = getFlagValue(rest, '--input');
|
|
213
|
+
const input = inputJson ? JSON.parse(inputJson) : {};
|
|
214
|
+
const profileName = getFlagValue(rest, '--profile');
|
|
215
|
+
const browserMode = parseBrowserMode(getFlagValue(rest, '--browser-mode'));
|
|
216
|
+
const browserVisibility = parseBrowserVisibility(getFlagValue(rest, '--browser-visibility'));
|
|
217
|
+
const result = await runFlow(resolve(projectRoot), subcommand, input, { headed, profileName, browserMode, browserVisibility });
|
|
218
|
+
console.log(JSON.stringify(result, null, 2));
|
|
219
|
+
return result.ok ? 0 : 1;
|
|
220
|
+
}
|
|
221
|
+
case 'login': {
|
|
222
|
+
const loginRest = [subcommand, ...rest].filter((value) => value !== undefined);
|
|
223
|
+
if (loginRest.includes('--status')) {
|
|
224
|
+
const status = await loginStatus();
|
|
225
|
+
console.log(status.loggedIn
|
|
226
|
+
? `Paired worker "${status.workerId}" with ${status.appUrl} (relay ${status.relayUrl}).`
|
|
227
|
+
: 'Not paired. Run: webflow login');
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
230
|
+
if (loginRest.includes('--token') || loginRest.includes('--token-file')) {
|
|
231
|
+
throw new WebFlowError('INVALID_COMMAND', 'webflow login no longer accepts tokens. Run "webflow login" and approve the device pairing in your browser.');
|
|
232
|
+
}
|
|
233
|
+
const appUrl = getFlagValue(loginRest, '--app');
|
|
234
|
+
if (loginRest.includes('--app') && !appUrl) {
|
|
235
|
+
throw new WebFlowError('INVALID_COMMAND', 'Usage: webflow login [--app <https://app.webflowmcp.es>]');
|
|
236
|
+
}
|
|
237
|
+
const result = await login(appUrl, {
|
|
238
|
+
onVerification: (url, opened) => {
|
|
239
|
+
console.log(opened ? `Complete pairing in your browser: ${url}` : `Could not open a browser. Open this URL to complete pairing: ${url}`);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
console.log(`Paired worker "${result.workerId}" with ${result.appUrl}. "webflow worker start" will use this credential automatically.`);
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
case 'logout': {
|
|
246
|
+
const cleared = await logout();
|
|
247
|
+
console.log(cleared ? 'Logged out — removed the saved relay login.' : 'Nothing to log out of — no saved login found.');
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
250
|
+
case 'approvals': {
|
|
251
|
+
if (subcommand === 'list') {
|
|
252
|
+
if (rest.some((argument) => argument !== '--json') || rest.filter((argument) => argument === '--json').length > 1) {
|
|
253
|
+
throw new WebFlowError('INVALID_COMMAND', 'Usage: webflow approvals list [--json]');
|
|
254
|
+
}
|
|
255
|
+
console.log(await formatApprovalState(rest.includes('--json')));
|
|
256
|
+
return 0;
|
|
257
|
+
}
|
|
258
|
+
if (subcommand !== 'approve' && subcommand !== 'revoke') {
|
|
259
|
+
throw new WebFlowError('INVALID_COMMAND', 'Usage: webflow approvals <list|approve|revoke> [flow] [--trust-domain <domain>]');
|
|
260
|
+
}
|
|
261
|
+
const trustDomainIndex = rest.indexOf('--trust-domain');
|
|
262
|
+
const trustDomain = trustDomainIndex >= 0 ? rest[trustDomainIndex + 1] : undefined;
|
|
263
|
+
if (trustDomainIndex >= 0 && (!trustDomain || trustDomain.startsWith('--'))) {
|
|
264
|
+
throw new WebFlowError('INVALID_COMMAND', `Usage: webflow approvals ${subcommand} <flow> [--trust-domain <domain>]`);
|
|
265
|
+
}
|
|
266
|
+
const positional = rest.filter((_, index) => trustDomainIndex < 0 || (index !== trustDomainIndex && index !== trustDomainIndex + 1));
|
|
267
|
+
if (positional.length !== 1 || positional[0].startsWith('--') || rest.filter((argument) => argument === '--trust-domain').length > 1) {
|
|
268
|
+
throw new WebFlowError('INVALID_COMMAND', `Usage: webflow approvals ${subcommand} <flow> [--trust-domain <domain>]`);
|
|
269
|
+
}
|
|
270
|
+
const flowName = positional[0];
|
|
271
|
+
console.log(subcommand === 'approve' ? await approveFlow(flowName, trustDomain) : await revokeFlow(flowName, trustDomain));
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
case 'worker': {
|
|
275
|
+
if (subcommand !== 'start') {
|
|
276
|
+
throw new WebFlowError('INVALID_COMMAND', 'Usage: webflow worker start [--headed] [--browser-mode <managed|cdp>] [--browser-visibility <visible|hidden>]');
|
|
277
|
+
}
|
|
278
|
+
if (rest.includes('--token') || rest.includes('--token-file') || rest.includes('--relay') || rest.includes('--worker-id')) {
|
|
279
|
+
throw new WebFlowError('INVALID_COMMAND', 'Worker connection and identity overrides are no longer supported. Run "webflow login" to pair this device.');
|
|
280
|
+
}
|
|
281
|
+
const credentials = await loadCredentials();
|
|
282
|
+
const headed = rest.includes('--headed');
|
|
283
|
+
const browserMode = parseBrowserMode(getFlagValue(rest, '--browser-mode'));
|
|
284
|
+
const browserVisibility = parseBrowserVisibility(getFlagValue(rest, '--browser-visibility'));
|
|
285
|
+
if (!credentials) {
|
|
286
|
+
throw new WebFlowError('INVALID_COMMAND', 'No saved v2 worker credential found. Run "webflow login" to pair this device first.');
|
|
287
|
+
}
|
|
288
|
+
const handle = startWorker(resolve(projectRoot), {
|
|
289
|
+
relayUrl: credentials.relayUrl,
|
|
290
|
+
credential: getWireCredential(credentials),
|
|
291
|
+
workerId: credentials.workerId,
|
|
292
|
+
headed,
|
|
293
|
+
browserMode,
|
|
294
|
+
browserVisibility,
|
|
295
|
+
onLog: (message) => console.error(`[worker] ${message}`)
|
|
296
|
+
});
|
|
297
|
+
console.log(`Worker "${handle.workerId}" connecting to ${credentials.relayUrl} (${headed ? 'headed — you will see Chrome' : 'headless — Chrome runs hidden'}). Press Ctrl+C to stop.`);
|
|
298
|
+
const shutdown = () => {
|
|
299
|
+
handle.stop();
|
|
300
|
+
process.exit(0);
|
|
301
|
+
};
|
|
302
|
+
process.on('SIGINT', shutdown);
|
|
303
|
+
process.on('SIGTERM', shutdown);
|
|
304
|
+
// This command is a long-running daemon by design, unlike every other CLI
|
|
305
|
+
// command here: it stays connected until the process is killed.
|
|
306
|
+
await new Promise(() => { });
|
|
307
|
+
return 0;
|
|
308
|
+
}
|
|
309
|
+
default:
|
|
310
|
+
throw new WebFlowError('INVALID_COMMAND', `Unknown command: ${command}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// `npm install -g` invokes this file through a symlink on macOS/Linux (bin/webflow ->
|
|
314
|
+
// .../dist/src/cli/index.js) or a generated shim on Windows. import.meta.url is always the
|
|
315
|
+
// resolved real path; process.argv[1] isn't (symlink case) or uses backslashes (Windows case).
|
|
316
|
+
// Building the file:// URL by hand breaks on Windows (drive-letter paths need
|
|
317
|
+
// file:///C:/... — three slashes — not file://C:/...); pathToFileURL handles both platforms
|
|
318
|
+
// correctly. Without this, the guard below silently never fires for any real end user (exits
|
|
319
|
+
// 0, prints nothing).
|
|
320
|
+
const invokedRealFileUrl = (() => {
|
|
321
|
+
try {
|
|
322
|
+
return process.argv[1] ? pathToFileURL(realpathSync(process.argv[1])).href : undefined;
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
})();
|
|
328
|
+
if (invokedRealFileUrl && import.meta.url === invokedRealFileUrl) {
|
|
329
|
+
runCli().then((code) => {
|
|
330
|
+
process.exitCode = code;
|
|
331
|
+
}, (error) => {
|
|
332
|
+
const message = error instanceof WebFlowError ? `[${error.code}] ${error.message}` : toErrorMessage(error);
|
|
333
|
+
console.error(message);
|
|
334
|
+
process.exitCode = 1;
|
|
335
|
+
});
|
|
336
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { BrowserMode } from '../runner/types.js';
|
|
2
|
+
export type BrowserVisibility = 'visible' | 'hidden';
|
|
3
|
+
/** 'inherit' means the user has never set a global default — capabilities.headless
|
|
4
|
+
* (per-flow, legacy) still decides in that case. Never inferred, only set explicitly
|
|
5
|
+
* via setDefaultBrowserVisibility(). */
|
|
6
|
+
export type GlobalVisibilityPreference = BrowserVisibility | 'inherit';
|
|
7
|
+
/** Same persistence model as browser visibility: 'inherit' means no global mode preference. */
|
|
8
|
+
export type GlobalBrowserModePreference = BrowserMode | 'inherit';
|
|
9
|
+
export interface WebflowConfig {
|
|
10
|
+
browserVisibility: {
|
|
11
|
+
default: GlobalVisibilityPreference;
|
|
12
|
+
/** Explicit per-flow overrides. Absence of a key means "no override" — falls
|
|
13
|
+
* through to the global default, never treated as 'inherit' itself. */
|
|
14
|
+
flows: Record<string, BrowserVisibility>;
|
|
15
|
+
};
|
|
16
|
+
browserMode: {
|
|
17
|
+
default: GlobalBrowserModePreference;
|
|
18
|
+
/** Explicit per-flow browser-mode overrides. Absence means fall through to the global default. */
|
|
19
|
+
flows: Record<string, BrowserMode>;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export declare function loadConfig(): Promise<WebflowConfig>;
|
|
23
|
+
export declare function saveConfig(config: WebflowConfig): Promise<void>;
|
|
24
|
+
export declare function setDefaultBrowserVisibility(value: GlobalVisibilityPreference): Promise<WebflowConfig>;
|
|
25
|
+
/** value:null clears the per-flow override, falling back to the global default. */
|
|
26
|
+
export declare function setFlowBrowserVisibility(flowName: string, value: BrowserVisibility | null): Promise<WebflowConfig>;
|
|
27
|
+
export declare function setDefaultBrowserMode(value: GlobalBrowserModePreference): Promise<WebflowConfig>;
|
|
28
|
+
/** value:null clears the per-flow override, falling back to the global default. */
|
|
29
|
+
export declare function setFlowBrowserMode(flowName: string, value: BrowserMode | null): Promise<WebflowConfig>;
|
|
30
|
+
/** Pure resolution, no I/O — flow-runner.ts calls this against an already-loaded config.
|
|
31
|
+
* Returns 'inherit' when neither a per-flow override nor a global default apply, leaving
|
|
32
|
+
* the legacy capabilities.headless field free to decide. */
|
|
33
|
+
export declare function resolveBrowserVisibility(config: WebflowConfig, flowName: string): GlobalVisibilityPreference;
|
|
34
|
+
/** Pure resolution for browser mode preference. 'inherit' means the flow's own default mode decides. */
|
|
35
|
+
export declare function resolveBrowserModePreference(config: WebflowConfig, flowName: string): GlobalBrowserModePreference;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { dirname } from 'node:path';
|
|
2
|
+
import { getConfigPath } from '../shared/paths.js';
|
|
3
|
+
import { ensureDir, readJsonFile, writeJsonFile } from '../shared/fs.js';
|
|
4
|
+
function defaultConfig() {
|
|
5
|
+
return { browserVisibility: { default: 'inherit', flows: {} }, browserMode: { default: 'inherit', flows: {} } };
|
|
6
|
+
}
|
|
7
|
+
function isBrowserVisibility(value) {
|
|
8
|
+
return value === 'visible' || value === 'hidden';
|
|
9
|
+
}
|
|
10
|
+
function isBrowserMode(value) {
|
|
11
|
+
return value === 'managed' || value === 'cdp';
|
|
12
|
+
}
|
|
13
|
+
/** Never trusts a config.json blindly — a corrupt or hand-edited file falls back to
|
|
14
|
+
* defaults instead of crashing the worker. */
|
|
15
|
+
function sanitizeConfig(raw) {
|
|
16
|
+
const fallback = defaultConfig();
|
|
17
|
+
if (typeof raw !== 'object' || raw === null)
|
|
18
|
+
return fallback;
|
|
19
|
+
const record = raw;
|
|
20
|
+
const browserVisibility = record.browserVisibility;
|
|
21
|
+
let browserVisibilityConfig = fallback.browserVisibility;
|
|
22
|
+
if (typeof browserVisibility === 'object' && browserVisibility !== null) {
|
|
23
|
+
const rawDefault = browserVisibility.default;
|
|
24
|
+
const globalDefault = rawDefault === 'inherit' || isBrowserVisibility(rawDefault) ? rawDefault : 'inherit';
|
|
25
|
+
const rawFlows = browserVisibility.flows;
|
|
26
|
+
const flows = {};
|
|
27
|
+
if (typeof rawFlows === 'object' && rawFlows !== null) {
|
|
28
|
+
for (const [name, value] of Object.entries(rawFlows)) {
|
|
29
|
+
if (isBrowserVisibility(value)) {
|
|
30
|
+
flows[name] = value;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
browserVisibilityConfig = { default: globalDefault, flows };
|
|
35
|
+
}
|
|
36
|
+
const browserMode = record.browserMode;
|
|
37
|
+
let browserModeConfig = fallback.browserMode;
|
|
38
|
+
if (typeof browserMode === 'object' && browserMode !== null) {
|
|
39
|
+
const rawDefault = browserMode.default;
|
|
40
|
+
const globalDefault = rawDefault === 'inherit' || isBrowserMode(rawDefault) ? rawDefault : 'inherit';
|
|
41
|
+
const rawFlows = browserMode.flows;
|
|
42
|
+
const flows = {};
|
|
43
|
+
if (typeof rawFlows === 'object' && rawFlows !== null) {
|
|
44
|
+
for (const [name, value] of Object.entries(rawFlows)) {
|
|
45
|
+
if (isBrowserMode(value)) {
|
|
46
|
+
flows[name] = value;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
browserModeConfig = { default: globalDefault, flows };
|
|
51
|
+
}
|
|
52
|
+
return { browserVisibility: browserVisibilityConfig, browserMode: browserModeConfig };
|
|
53
|
+
}
|
|
54
|
+
export async function loadConfig() {
|
|
55
|
+
try {
|
|
56
|
+
const raw = await readJsonFile(getConfigPath());
|
|
57
|
+
return sanitizeConfig(raw);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return defaultConfig();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export async function saveConfig(config) {
|
|
64
|
+
const path = getConfigPath();
|
|
65
|
+
await ensureDir(dirname(path));
|
|
66
|
+
await writeJsonFile(path, config);
|
|
67
|
+
}
|
|
68
|
+
export async function setDefaultBrowserVisibility(value) {
|
|
69
|
+
const config = await loadConfig();
|
|
70
|
+
config.browserVisibility.default = value;
|
|
71
|
+
await saveConfig(config);
|
|
72
|
+
return config;
|
|
73
|
+
}
|
|
74
|
+
/** value:null clears the per-flow override, falling back to the global default. */
|
|
75
|
+
export async function setFlowBrowserVisibility(flowName, value) {
|
|
76
|
+
const config = await loadConfig();
|
|
77
|
+
if (value === null) {
|
|
78
|
+
delete config.browserVisibility.flows[flowName];
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
config.browserVisibility.flows[flowName] = value;
|
|
82
|
+
}
|
|
83
|
+
await saveConfig(config);
|
|
84
|
+
return config;
|
|
85
|
+
}
|
|
86
|
+
export async function setDefaultBrowserMode(value) {
|
|
87
|
+
const config = await loadConfig();
|
|
88
|
+
config.browserMode.default = value;
|
|
89
|
+
await saveConfig(config);
|
|
90
|
+
return config;
|
|
91
|
+
}
|
|
92
|
+
/** value:null clears the per-flow override, falling back to the global default. */
|
|
93
|
+
export async function setFlowBrowserMode(flowName, value) {
|
|
94
|
+
const config = await loadConfig();
|
|
95
|
+
if (value === null) {
|
|
96
|
+
delete config.browserMode.flows[flowName];
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
config.browserMode.flows[flowName] = value;
|
|
100
|
+
}
|
|
101
|
+
await saveConfig(config);
|
|
102
|
+
return config;
|
|
103
|
+
}
|
|
104
|
+
/** Pure resolution, no I/O — flow-runner.ts calls this against an already-loaded config.
|
|
105
|
+
* Returns 'inherit' when neither a per-flow override nor a global default apply, leaving
|
|
106
|
+
* the legacy capabilities.headless field free to decide. */
|
|
107
|
+
export function resolveBrowserVisibility(config, flowName) {
|
|
108
|
+
const override = config.browserVisibility.flows[flowName];
|
|
109
|
+
if (override)
|
|
110
|
+
return override;
|
|
111
|
+
return config.browserVisibility.default;
|
|
112
|
+
}
|
|
113
|
+
/** Pure resolution for browser mode preference. 'inherit' means the flow's own default mode decides. */
|
|
114
|
+
export function resolveBrowserModePreference(config, flowName) {
|
|
115
|
+
const override = config.browserMode.flows[flowName];
|
|
116
|
+
if (override)
|
|
117
|
+
return override;
|
|
118
|
+
return config.browserMode.default;
|
|
119
|
+
}
|