webflow-mcp 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/.webflow/flows/_template/index.ts +14 -0
  2. package/.webflow/flows/_template/schema.json +11 -0
  3. package/.webflow/skills/README.md +3 -0
  4. package/README.md +215 -0
  5. package/dist/src/anti-block/provider-gate.d.ts +19 -0
  6. package/dist/src/anti-block/provider-gate.js +116 -0
  7. package/dist/src/cli/commands/approvals.d.ts +5 -0
  8. package/dist/src/cli/commands/approvals.js +69 -0
  9. package/dist/src/cli/commands/config.d.ts +4 -0
  10. package/dist/src/cli/commands/config.js +56 -0
  11. package/dist/src/cli/commands/create-flow.d.ts +4 -0
  12. package/dist/src/cli/commands/create-flow.js +27 -0
  13. package/dist/src/cli/commands/doctor.d.ts +19 -0
  14. package/dist/src/cli/commands/doctor.js +241 -0
  15. package/dist/src/cli/commands/fork.d.ts +4 -0
  16. package/dist/src/cli/commands/fork.js +19 -0
  17. package/dist/src/cli/commands/init.d.ts +4 -0
  18. package/dist/src/cli/commands/init.js +22 -0
  19. package/dist/src/cli/commands/login.d.ts +26 -0
  20. package/dist/src/cli/commands/login.js +145 -0
  21. package/dist/src/cli/commands/profiles.d.ts +40 -0
  22. package/dist/src/cli/commands/profiles.js +142 -0
  23. package/dist/src/cli/commands/run.d.ts +10 -0
  24. package/dist/src/cli/commands/run.js +13 -0
  25. package/dist/src/cli/commands/setup.d.ts +11 -0
  26. package/dist/src/cli/commands/setup.js +46 -0
  27. package/dist/src/cli/commands/worker.d.ts +29 -0
  28. package/dist/src/cli/commands/worker.js +30 -0
  29. package/dist/src/cli/index.d.ts +2 -0
  30. package/dist/src/cli/index.js +336 -0
  31. package/dist/src/config/webflow-config.d.ts +35 -0
  32. package/dist/src/config/webflow-config.js +119 -0
  33. package/dist/src/errors/self-healing.d.ts +62 -0
  34. package/dist/src/errors/self-healing.js +77 -0
  35. package/dist/src/mcp/cli.d.ts +2 -0
  36. package/dist/src/mcp/cli.js +36 -0
  37. package/dist/src/mcp/relay-gateway.d.ts +24 -0
  38. package/dist/src/mcp/relay-gateway.js +208 -0
  39. package/dist/src/mcp/server.d.ts +8 -0
  40. package/dist/src/mcp/server.js +62 -0
  41. package/dist/src/mcp/tool-registry.d.ts +10 -0
  42. package/dist/src/mcp/tool-registry.js +366 -0
  43. package/dist/src/profiles/profile-manager.d.ts +33 -0
  44. package/dist/src/profiles/profile-manager.js +37 -0
  45. package/dist/src/profiles/profile-state.d.ts +31 -0
  46. package/dist/src/profiles/profile-state.js +122 -0
  47. package/dist/src/repertoire/local-repertoire.d.ts +3 -0
  48. package/dist/src/repertoire/local-repertoire.js +59 -0
  49. package/dist/src/runner/browser-session.d.ts +23 -0
  50. package/dist/src/runner/browser-session.js +58 -0
  51. package/dist/src/runner/cdp-session.d.ts +25 -0
  52. package/dist/src/runner/cdp-session.js +156 -0
  53. package/dist/src/runner/chrome.d.ts +18 -0
  54. package/dist/src/runner/chrome.js +56 -0
  55. package/dist/src/runner/flow-loader.d.ts +11 -0
  56. package/dist/src/runner/flow-loader.js +115 -0
  57. package/dist/src/runner/flow-runner.d.ts +70 -0
  58. package/dist/src/runner/flow-runner.js +378 -0
  59. package/dist/src/runner/types.d.ts +112 -0
  60. package/dist/src/runner/types.js +1 -0
  61. package/dist/src/schema/translator.d.ts +5 -0
  62. package/dist/src/schema/translator.js +104 -0
  63. package/dist/src/schema/types.d.ts +32 -0
  64. package/dist/src/schema/types.js +1 -0
  65. package/dist/src/security/flow-approvals.d.ts +34 -0
  66. package/dist/src/security/flow-approvals.js +324 -0
  67. package/dist/src/security/flow-release.d.ts +82 -0
  68. package/dist/src/security/flow-release.js +392 -0
  69. package/dist/src/security/official-flow-trust.d.ts +2 -0
  70. package/dist/src/security/official-flow-trust.js +16 -0
  71. package/dist/src/security/trusted-flow-keys.d.ts +8 -0
  72. package/dist/src/security/trusted-flow-keys.js +132 -0
  73. package/dist/src/shared/credentials.d.ts +21 -0
  74. package/dist/src/shared/credentials.js +99 -0
  75. package/dist/src/shared/errors.d.ts +6 -0
  76. package/dist/src/shared/errors.js +16 -0
  77. package/dist/src/shared/fs.d.ts +5 -0
  78. package/dist/src/shared/fs.js +23 -0
  79. package/dist/src/shared/paths.d.ts +112 -0
  80. package/dist/src/shared/paths.js +257 -0
  81. package/dist/src/shared/semaphore.d.ts +8 -0
  82. package/dist/src/shared/semaphore.js +23 -0
  83. package/dist/src/shared/taxonomy.d.ts +21 -0
  84. package/dist/src/shared/taxonomy.js +20 -0
  85. package/dist/src/worker/connection.d.ts +72 -0
  86. package/dist/src/worker/connection.js +427 -0
  87. package/dist/src/worker/lifecycle.d.ts +50 -0
  88. package/dist/src/worker/lifecycle.js +154 -0
  89. package/dist/src/worker/protocol.d.ts +188 -0
  90. package/dist/src/worker/protocol.js +156 -0
  91. package/dist/src/worker/worker-service.d.ts +112 -0
  92. package/dist/src/worker/worker-service.js +258 -0
  93. package/package.json +50 -0
  94. package/taxonomy.json +99 -0
@@ -0,0 +1,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
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "type": "object",
3
+ "description": "Template input schema for a WebFlow flow.",
4
+ "properties": {
5
+ "message": {
6
+ "type": "string",
7
+ "description": "Message to echo from the flow."
8
+ }
9
+ },
10
+ "required": ["message"]
11
+ }
@@ -0,0 +1,3 @@
1
+ # WebFlow Skills
2
+
3
+ Place local project skills here.
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,4 @@
1
+ export declare function createFlow(projectRoot: string, name: string): Promise<{
2
+ name: string;
3
+ dir: string;
4
+ }>;
@@ -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;