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,14 @@
|
|
|
1
|
+
// export const capabilities = {
|
|
2
|
+
// browser: true,
|
|
3
|
+
// category: 'pick-a-sector-from-taxonomy.json',
|
|
4
|
+
// service: 'pick-a-service-under-that-sector',
|
|
5
|
+
// requiresLogin: false
|
|
6
|
+
// };
|
|
7
|
+
|
|
8
|
+
export async function run(ctx: { flowName: string; profile: { id: string; dir: string } }, input: { message: string }) {
|
|
9
|
+
return {
|
|
10
|
+
flow: ctx.flowName,
|
|
11
|
+
profile: ctx.profile.id,
|
|
12
|
+
echoed: input.message
|
|
13
|
+
};
|
|
14
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# WebFlow MCP
|
|
2
|
+
|
|
3
|
+
Local worker and unified MCP gateway for the WebFlowMCP signed remote automation catalog. A legacy
|
|
4
|
+
local-authoring mode can still discover `.webflow/flows/*` and expose each valid flow directly.
|
|
5
|
+
|
|
6
|
+
## Requirements
|
|
7
|
+
|
|
8
|
+
- Node 20+
|
|
9
|
+
- npm 10+
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install
|
|
15
|
+
npm run build
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
For an installed package:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install -g webflow-mcp
|
|
22
|
+
webflow login
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`webflow login` creates a worker-only credential locally and opens the dashboard for browser approval.
|
|
26
|
+
It never asks for the separate MCP token.
|
|
27
|
+
|
|
28
|
+
## Unified MCP gateway
|
|
29
|
+
|
|
30
|
+
Normal SaaS use configures one local MCP. It provides worker lifecycle, browser preferences, profile
|
|
31
|
+
administration and signed-flow approvals locally, while proxying catalog and execution calls to the relay.
|
|
32
|
+
|
|
33
|
+
OpenCode example:
|
|
34
|
+
|
|
35
|
+
```jsonc
|
|
36
|
+
{
|
|
37
|
+
"mcp": {
|
|
38
|
+
"webflow": {
|
|
39
|
+
"type": "local",
|
|
40
|
+
"command": ["webflow-mcp"],
|
|
41
|
+
"environment": {
|
|
42
|
+
"WEBFLOW_RELAY_MCP_URL": "https://relay.webflowmcp.es/mcp",
|
|
43
|
+
"WEBFLOW_RELAY_MCP_TOKEN": "{env:WEBFLOW_MCP_TOKEN}"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Set `WEBFLOW_MCP_TOKEN` outside a checked-in project config to the `tok_...` value created in the
|
|
51
|
+
dashboard. The MCP token authenticates only `/mcp`; the background worker uses its independent
|
|
52
|
+
browser-paired `wkr_...` credential. Start and stop the worker through `start_worker`/`stop_worker`.
|
|
53
|
+
|
|
54
|
+
Gateway mode exposes a stable catalog surface rather than one dynamic tool per saved flow. Discover with
|
|
55
|
+
`browse_catalog`/`search_flows`, inspect with `describe_flow`, and execute saved flows with
|
|
56
|
+
`run_flow_with_options`.
|
|
57
|
+
|
|
58
|
+
## CLI
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
webflow init
|
|
62
|
+
webflow create flow checkout
|
|
63
|
+
webflow fork checkout checkout-copy
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The workspace contract is:
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
.webflow/
|
|
70
|
+
flows/
|
|
71
|
+
_template/
|
|
72
|
+
<flow>/
|
|
73
|
+
schema.json
|
|
74
|
+
index.ts
|
|
75
|
+
skills/
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Provider profiles are machine-global under `~/.webflow/profiles/`; they are not part of a workspace.
|
|
79
|
+
|
|
80
|
+
## Authoring a flow
|
|
81
|
+
|
|
82
|
+
Each flow must export `run(ctx, input)` from `index.ts` and define a supported JSON-schema subset in `schema.json`:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
export async function run(ctx, input) {
|
|
86
|
+
return { flow: ctx.flowName, echoed: input.message };
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Supported schema keys: `type`, `properties`, `required`, `items`, `enum`, `description`, `default`.
|
|
91
|
+
|
|
92
|
+
## Legacy local-authoring MCP
|
|
93
|
+
|
|
94
|
+
The `projectRoot` argument is the absolute path to **the repo you are working in** — it decides where downloads land. The flows themselves do not need to live there: if `projectRoot` has no `.webflow/flows`, the server automatically uses the flows shipped with this package (Model B). So a single flows repo serves many working repos, and each repo keeps its own outputs.
|
|
95
|
+
|
|
96
|
+
During development from this repo:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
node --import tsx src/mcp/cli.ts /absolute/path/to/your-working-repo
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Or after building:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
npm run build
|
|
106
|
+
node dist/src/mcp/cli.js /absolute/path/to/your-working-repo
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
For MCP client configuration **from another repo**, point the entrypoint at this package and the argument at that repo. Flows come from here; downloads stay in that repo:
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"mcpServers": {
|
|
114
|
+
"webflow": {
|
|
115
|
+
"command": "node",
|
|
116
|
+
"args": [
|
|
117
|
+
"C:/absolute/path/to/webflow-mcp/dist/src/mcp/cli.js",
|
|
118
|
+
"C:/absolute/path/to/your-other-repo"
|
|
119
|
+
]
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
To override where flows are loaded from, set `WEBFLOW_FLOWS_DIR` to a workspace that has `.webflow/flows`. The server falls back to `CLAUDE_PROJECT_DIR` then `cwd` when no argument is given, and prints which flows root, session store, and downloads dir it resolved as startup diagnostics.
|
|
126
|
+
|
|
127
|
+
On Windows, `run-mcp.bat` is only a local helper. It requires `npm run build` first and expects the workspace root as its first argument:
|
|
128
|
+
|
|
129
|
+
```bat
|
|
130
|
+
run-mcp.bat C:\absolute\path\to\workspace
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Doctor
|
|
134
|
+
|
|
135
|
+
Use `doctor` before wiring MCP from another repo:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
webflow doctor /absolute/path/to/workspace
|
|
139
|
+
node dist/src/cli/index.js doctor /absolute/path/to/workspace
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`doctor` checks Node, the workspace layout, exposed flows, parseable schemas, build output, Playwright/Chromium availability, and warns about fragile flow patterns such as fixed CDP port `9222`, `taskkill`, `Profile 1`, `process.cwd()`, hardcoded Windows paths, or `connectOverCDP`.
|
|
143
|
+
|
|
144
|
+
## Sessions vs downloads
|
|
145
|
+
|
|
146
|
+
Two things are deliberately decoupled:
|
|
147
|
+
|
|
148
|
+
- **Login sessions are global.** Chrome profiles live in one shared store, default `~/.webflow/profiles` (override with `WEBFLOW_PROFILES_DIR`). Log in once and every workspace reuses the session — you do not re-authenticate per repo.
|
|
149
|
+
- **Downloads are per-repo.** Each flow writes to `<projectRoot>/.webflow/downloads/<flow>/...`, so outputs are organized by the workspace you ran from. Point the MCP server's `projectRoot` at a repo and that repo collects its own downloads while sharing the global login.
|
|
150
|
+
|
|
151
|
+
The MCP server prints both locations as diagnostics on startup.
|
|
152
|
+
|
|
153
|
+
## Browser Flow Setup
|
|
154
|
+
|
|
155
|
+
Run `webflow setup <flow>` once per profile to log in. By default this opens a **plain real Chrome window with no automation flags**, pointed at the flow's dedicated global profile. Log in, accept any prompts, then close the window — the session persists and is reused by automated runs. This avoids the "unsafe browser" block that strict providers raise against automation-controlled login windows.
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
webflow setup chatgpt-prompt
|
|
159
|
+
webflow setup google-speech-generate --url https://aistudio.google.com
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Flows share a profile by declaring the same `capabilities.profile` (for example `chatgpt-prompt` and `chatgpt-image-generate` both use `chatgpt`), so one login covers the whole provider.
|
|
163
|
+
|
|
164
|
+
### Browser modes
|
|
165
|
+
|
|
166
|
+
A flow declares how it drives the browser via `capabilities.browser`:
|
|
167
|
+
|
|
168
|
+
- `true` / `'managed'` — Playwright-managed persistent profile (default). Clean, isolated, works headless from any repo. Used by ChatGPT, Gemini app, X, and the scrapers.
|
|
169
|
+
- `'cdp'` — the runner attaches to a real Chrome over CDP. Used by strict providers (`google-speech-generate` for Google AI Studio, `adobe-lipsync`). The runner launches Chrome against the dedicated profile on a free port, connects, and **closes that window when the flow finishes** (it owns what it launched). If you instead attach to a Chrome you already had open via `cdpUrl`, it only disconnects and leaves your window alone. Optional per-run overrides: `cdpUrl`, `chromePath`, `userDataDir`.
|
|
170
|
+
|
|
171
|
+
Flows consume `ctx.page`/`ctx.browser` and are mode-agnostic, so switching a flow between `managed` and `cdp` needs no code changes.
|
|
172
|
+
|
|
173
|
+
### Verifying whether a strict provider can use the managed profile
|
|
174
|
+
|
|
175
|
+
`'cdp'` is the proven path for AI Studio/Adobe; the managed profile is unverified there. To test whether managed + one-time login is enough (and unify on a single mechanism), run a `cdp` flow once with:
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
# PowerShell: $env:WEBFLOW_CDP_AS_MANAGED = '1'
|
|
179
|
+
WEBFLOW_CDP_AS_MANAGED=1 webflow run google-speech-generate --headed --input '{ ... }'
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
If the provider accepts the login, switch that flow's `capabilities.browser` to `'managed'`. If it rejects, leave it on `'cdp'`.
|
|
183
|
+
|
|
184
|
+
### Running multiple agents in parallel
|
|
185
|
+
|
|
186
|
+
A Chrome user-data-dir can be opened by only one process at a time, so parallel agents cannot share one profile directory. Set `WEBFLOW_PROFILE_INSTANCE=<id>` per agent: the runner clones the logged-in base profile into a per-instance directory on first use. Off by default (a single agent reuses the shared base profile). Note that some providers may then ask the extra session to re-verify as a new device.
|
|
187
|
+
|
|
188
|
+
### Signed remote flows
|
|
189
|
+
|
|
190
|
+
Official relays send immutable Ed25519-signed flow releases. The worker verifies the relay-bound public
|
|
191
|
+
key, manifest and exact code/schema/capability hashes before importing any code. Every new flow digest
|
|
192
|
+
also requires an explicit local decision. Through the unified MCP, call `list_flow_approvals`, inspect the
|
|
193
|
+
full digest/signer/trust domain, ask the user, and then call `approve_flow_update` with the exact returned
|
|
194
|
+
identity and `confirmed:true`. The interactive CLI remains available:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
webflow approvals list
|
|
198
|
+
webflow approvals approve <flow> --trust-domain <domain>
|
|
199
|
+
webflow approvals revoke <flow> --trust-domain <domain>
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The remote relay cannot approve a release; MCP approval tools mutate only worker-local state. Their
|
|
203
|
+
`confirmed:true` argument is a user-interaction convention, while typing `APPROVE` in the CLI remains the
|
|
204
|
+
stronger manual path. Flow source is not installed as a local catalog, but code
|
|
205
|
+
executed on a user-controlled computer cannot be made impossible for that computer's owner to capture.
|
|
206
|
+
Signed code still has the worker process's OS permissions; this release does not add a sandbox.
|
|
207
|
+
Signing keys and release commands are operator-only; end users never generate or manage signing keys.
|
|
208
|
+
|
|
209
|
+
## Verification
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
npm test
|
|
213
|
+
npm run smoke
|
|
214
|
+
npm pack --dry-run --json
|
|
215
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type ProviderSignal = 'ok' | 'blocked' | 'verification_required' | 'rate_limited' | 'page_not_found' | 'unknown';
|
|
2
|
+
export interface AntiBlockPolicy {
|
|
3
|
+
provider?: string;
|
|
4
|
+
minIntervalMs?: number;
|
|
5
|
+
openAfterFailures?: number;
|
|
6
|
+
cooldownMs?: number;
|
|
7
|
+
tripOnPageNotFound?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function deriveProviderKey(flowName: string, capabilities?: {
|
|
10
|
+
service?: string;
|
|
11
|
+
profile?: string;
|
|
12
|
+
startUrl?: string;
|
|
13
|
+
antiBlock?: AntiBlockPolicy;
|
|
14
|
+
}): string;
|
|
15
|
+
export declare function classifyProviderSignal(error: unknown, tripOnPageNotFound?: boolean): ProviderSignal;
|
|
16
|
+
export declare class ProviderGate {
|
|
17
|
+
enter(provider: string, policy?: AntiBlockPolicy): Promise<() => void>;
|
|
18
|
+
record(provider: string, signal: ProviderSignal, policy?: AntiBlockPolicy): Promise<void>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { ensureDir, readJsonFile, writeJsonFile } from '../shared/fs.js';
|
|
3
|
+
import { getAntiBlockDir, sanitizeName } from '../shared/paths.js';
|
|
4
|
+
import { WebFlowError } from '../shared/errors.js';
|
|
5
|
+
const DEFAULT_POLICY = {
|
|
6
|
+
minIntervalMs: 5_000,
|
|
7
|
+
openAfterFailures: 2,
|
|
8
|
+
cooldownMs: 10 * 60_000
|
|
9
|
+
};
|
|
10
|
+
const queues = new Map();
|
|
11
|
+
function statePath(provider) {
|
|
12
|
+
return resolve(getAntiBlockDir(), `${sanitizeName(provider)}.json`);
|
|
13
|
+
}
|
|
14
|
+
async function readState(provider) {
|
|
15
|
+
try {
|
|
16
|
+
const value = await readJsonFile(statePath(provider));
|
|
17
|
+
return {
|
|
18
|
+
lastAttemptAt: Number(value.lastAttemptAt) || 0,
|
|
19
|
+
consecutiveFailures: Number(value.consecutiveFailures) || 0,
|
|
20
|
+
cooldownUntil: Number(value.cooldownUntil) || 0,
|
|
21
|
+
lastSignal: typeof value.lastSignal === 'string' ? value.lastSignal : 'unknown'
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return { lastAttemptAt: 0, consecutiveFailures: 0, cooldownUntil: 0, lastSignal: 'unknown' };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function writeState(provider, state) {
|
|
29
|
+
await ensureDir(getAntiBlockDir());
|
|
30
|
+
await writeJsonFile(statePath(provider), { provider, ...state, updatedAt: new Date().toISOString() });
|
|
31
|
+
}
|
|
32
|
+
function sleep(ms) {
|
|
33
|
+
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
34
|
+
}
|
|
35
|
+
export function deriveProviderKey(flowName, capabilities) {
|
|
36
|
+
if (capabilities?.antiBlock?.provider)
|
|
37
|
+
return sanitizeName(capabilities.antiBlock.provider);
|
|
38
|
+
if (capabilities?.service)
|
|
39
|
+
return sanitizeName(capabilities.service);
|
|
40
|
+
if (capabilities?.profile)
|
|
41
|
+
return sanitizeName(capabilities.profile);
|
|
42
|
+
if (capabilities?.startUrl && capabilities.startUrl !== 'about:blank') {
|
|
43
|
+
try {
|
|
44
|
+
return sanitizeName(new URL(capabilities.startUrl).hostname);
|
|
45
|
+
}
|
|
46
|
+
catch { /* fall through */ }
|
|
47
|
+
}
|
|
48
|
+
return sanitizeName(flowName);
|
|
49
|
+
}
|
|
50
|
+
export function classifyProviderSignal(error, tripOnPageNotFound = false) {
|
|
51
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
52
|
+
if (/\[VERIFICATION_REQUIRED\]|verify you are human|checking your browser/i.test(message))
|
|
53
|
+
return 'verification_required';
|
|
54
|
+
if (/\[RATE_LIMITED\]|too many requests|rate limit|\b429\b/i.test(message))
|
|
55
|
+
return 'rate_limited';
|
|
56
|
+
if (/\[BLOCKED\]|captcha|access denied|cloudflare|datadome|actividad inusual|unusual traffic|just a moment/i.test(message))
|
|
57
|
+
return 'blocked';
|
|
58
|
+
if (tripOnPageNotFound && /\[PAGE_NOT_FOUND\]/i.test(message))
|
|
59
|
+
return 'page_not_found';
|
|
60
|
+
return 'unknown';
|
|
61
|
+
}
|
|
62
|
+
export class ProviderGate {
|
|
63
|
+
async enter(provider, policy = {}) {
|
|
64
|
+
const key = sanitizeName(provider);
|
|
65
|
+
const previous = queues.get(key) ?? Promise.resolve();
|
|
66
|
+
let releaseQueue = () => { };
|
|
67
|
+
const current = new Promise((resolvePromise) => { releaseQueue = resolvePromise; });
|
|
68
|
+
const queued = previous.catch(() => undefined).then(() => current);
|
|
69
|
+
queues.set(key, queued);
|
|
70
|
+
await previous.catch(() => undefined);
|
|
71
|
+
try {
|
|
72
|
+
const state = await readState(key);
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
if (state.cooldownUntil > now) {
|
|
75
|
+
throw new WebFlowError('PROVIDER_COOLDOWN', `Provider "${key}" is cooling down until ${new Date(state.cooldownUntil).toISOString()}.`, {
|
|
76
|
+
provider: key,
|
|
77
|
+
retryAfter: new Date(state.cooldownUntil).toISOString(),
|
|
78
|
+
lastSignal: state.lastSignal
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const interval = Math.max(0, policy.minIntervalMs ?? DEFAULT_POLICY.minIntervalMs);
|
|
82
|
+
if (state.lastAttemptAt && now - state.lastAttemptAt < interval)
|
|
83
|
+
await sleep(interval - (now - state.lastAttemptAt));
|
|
84
|
+
await writeState(key, { ...state, lastAttemptAt: Date.now() });
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
releaseQueue();
|
|
88
|
+
if (queues.get(key) === queued)
|
|
89
|
+
queues.delete(key);
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
return () => {
|
|
93
|
+
releaseQueue();
|
|
94
|
+
if (queues.get(key) === queued)
|
|
95
|
+
queues.delete(key);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
async record(provider, signal, policy = {}) {
|
|
99
|
+
const key = sanitizeName(provider);
|
|
100
|
+
const state = await readState(key);
|
|
101
|
+
if (signal === 'ok') {
|
|
102
|
+
await writeState(key, { ...state, consecutiveFailures: 0, cooldownUntil: 0, lastSignal: 'ok' });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (signal === 'unknown')
|
|
106
|
+
return;
|
|
107
|
+
const failures = state.consecutiveFailures + 1;
|
|
108
|
+
const threshold = Math.max(1, policy.openAfterFailures ?? DEFAULT_POLICY.openAfterFailures);
|
|
109
|
+
await writeState(key, {
|
|
110
|
+
...state,
|
|
111
|
+
consecutiveFailures: failures,
|
|
112
|
+
cooldownUntil: failures >= threshold ? Date.now() + Math.max(1_000, policy.cooldownMs ?? DEFAULT_POLICY.cooldownMs) : 0,
|
|
113
|
+
lastSignal: signal
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type ApprovalConfirmer = (message: string) => Promise<void>;
|
|
2
|
+
export declare function formatApprovalState(json?: boolean): Promise<string>;
|
|
3
|
+
export declare function confirmApprovalInTerminal(message: string, isInteractive?: boolean): Promise<void>;
|
|
4
|
+
export declare function approveFlow(flowName: string, trustDomain?: string, confirmer?: ApprovalConfirmer): Promise<string>;
|
|
5
|
+
export declare function revokeFlow(flowName: string, trustDomain?: string, confirmer?: ApprovalConfirmer): Promise<string>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { approvePending, listApprovals, listPending, revokeApproval } from '../../security/flow-approvals.js';
|
|
3
|
+
import { WebFlowError } from '../../shared/errors.js';
|
|
4
|
+
export async function formatApprovalState(json = false) {
|
|
5
|
+
const [approved, pending] = await Promise.all([listApprovals(), listPending()]);
|
|
6
|
+
if (json)
|
|
7
|
+
return JSON.stringify({ approved, pending }, null, 2);
|
|
8
|
+
const lines = [
|
|
9
|
+
...pending.map((entry) => `PENDING ${entry.flowName} ${entry.version} ${entry.flowDigest.slice(0, 16)}... ${entry.trustDomain}`),
|
|
10
|
+
...approved.map((entry) => `APPROVED ${entry.flowName} ${entry.version} ${entry.flowDigest.slice(0, 16)}... ${entry.trustDomain}`)
|
|
11
|
+
];
|
|
12
|
+
return lines.length ? lines.join('\n') : 'No signed flow approvals or pending updates.';
|
|
13
|
+
}
|
|
14
|
+
export async function confirmApprovalInTerminal(message, isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY)) {
|
|
15
|
+
if (!isInteractive) {
|
|
16
|
+
throw new WebFlowError('INTERACTIVE_CONFIRMATION_REQUIRED', 'Flow approval requires an interactive local terminal.');
|
|
17
|
+
}
|
|
18
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
19
|
+
try {
|
|
20
|
+
const answer = await prompt.question(`${message}\nType APPROVE to continue: `);
|
|
21
|
+
if (answer !== 'APPROVE')
|
|
22
|
+
throw new WebFlowError('APPROVAL_CANCELLED', 'Flow approval was cancelled.');
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
prompt.close();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export async function approveFlow(flowName, trustDomain, confirmer = confirmApprovalInTerminal) {
|
|
29
|
+
const matches = (await listPending()).filter((entry) => entry.flowName === flowName && (!trustDomain || entry.trustDomain === trustDomain));
|
|
30
|
+
if (matches.length === 0)
|
|
31
|
+
throw new WebFlowError('FLOW_APPROVAL_NOT_FOUND', `No pending signed update found for flow "${flowName}".`);
|
|
32
|
+
if (matches.length > 1)
|
|
33
|
+
throw new WebFlowError('FLOW_APPROVAL_AMBIGUOUS', `Specify --trust-domain for flow "${flowName}".`);
|
|
34
|
+
const pending = matches[0];
|
|
35
|
+
await confirmer([
|
|
36
|
+
`Approve signed flow update "${pending.flowName}"?`,
|
|
37
|
+
`Version: ${pending.version}`,
|
|
38
|
+
`Digest: ${pending.flowDigest}`,
|
|
39
|
+
`Signer: ${pending.keyId}`,
|
|
40
|
+
`Trust domain: ${pending.trustDomain}`
|
|
41
|
+
].join('\n'));
|
|
42
|
+
const latest = (await listPending()).find((entry) => entry.flowName === pending.flowName && entry.trustDomain === pending.trustDomain);
|
|
43
|
+
if (!latest || latest.flowDigest !== pending.flowDigest || latest.releaseId !== pending.releaseId) {
|
|
44
|
+
throw new WebFlowError('FLOW_APPROVAL_CHANGED', 'The pending signed update changed during confirmation; inspect and approve it again.');
|
|
45
|
+
}
|
|
46
|
+
const approved = await approvePending(flowName, pending.trustDomain, {
|
|
47
|
+
flowDigest: pending.flowDigest,
|
|
48
|
+
releaseId: pending.releaseId,
|
|
49
|
+
sequence: pending.sequence
|
|
50
|
+
});
|
|
51
|
+
return `Approved ${approved.flowName} ${approved.version} (${approved.flowDigest.slice(0, 16)}...).`;
|
|
52
|
+
}
|
|
53
|
+
export async function revokeFlow(flowName, trustDomain, confirmer = confirmApprovalInTerminal) {
|
|
54
|
+
const matches = (await listApprovals()).filter((entry) => entry.flowName === flowName && (!trustDomain || entry.trustDomain === trustDomain));
|
|
55
|
+
if (matches.length === 0)
|
|
56
|
+
return `No approval found for ${flowName}.`;
|
|
57
|
+
if (matches.length > 1)
|
|
58
|
+
throw new WebFlowError('FLOW_APPROVAL_AMBIGUOUS', `Specify --trust-domain for flow "${flowName}".`);
|
|
59
|
+
const approval = matches[0];
|
|
60
|
+
await confirmer([
|
|
61
|
+
`Revoke local approval for flow "${approval.flowName}"?`,
|
|
62
|
+
`Version: ${approval.version}`,
|
|
63
|
+
`Digest: ${approval.flowDigest}`,
|
|
64
|
+
`Signer: ${approval.keyId}`,
|
|
65
|
+
`Trust domain: ${approval.trustDomain}`
|
|
66
|
+
].join('\n'));
|
|
67
|
+
const removed = await revokeApproval(flowName, approval.trustDomain);
|
|
68
|
+
return removed ? `Revoked approval for ${flowName}.` : `No approval found for ${flowName}.`;
|
|
69
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type WebflowConfig } from '../../config/webflow-config.js';
|
|
2
|
+
export declare function formatConfig(config: WebflowConfig): string;
|
|
3
|
+
export declare function getConfig(): Promise<WebflowConfig>;
|
|
4
|
+
export declare function setConfigPreference(kind: string | undefined, scope: string | undefined, nameOrValue: string | undefined, maybeValue?: string): Promise<WebflowConfig>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { loadConfig, setDefaultBrowserMode, setDefaultBrowserVisibility, setFlowBrowserMode, setFlowBrowserVisibility } from '../../config/webflow-config.js';
|
|
2
|
+
import { WebFlowError } from '../../shared/errors.js';
|
|
3
|
+
function isBrowserVisibility(value) {
|
|
4
|
+
return value === 'visible' || value === 'hidden';
|
|
5
|
+
}
|
|
6
|
+
function isDefaultBrowserVisibility(value) {
|
|
7
|
+
return value === 'inherit' || isBrowserVisibility(value);
|
|
8
|
+
}
|
|
9
|
+
function isBrowserMode(value) {
|
|
10
|
+
return value === 'managed' || value === 'cdp';
|
|
11
|
+
}
|
|
12
|
+
function isDefaultBrowserMode(value) {
|
|
13
|
+
return value === 'inherit' || isBrowserMode(value);
|
|
14
|
+
}
|
|
15
|
+
export function formatConfig(config) {
|
|
16
|
+
return [
|
|
17
|
+
'WebFlow config',
|
|
18
|
+
`Browser visibility default: ${config.browserVisibility.default}`,
|
|
19
|
+
`Browser visibility per-flow: ${Object.keys(config.browserVisibility.flows).length ? JSON.stringify(config.browserVisibility.flows) : '(none)'}`,
|
|
20
|
+
`Browser mode default: ${config.browserMode.default}`,
|
|
21
|
+
`Browser mode per-flow: ${Object.keys(config.browserMode.flows).length ? JSON.stringify(config.browserMode.flows) : '(none)'}`
|
|
22
|
+
].join('\n');
|
|
23
|
+
}
|
|
24
|
+
export async function getConfig() {
|
|
25
|
+
return loadConfig();
|
|
26
|
+
}
|
|
27
|
+
export async function setConfigPreference(kind, scope, nameOrValue, maybeValue) {
|
|
28
|
+
if (kind !== 'browser-mode' && kind !== 'browser-visibility') {
|
|
29
|
+
throw new WebFlowError('INVALID_COMMAND', 'Usage: webflow config <get|browser-mode|browser-visibility> ...');
|
|
30
|
+
}
|
|
31
|
+
if (scope !== 'default' && scope !== 'flow') {
|
|
32
|
+
throw new WebFlowError('INVALID_COMMAND', `Usage: webflow config ${kind} <default|flow> ...`);
|
|
33
|
+
}
|
|
34
|
+
if (kind === 'browser-visibility') {
|
|
35
|
+
if (scope === 'default') {
|
|
36
|
+
if (!isDefaultBrowserVisibility(nameOrValue)) {
|
|
37
|
+
throw new WebFlowError('INVALID_COMMAND', 'Browser visibility default must be one of: inherit, visible, hidden.');
|
|
38
|
+
}
|
|
39
|
+
return setDefaultBrowserVisibility(nameOrValue);
|
|
40
|
+
}
|
|
41
|
+
if (!nameOrValue || !isDefaultBrowserVisibility(maybeValue)) {
|
|
42
|
+
throw new WebFlowError('INVALID_COMMAND', 'Usage: webflow config browser-visibility flow <flow> <inherit|visible|hidden>');
|
|
43
|
+
}
|
|
44
|
+
return setFlowBrowserVisibility(nameOrValue, maybeValue === 'inherit' ? null : maybeValue);
|
|
45
|
+
}
|
|
46
|
+
if (scope === 'default') {
|
|
47
|
+
if (!isDefaultBrowserMode(nameOrValue)) {
|
|
48
|
+
throw new WebFlowError('INVALID_COMMAND', 'Browser mode default must be one of: inherit, managed, cdp.');
|
|
49
|
+
}
|
|
50
|
+
return setDefaultBrowserMode(nameOrValue);
|
|
51
|
+
}
|
|
52
|
+
if (!nameOrValue || !isDefaultBrowserMode(maybeValue)) {
|
|
53
|
+
throw new WebFlowError('INVALID_COMMAND', 'Usage: webflow config browser-mode flow <flow> <inherit|managed|cdp>');
|
|
54
|
+
}
|
|
55
|
+
return setFlowBrowserMode(nameOrValue, maybeValue === 'inherit' ? null : maybeValue);
|
|
56
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { ensureDir, pathExists } from '../../shared/fs.js';
|
|
4
|
+
import { WebFlowError } from '../../shared/errors.js';
|
|
5
|
+
import { getWorkspacePaths, sanitizeName } from '../../shared/paths.js';
|
|
6
|
+
export async function createFlow(projectRoot, name) {
|
|
7
|
+
const sanitizedName = sanitizeName(name);
|
|
8
|
+
const paths = getWorkspacePaths(projectRoot);
|
|
9
|
+
const templateDir = resolve(paths.flowsDir, '_template');
|
|
10
|
+
const flowDir = resolve(paths.flowsDir, sanitizedName);
|
|
11
|
+
if (!(await pathExists(templateDir))) {
|
|
12
|
+
throw new WebFlowError('WORKSPACE_NOT_INITIALIZED', 'Run `webflow init` before creating flows.');
|
|
13
|
+
}
|
|
14
|
+
if (await pathExists(flowDir)) {
|
|
15
|
+
throw new WebFlowError('FLOW_ALREADY_EXISTS', `Flow "${sanitizedName}" already exists.`);
|
|
16
|
+
}
|
|
17
|
+
await ensureDir(flowDir);
|
|
18
|
+
const [schemaTemplate, indexTemplate] = await Promise.all([
|
|
19
|
+
readFile(resolve(templateDir, 'schema.json'), 'utf8'),
|
|
20
|
+
readFile(resolve(templateDir, 'index.ts'), 'utf8')
|
|
21
|
+
]);
|
|
22
|
+
await Promise.all([
|
|
23
|
+
writeFile(resolve(flowDir, 'schema.json'), schemaTemplate.replace(/Template input schema/g, `${sanitizedName} flow input schema`), 'utf8'),
|
|
24
|
+
writeFile(resolve(flowDir, 'index.ts'), indexTemplate.replace(/ctx.flowName/g, JSON.stringify(sanitizedName)), 'utf8')
|
|
25
|
+
]);
|
|
26
|
+
return { name: sanitizedName, dir: flowDir };
|
|
27
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type DoctorLevel = 'ok' | 'warn' | 'error';
|
|
2
|
+
export interface DoctorCheck {
|
|
3
|
+
level: DoctorLevel;
|
|
4
|
+
message: string;
|
|
5
|
+
}
|
|
6
|
+
export interface DoctorResult {
|
|
7
|
+
projectRoot: string;
|
|
8
|
+
checks: DoctorCheck[];
|
|
9
|
+
exposedFlows: string[];
|
|
10
|
+
invalidFlows: string[];
|
|
11
|
+
exitCode: number;
|
|
12
|
+
}
|
|
13
|
+
export interface DoctorOptions {
|
|
14
|
+
nodeVersion?: string;
|
|
15
|
+
checkPlaywright?: boolean;
|
|
16
|
+
packageRoot?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function runDoctor(projectRoot: string, options?: DoctorOptions): Promise<DoctorResult>;
|
|
19
|
+
export declare function formatDoctorResult(result: DoctorResult): string;
|