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,16 @@
|
|
|
1
|
+
export class WebFlowError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
details;
|
|
4
|
+
constructor(code, message, details) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'WebFlowError';
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.details = details;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function toErrorMessage(error) {
|
|
12
|
+
if (error instanceof Error) {
|
|
13
|
+
return error.message;
|
|
14
|
+
}
|
|
15
|
+
return typeof error === 'string' ? error : 'Unknown error';
|
|
16
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function pathExists(path: string): Promise<boolean>;
|
|
2
|
+
export declare function ensureDir(path: string): Promise<void>;
|
|
3
|
+
export declare function readJsonFile<T>(filePath: string): Promise<T>;
|
|
4
|
+
export declare function writeJsonFile(filePath: string, value: unknown): Promise<void>;
|
|
5
|
+
export declare function copyDir(source: string, target: string): Promise<void>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { cp, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
export async function pathExists(path) {
|
|
3
|
+
try {
|
|
4
|
+
await stat(path);
|
|
5
|
+
return true;
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export async function ensureDir(path) {
|
|
12
|
+
await mkdir(path, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
export async function readJsonFile(filePath) {
|
|
15
|
+
const raw = await readFile(filePath, 'utf8');
|
|
16
|
+
return JSON.parse(raw);
|
|
17
|
+
}
|
|
18
|
+
export async function writeJsonFile(filePath, value) {
|
|
19
|
+
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
20
|
+
}
|
|
21
|
+
export async function copyDir(source, target) {
|
|
22
|
+
await cp(source, target, { recursive: true, errorOnExist: false });
|
|
23
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
export declare const MIN_NODE_MAJOR = 20;
|
|
2
|
+
export declare const WORKFLOW_DIRNAME = ".webflow";
|
|
3
|
+
/** Env var to override where login sessions (Chrome profiles) are stored. */
|
|
4
|
+
export declare const PROFILES_DIR_ENV = "WEBFLOW_PROFILES_DIR";
|
|
5
|
+
/** Env var to override the workspace root that holds .webflow/flows (Model B cross-repo). */
|
|
6
|
+
export declare const FLOWS_DIR_ENV = "WEBFLOW_FLOWS_DIR";
|
|
7
|
+
/** Env var to override where the global webflow-config.json (browser visibility, etc.) lives. */
|
|
8
|
+
export declare const CONFIG_PATH_ENV = "WEBFLOW_CONFIG_PATH";
|
|
9
|
+
/** Env var to override where the local repertoire.json (saved flows) lives. */
|
|
10
|
+
export declare const REPERTOIRE_PATH_ENV = "WEBFLOW_REPERTOIRE_PATH";
|
|
11
|
+
/** Env var to override persistent provider cooldown state. */
|
|
12
|
+
export declare const ANTIBLOCK_DIR_ENV = "WEBFLOW_ANTIBLOCK_DIR";
|
|
13
|
+
/** Env var to override where the persisted relay login (~/.webflow/credentials.json) lives. */
|
|
14
|
+
export declare const CREDENTIALS_PATH_ENV = "WEBFLOW_CREDENTIALS_PATH";
|
|
15
|
+
/** Env var to override the persistent device identity used during worker pairing. */
|
|
16
|
+
export declare const DEVICE_IDENTITY_PATH_ENV = "WEBFLOW_DEVICE_IDENTITY_PATH";
|
|
17
|
+
/** Env vars for worker-local remote-flow trust decisions. */
|
|
18
|
+
export declare const FLOW_APPROVALS_PATH_ENV = "WEBFLOW_FLOW_APPROVALS_PATH";
|
|
19
|
+
export declare const FLOW_PENDING_APPROVALS_PATH_ENV = "WEBFLOW_FLOW_PENDING_APPROVALS_PATH";
|
|
20
|
+
export declare const FLOW_RELEASE_STATE_PATH_ENV = "WEBFLOW_FLOW_RELEASE_STATE_PATH";
|
|
21
|
+
/** Independent MCP-client credentials used only by the local unified gateway. */
|
|
22
|
+
export declare const RELAY_MCP_URL_ENV = "WEBFLOW_RELAY_MCP_URL";
|
|
23
|
+
export declare const RELAY_MCP_TOKEN_ENV = "WEBFLOW_RELAY_MCP_TOKEN";
|
|
24
|
+
export interface WorkspacePaths {
|
|
25
|
+
projectRoot: string;
|
|
26
|
+
workspaceRoot: string;
|
|
27
|
+
flowsDir: string;
|
|
28
|
+
skillsDir: string;
|
|
29
|
+
/**
|
|
30
|
+
* Where Chrome login sessions live. Global by default (shared across repos) so
|
|
31
|
+
* you log in once and every workspace reuses the session. Per-repo state such as
|
|
32
|
+
* downloads stays under the workspace; only sessions are global.
|
|
33
|
+
*/
|
|
34
|
+
profilesDir: string;
|
|
35
|
+
downloadsDir: string;
|
|
36
|
+
cacheDir: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the global profiles (login session) root, decoupled from any single
|
|
40
|
+
* repository. Honors WEBFLOW_PROFILES_DIR, otherwise defaults to ~/.webflow/profiles.
|
|
41
|
+
*/
|
|
42
|
+
export declare function getProfilesRoot(): string;
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the global webflow-config.json path (browser visibility preferences, etc.),
|
|
45
|
+
* decoupled from any single repository — same rationale as getProfilesRoot(): the person
|
|
46
|
+
* running the worker has one set of preferences, not one per project. Honors
|
|
47
|
+
* WEBFLOW_CONFIG_PATH, otherwise defaults to ~/.webflow/config.json.
|
|
48
|
+
*/
|
|
49
|
+
export declare function getConfigPath(): string;
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the global repertoire.json path (flows saved locally so the worker can expose
|
|
52
|
+
* them as direct tools without re-searching the catalog). Honors WEBFLOW_REPERTOIRE_PATH,
|
|
53
|
+
* otherwise defaults to ~/.webflow/repertoire.json.
|
|
54
|
+
*/
|
|
55
|
+
export declare function getRepertoirePath(): string;
|
|
56
|
+
export declare function getAntiBlockDir(): string;
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the persisted worker-pairing path (`webflow login`'s relay URL + secret).
|
|
59
|
+
* Deliberately a SEPARATE file from config.json: that file is read wholesale
|
|
60
|
+
* and forwarded verbatim to MCP clients by the get_config tool (tool-registry.ts /
|
|
61
|
+
* mcp-endpoint.ts), so a worker secret must never live there. Honors
|
|
62
|
+
* WEBFLOW_CREDENTIALS_PATH, otherwise defaults to ~/.webflow/credentials.json.
|
|
63
|
+
*/
|
|
64
|
+
export declare function getCredentialsPath(): string;
|
|
65
|
+
export declare function getDeviceIdentityPath(): string;
|
|
66
|
+
export declare function getFlowApprovalsPath(): string;
|
|
67
|
+
export declare function getFlowPendingApprovalsPath(): string;
|
|
68
|
+
export declare function getFlowReleaseStatePath(): string;
|
|
69
|
+
/** Env var to override where the background worker's PID file lives (src/worker/lifecycle.ts). */
|
|
70
|
+
export declare const WORKER_PID_PATH_ENV = "WEBFLOW_WORKER_PID_PATH";
|
|
71
|
+
/**
|
|
72
|
+
* Tracks the detached worker process started by the start_worker MCP tool
|
|
73
|
+
* (src/worker/lifecycle.ts), so stop_worker/worker_status can find it again from a
|
|
74
|
+
* different process/session. Honors WEBFLOW_WORKER_PID_PATH, otherwise defaults to
|
|
75
|
+
* ~/.webflow/worker.pid.
|
|
76
|
+
*/
|
|
77
|
+
export declare function getWorkerPidPath(): string;
|
|
78
|
+
/** stdout/stderr of the detached worker started by start_worker — there is no other
|
|
79
|
+
* way to see its output once the MCP tool call that launched it has returned. */
|
|
80
|
+
export declare function getWorkerLogPath(): string;
|
|
81
|
+
export declare function assertSupportedNode(version?: string): void;
|
|
82
|
+
export declare function sanitizeName(input: string): string;
|
|
83
|
+
export declare function getWorkspacePaths(projectRoot: string): WorkspacePaths;
|
|
84
|
+
/**
|
|
85
|
+
* Resolve the workspace root that actually holds the flows, decoupled from the
|
|
86
|
+
* download/working root (Model B). Priority: WEBFLOW_FLOWS_DIR, then the calling
|
|
87
|
+
* repo if it has its own .webflow/flows, then the installed package's bundled flows.
|
|
88
|
+
* This lets an agent in any repo use the flows shipped here while its downloads stay
|
|
89
|
+
* in its own repo (the projectRoot).
|
|
90
|
+
*/
|
|
91
|
+
export declare function resolveFlowsProjectRoot(projectRoot: string): string;
|
|
92
|
+
export declare function assertWorkspaceReady(projectRoot: string): Promise<void>;
|
|
93
|
+
export declare function getPackageRoot(metaUrl: string): string;
|
|
94
|
+
/**
|
|
95
|
+
* Gates the flow-author-only CLI commands (init/create/fork/doctor — scaffolding and
|
|
96
|
+
* diagnosing flows for the catalog, not something an end user running automations
|
|
97
|
+
* should ever see or reach). Detects the maintainer's own git checkout by checking
|
|
98
|
+
* for `.git` next to package.json — this can never end up in what an end user
|
|
99
|
+
* installs, because package.json's "files" field only ships `dist/src/` (never `.git`,
|
|
100
|
+
* never the top-level `src/`), so the check is a structural consequence of packaging
|
|
101
|
+
* itself, not a separate flag that could drift out of sync with it.
|
|
102
|
+
*/
|
|
103
|
+
export declare function isAuthorEnvironment(metaUrl: string): boolean;
|
|
104
|
+
/** Split out from isAuthorEnvironment so tests can check the underlying rule (does
|
|
105
|
+
* this directory have a `.git`?) against a synthetic package root, without needing to
|
|
106
|
+
* fake an import.meta.url that resolves there via getPackageRoot's directory walk.
|
|
107
|
+
* `.git` is a DIRECTORY in a normal clone but a plain FILE (a `gitdir: ...` pointer)
|
|
108
|
+
* in a git worktree — this repo itself is checked out as a worktree, so checking
|
|
109
|
+
* `.isDirectory()` would wrongly say "not an author environment" on this very box.
|
|
110
|
+
* Either form only ever exists in a real git checkout, never in what an installed
|
|
111
|
+
* package extracts. */
|
|
112
|
+
export declare function isAuthorPackageRoot(packageRoot: string): boolean;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { dirname, resolve } from 'node:path';
|
|
2
|
+
import { statSync } from 'node:fs';
|
|
3
|
+
import { stat } from 'node:fs/promises';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { WebFlowError } from './errors.js';
|
|
7
|
+
export const MIN_NODE_MAJOR = 20;
|
|
8
|
+
export const WORKFLOW_DIRNAME = '.webflow';
|
|
9
|
+
/** Env var to override where login sessions (Chrome profiles) are stored. */
|
|
10
|
+
export const PROFILES_DIR_ENV = 'WEBFLOW_PROFILES_DIR';
|
|
11
|
+
/** Env var to override the workspace root that holds .webflow/flows (Model B cross-repo). */
|
|
12
|
+
export const FLOWS_DIR_ENV = 'WEBFLOW_FLOWS_DIR';
|
|
13
|
+
/** Env var to override where the global webflow-config.json (browser visibility, etc.) lives. */
|
|
14
|
+
export const CONFIG_PATH_ENV = 'WEBFLOW_CONFIG_PATH';
|
|
15
|
+
/** Env var to override where the local repertoire.json (saved flows) lives. */
|
|
16
|
+
export const REPERTOIRE_PATH_ENV = 'WEBFLOW_REPERTOIRE_PATH';
|
|
17
|
+
/** Env var to override persistent provider cooldown state. */
|
|
18
|
+
export const ANTIBLOCK_DIR_ENV = 'WEBFLOW_ANTIBLOCK_DIR';
|
|
19
|
+
/** Env var to override where the persisted relay login (~/.webflow/credentials.json) lives. */
|
|
20
|
+
export const CREDENTIALS_PATH_ENV = 'WEBFLOW_CREDENTIALS_PATH';
|
|
21
|
+
/** Env var to override the persistent device identity used during worker pairing. */
|
|
22
|
+
export const DEVICE_IDENTITY_PATH_ENV = 'WEBFLOW_DEVICE_IDENTITY_PATH';
|
|
23
|
+
/** Env vars for worker-local remote-flow trust decisions. */
|
|
24
|
+
export const FLOW_APPROVALS_PATH_ENV = 'WEBFLOW_FLOW_APPROVALS_PATH';
|
|
25
|
+
export const FLOW_PENDING_APPROVALS_PATH_ENV = 'WEBFLOW_FLOW_PENDING_APPROVALS_PATH';
|
|
26
|
+
export const FLOW_RELEASE_STATE_PATH_ENV = 'WEBFLOW_FLOW_RELEASE_STATE_PATH';
|
|
27
|
+
/** Independent MCP-client credentials used only by the local unified gateway. */
|
|
28
|
+
export const RELAY_MCP_URL_ENV = 'WEBFLOW_RELAY_MCP_URL';
|
|
29
|
+
export const RELAY_MCP_TOKEN_ENV = 'WEBFLOW_RELAY_MCP_TOKEN';
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the global profiles (login session) root, decoupled from any single
|
|
32
|
+
* repository. Honors WEBFLOW_PROFILES_DIR, otherwise defaults to ~/.webflow/profiles.
|
|
33
|
+
*/
|
|
34
|
+
export function getProfilesRoot() {
|
|
35
|
+
const override = process.env[PROFILES_DIR_ENV];
|
|
36
|
+
if (override && override.trim().length > 0) {
|
|
37
|
+
return resolve(override.trim());
|
|
38
|
+
}
|
|
39
|
+
return resolve(homedir(), WORKFLOW_DIRNAME, 'profiles');
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the global webflow-config.json path (browser visibility preferences, etc.),
|
|
43
|
+
* decoupled from any single repository — same rationale as getProfilesRoot(): the person
|
|
44
|
+
* running the worker has one set of preferences, not one per project. Honors
|
|
45
|
+
* WEBFLOW_CONFIG_PATH, otherwise defaults to ~/.webflow/config.json.
|
|
46
|
+
*/
|
|
47
|
+
export function getConfigPath() {
|
|
48
|
+
const override = process.env[CONFIG_PATH_ENV];
|
|
49
|
+
if (override && override.trim().length > 0) {
|
|
50
|
+
return resolve(override.trim());
|
|
51
|
+
}
|
|
52
|
+
return resolve(homedir(), WORKFLOW_DIRNAME, 'config.json');
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the global repertoire.json path (flows saved locally so the worker can expose
|
|
56
|
+
* them as direct tools without re-searching the catalog). Honors WEBFLOW_REPERTOIRE_PATH,
|
|
57
|
+
* otherwise defaults to ~/.webflow/repertoire.json.
|
|
58
|
+
*/
|
|
59
|
+
export function getRepertoirePath() {
|
|
60
|
+
const override = process.env[REPERTOIRE_PATH_ENV];
|
|
61
|
+
if (override && override.trim().length > 0) {
|
|
62
|
+
return resolve(override.trim());
|
|
63
|
+
}
|
|
64
|
+
return resolve(homedir(), WORKFLOW_DIRNAME, 'repertoire.json');
|
|
65
|
+
}
|
|
66
|
+
export function getAntiBlockDir() {
|
|
67
|
+
const override = process.env[ANTIBLOCK_DIR_ENV];
|
|
68
|
+
if (override && override.trim().length > 0)
|
|
69
|
+
return resolve(override.trim());
|
|
70
|
+
return resolve(homedir(), WORKFLOW_DIRNAME, 'anti-block');
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Resolve the persisted worker-pairing path (`webflow login`'s relay URL + secret).
|
|
74
|
+
* Deliberately a SEPARATE file from config.json: that file is read wholesale
|
|
75
|
+
* and forwarded verbatim to MCP clients by the get_config tool (tool-registry.ts /
|
|
76
|
+
* mcp-endpoint.ts), so a worker secret must never live there. Honors
|
|
77
|
+
* WEBFLOW_CREDENTIALS_PATH, otherwise defaults to ~/.webflow/credentials.json.
|
|
78
|
+
*/
|
|
79
|
+
export function getCredentialsPath() {
|
|
80
|
+
const override = process.env[CREDENTIALS_PATH_ENV];
|
|
81
|
+
if (override && override.trim().length > 0) {
|
|
82
|
+
return resolve(override.trim());
|
|
83
|
+
}
|
|
84
|
+
return resolve(homedir(), WORKFLOW_DIRNAME, 'credentials.json');
|
|
85
|
+
}
|
|
86
|
+
export function getDeviceIdentityPath() {
|
|
87
|
+
const override = process.env[DEVICE_IDENTITY_PATH_ENV];
|
|
88
|
+
if (override && override.trim().length > 0) {
|
|
89
|
+
return resolve(override.trim());
|
|
90
|
+
}
|
|
91
|
+
return resolve(homedir(), WORKFLOW_DIRNAME, 'device.json');
|
|
92
|
+
}
|
|
93
|
+
export function getFlowApprovalsPath() {
|
|
94
|
+
const override = process.env[FLOW_APPROVALS_PATH_ENV];
|
|
95
|
+
if (override && override.trim().length > 0) {
|
|
96
|
+
return resolve(override.trim());
|
|
97
|
+
}
|
|
98
|
+
return resolve(homedir(), WORKFLOW_DIRNAME, 'flow-approvals.json');
|
|
99
|
+
}
|
|
100
|
+
export function getFlowPendingApprovalsPath() {
|
|
101
|
+
const override = process.env[FLOW_PENDING_APPROVALS_PATH_ENV];
|
|
102
|
+
if (override && override.trim().length > 0) {
|
|
103
|
+
return resolve(override.trim());
|
|
104
|
+
}
|
|
105
|
+
return resolve(homedir(), WORKFLOW_DIRNAME, 'flow-pending-approvals.json');
|
|
106
|
+
}
|
|
107
|
+
export function getFlowReleaseStatePath() {
|
|
108
|
+
const override = process.env[FLOW_RELEASE_STATE_PATH_ENV];
|
|
109
|
+
if (override && override.trim().length > 0) {
|
|
110
|
+
return resolve(override.trim());
|
|
111
|
+
}
|
|
112
|
+
return resolve(homedir(), WORKFLOW_DIRNAME, 'flow-release-state.json');
|
|
113
|
+
}
|
|
114
|
+
/** Env var to override where the background worker's PID file lives (src/worker/lifecycle.ts). */
|
|
115
|
+
export const WORKER_PID_PATH_ENV = 'WEBFLOW_WORKER_PID_PATH';
|
|
116
|
+
/**
|
|
117
|
+
* Tracks the detached worker process started by the start_worker MCP tool
|
|
118
|
+
* (src/worker/lifecycle.ts), so stop_worker/worker_status can find it again from a
|
|
119
|
+
* different process/session. Honors WEBFLOW_WORKER_PID_PATH, otherwise defaults to
|
|
120
|
+
* ~/.webflow/worker.pid.
|
|
121
|
+
*/
|
|
122
|
+
export function getWorkerPidPath() {
|
|
123
|
+
const override = process.env[WORKER_PID_PATH_ENV];
|
|
124
|
+
if (override && override.trim().length > 0) {
|
|
125
|
+
return resolve(override.trim());
|
|
126
|
+
}
|
|
127
|
+
return resolve(homedir(), WORKFLOW_DIRNAME, 'worker.pid');
|
|
128
|
+
}
|
|
129
|
+
/** stdout/stderr of the detached worker started by start_worker — there is no other
|
|
130
|
+
* way to see its output once the MCP tool call that launched it has returned. */
|
|
131
|
+
export function getWorkerLogPath() {
|
|
132
|
+
return resolve(dirname(getWorkerPidPath()), 'worker.log');
|
|
133
|
+
}
|
|
134
|
+
export function assertSupportedNode(version = process.versions.node) {
|
|
135
|
+
const major = Number.parseInt(version.split('.')[0] ?? '', 10);
|
|
136
|
+
if (Number.isNaN(major) || major < MIN_NODE_MAJOR) {
|
|
137
|
+
throw new WebFlowError('UNSUPPORTED_NODE_VERSION', `WebFlow MCP requires Node ${MIN_NODE_MAJOR}+; detected ${version}.`, { version, required: `${MIN_NODE_MAJOR}+` });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export function sanitizeName(input) {
|
|
141
|
+
const normalized = input.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9_-]/g, '-').replace(/-+/g, '-');
|
|
142
|
+
const sanitized = normalized.replace(/^[-_.]+|[-_.]+$/g, '');
|
|
143
|
+
if (!sanitized) {
|
|
144
|
+
throw new WebFlowError('INVALID_NAME', `Invalid name: "${input}". Use letters, numbers, dashes, or underscores.`);
|
|
145
|
+
}
|
|
146
|
+
return sanitized;
|
|
147
|
+
}
|
|
148
|
+
export function getWorkspacePaths(projectRoot) {
|
|
149
|
+
const workspaceRoot = resolve(projectRoot, WORKFLOW_DIRNAME);
|
|
150
|
+
return {
|
|
151
|
+
projectRoot,
|
|
152
|
+
workspaceRoot,
|
|
153
|
+
flowsDir: resolve(workspaceRoot, 'flows'),
|
|
154
|
+
skillsDir: resolve(workspaceRoot, 'skills'),
|
|
155
|
+
// Sessions are global (log in once, reuse everywhere); downloads stay per-repo.
|
|
156
|
+
profilesDir: getProfilesRoot(),
|
|
157
|
+
downloadsDir: resolve(workspaceRoot, 'downloads'),
|
|
158
|
+
cacheDir: resolve(workspaceRoot, '.cache')
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Resolve the workspace root that actually holds the flows, decoupled from the
|
|
163
|
+
* download/working root (Model B). Priority: WEBFLOW_FLOWS_DIR, then the calling
|
|
164
|
+
* repo if it has its own .webflow/flows, then the installed package's bundled flows.
|
|
165
|
+
* This lets an agent in any repo use the flows shipped here while its downloads stay
|
|
166
|
+
* in its own repo (the projectRoot).
|
|
167
|
+
*/
|
|
168
|
+
export function resolveFlowsProjectRoot(projectRoot) {
|
|
169
|
+
const override = process.env[FLOWS_DIR_ENV];
|
|
170
|
+
if (override && override.trim().length > 0) {
|
|
171
|
+
return resolve(override.trim());
|
|
172
|
+
}
|
|
173
|
+
const root = resolve(projectRoot);
|
|
174
|
+
try {
|
|
175
|
+
if (statSync(resolve(root, WORKFLOW_DIRNAME, 'flows')).isDirectory()) {
|
|
176
|
+
return root;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// no local flows in the calling repo — fall through to the package's flows
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
return getPackageRoot(import.meta.url);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return root;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
export async function assertWorkspaceReady(projectRoot) {
|
|
190
|
+
const root = resolve(projectRoot);
|
|
191
|
+
try {
|
|
192
|
+
const rootStat = await stat(root);
|
|
193
|
+
if (!rootStat.isDirectory()) {
|
|
194
|
+
throw new Error('not a directory');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
throw new WebFlowError('INVALID_PROJECT_ROOT', `Project root does not exist or is not a directory: ${root}`, { projectRoot: root });
|
|
199
|
+
}
|
|
200
|
+
const flowsRoot = resolveFlowsProjectRoot(root);
|
|
201
|
+
const paths = getWorkspacePaths(flowsRoot);
|
|
202
|
+
try {
|
|
203
|
+
const flowsStat = await stat(paths.flowsDir);
|
|
204
|
+
if (!flowsStat.isDirectory()) {
|
|
205
|
+
throw new Error('not a directory');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
throw new WebFlowError('WORKSPACE_FLOWS_NOT_FOUND', `Workspace flows directory not found at ${paths.flowsDir}. Run webflow init, or set ${FLOWS_DIR_ENV} to a workspace that has .webflow/flows.`, { projectRoot: root, flowsDir: paths.flowsDir });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
export function getPackageRoot(metaUrl) {
|
|
213
|
+
let current = dirname(fileURLToPath(metaUrl));
|
|
214
|
+
while (true) {
|
|
215
|
+
try {
|
|
216
|
+
statSync(resolve(current, 'package.json'));
|
|
217
|
+
statSync(resolve(current, '.webflow/flows/_template'));
|
|
218
|
+
return current;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
const parent = resolve(current, '..');
|
|
222
|
+
if (parent === current) {
|
|
223
|
+
throw new WebFlowError('PACKAGE_ROOT_NOT_FOUND', 'Unable to resolve the WebFlow package root.');
|
|
224
|
+
}
|
|
225
|
+
current = parent;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Gates the flow-author-only CLI commands (init/create/fork/doctor — scaffolding and
|
|
231
|
+
* diagnosing flows for the catalog, not something an end user running automations
|
|
232
|
+
* should ever see or reach). Detects the maintainer's own git checkout by checking
|
|
233
|
+
* for `.git` next to package.json — this can never end up in what an end user
|
|
234
|
+
* installs, because package.json's "files" field only ships `dist/src/` (never `.git`,
|
|
235
|
+
* never the top-level `src/`), so the check is a structural consequence of packaging
|
|
236
|
+
* itself, not a separate flag that could drift out of sync with it.
|
|
237
|
+
*/
|
|
238
|
+
export function isAuthorEnvironment(metaUrl) {
|
|
239
|
+
return isAuthorPackageRoot(getPackageRoot(metaUrl));
|
|
240
|
+
}
|
|
241
|
+
/** Split out from isAuthorEnvironment so tests can check the underlying rule (does
|
|
242
|
+
* this directory have a `.git`?) against a synthetic package root, without needing to
|
|
243
|
+
* fake an import.meta.url that resolves there via getPackageRoot's directory walk.
|
|
244
|
+
* `.git` is a DIRECTORY in a normal clone but a plain FILE (a `gitdir: ...` pointer)
|
|
245
|
+
* in a git worktree — this repo itself is checked out as a worktree, so checking
|
|
246
|
+
* `.isDirectory()` would wrongly say "not an author environment" on this very box.
|
|
247
|
+
* Either form only ever exists in a real git checkout, never in what an installed
|
|
248
|
+
* package extracts. */
|
|
249
|
+
export function isAuthorPackageRoot(packageRoot) {
|
|
250
|
+
try {
|
|
251
|
+
statSync(resolve(packageRoot, '.git'));
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** A simple counting semaphore with FIFO-ordered waiters. */
|
|
2
|
+
export class Semaphore {
|
|
3
|
+
available;
|
|
4
|
+
waiters = [];
|
|
5
|
+
constructor(permits) {
|
|
6
|
+
this.available = permits;
|
|
7
|
+
}
|
|
8
|
+
async acquire() {
|
|
9
|
+
if (this.available > 0) {
|
|
10
|
+
this.available -= 1;
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
await new Promise((resolve) => this.waiters.push(resolve));
|
|
14
|
+
}
|
|
15
|
+
release() {
|
|
16
|
+
const next = this.waiters.shift();
|
|
17
|
+
if (next) {
|
|
18
|
+
next();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
this.available += 1;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface TaxonomyService {
|
|
2
|
+
label: string;
|
|
3
|
+
description?: string;
|
|
4
|
+
}
|
|
5
|
+
export interface TaxonomySector {
|
|
6
|
+
label: string;
|
|
7
|
+
services: Record<string, TaxonomyService>;
|
|
8
|
+
}
|
|
9
|
+
export interface Taxonomy {
|
|
10
|
+
sectors: Record<string, TaxonomySector>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Loads the canonical sector/service registry (`taxonomy.json`, shipped at the package root
|
|
14
|
+
* alongside `package.json`). This is the single source of truth `webflow doctor` and
|
|
15
|
+
* `relay/scripts/sync-catalog.ts` validate `capabilities.category`/`capabilities.service`
|
|
16
|
+
* against — always resolved from this package's own install location, never from the
|
|
17
|
+
* `projectRoot` being audited, so it stays identical across every project that uses this CLI.
|
|
18
|
+
*/
|
|
19
|
+
export declare function loadTaxonomy(): Promise<Taxonomy>;
|
|
20
|
+
export declare function isValidSector(taxonomy: Taxonomy, category: string): boolean;
|
|
21
|
+
export declare function isValidService(taxonomy: Taxonomy, category: string, service: string): boolean;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { getPackageRoot } from './paths.js';
|
|
2
|
+
import { readJsonFile } from './fs.js';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Loads the canonical sector/service registry (`taxonomy.json`, shipped at the package root
|
|
6
|
+
* alongside `package.json`). This is the single source of truth `webflow doctor` and
|
|
7
|
+
* `relay/scripts/sync-catalog.ts` validate `capabilities.category`/`capabilities.service`
|
|
8
|
+
* against — always resolved from this package's own install location, never from the
|
|
9
|
+
* `projectRoot` being audited, so it stays identical across every project that uses this CLI.
|
|
10
|
+
*/
|
|
11
|
+
export async function loadTaxonomy() {
|
|
12
|
+
const packageRoot = getPackageRoot(import.meta.url);
|
|
13
|
+
return readJsonFile(resolve(packageRoot, 'taxonomy.json'));
|
|
14
|
+
}
|
|
15
|
+
export function isValidSector(taxonomy, category) {
|
|
16
|
+
return category in taxonomy.sectors;
|
|
17
|
+
}
|
|
18
|
+
export function isValidService(taxonomy, category, service) {
|
|
19
|
+
return Boolean(taxonomy.sectors[category]?.services[service]);
|
|
20
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { type FlowTrustPolicyEntry } from '../security/flow-release.js';
|
|
3
|
+
import type { WorkerService } from './worker-service.js';
|
|
4
|
+
export interface WorkerConnectionOptions {
|
|
5
|
+
relayUrl: string;
|
|
6
|
+
credential: string;
|
|
7
|
+
workerId: string;
|
|
8
|
+
service: WorkerService;
|
|
9
|
+
trustPolicy?: readonly FlowTrustPolicyEntry[];
|
|
10
|
+
workerVersion?: string;
|
|
11
|
+
heartbeatIntervalMs?: number;
|
|
12
|
+
reconnectBaseDelayMs?: number;
|
|
13
|
+
reconnectMaxDelayMs?: number;
|
|
14
|
+
onLog?: (message: string) => void;
|
|
15
|
+
/** Test hook: build the socket instead of dialing relayUrl for real. */
|
|
16
|
+
createSocket?: (url: string) => WebSocket;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Outbound WebSocket client that lets a local worker be driven by a remote relay
|
|
20
|
+
* without ever opening an inbound port on the user's machine (RFC 0001, section 2.1).
|
|
21
|
+
* Owns reconnection/backoff and heartbeating; all flow execution is delegated to
|
|
22
|
+
* WorkerService, which is the only piece that knows about FlowRunner/Chrome.
|
|
23
|
+
*/
|
|
24
|
+
export declare class WorkerConnection {
|
|
25
|
+
private readonly options;
|
|
26
|
+
private socket;
|
|
27
|
+
private heartbeatTimer;
|
|
28
|
+
private reconnectTimer;
|
|
29
|
+
private reconnectAttempt;
|
|
30
|
+
private stopped;
|
|
31
|
+
private helloSentSocket;
|
|
32
|
+
private authenticatedSocket;
|
|
33
|
+
constructor(options: WorkerConnectionOptions);
|
|
34
|
+
start(): void;
|
|
35
|
+
stop(): void;
|
|
36
|
+
private log;
|
|
37
|
+
private connect;
|
|
38
|
+
/**
|
|
39
|
+
* Sent on every connect AND every reconnect with the full catalog (never a diff),
|
|
40
|
+
* so a worker that changed its flows on disk never leaves the relay serving a
|
|
41
|
+
* stale tool list (RFC 0001, section 2.3).
|
|
42
|
+
*/
|
|
43
|
+
private sendHello;
|
|
44
|
+
private send;
|
|
45
|
+
private startHeartbeat;
|
|
46
|
+
private clearHeartbeat;
|
|
47
|
+
private scheduleReconnect;
|
|
48
|
+
private handleMessage;
|
|
49
|
+
/**
|
|
50
|
+
* A dropped socket mid-job is an accepted v1 gap (RFC 0001, section 6, edge case 2):
|
|
51
|
+
* the flow keeps running locally against the user's real Chrome, but its result is
|
|
52
|
+
* bound to the socket that received the job. If that socket disconnects, the result
|
|
53
|
+
* is dropped instead of leaking into a new unauthenticated connection. Revisit before
|
|
54
|
+
* relying on this for anything
|
|
55
|
+
* business-critical.
|
|
56
|
+
*/
|
|
57
|
+
private handleRun;
|
|
58
|
+
private rejectMalformedRun;
|
|
59
|
+
private runSignedRemoteFlow;
|
|
60
|
+
private approvalCommand;
|
|
61
|
+
/** Pure local disk I/O (~/.webflow/config.json) — should never take long enough for
|
|
62
|
+
* the relay's short request timeout to matter in practice. */
|
|
63
|
+
private handleConfigRequest;
|
|
64
|
+
/** Pure local disk I/O (~/.webflow/repertoire.json) — same near-instant expectation as config. */
|
|
65
|
+
private handleRepertoireRequest;
|
|
66
|
+
/**
|
|
67
|
+
* `login` opens a real, visible Chrome window and returns as soon as it's up
|
|
68
|
+
* (see WorkerService.startProfileLogin) — still well within the relay's short
|
|
69
|
+
* localRequestTimeoutMs, unlike waiting for the human to actually finish logging in.
|
|
70
|
+
*/
|
|
71
|
+
private handleProfileRequest;
|
|
72
|
+
}
|