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,366 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { FlowRunner } from '../runner/flow-runner.js';
|
|
3
|
+
import { discoverFlows, loadFlowModule } from '../runner/flow-loader.js';
|
|
4
|
+
import { buildProfileLoginCommand } from '../profiles/profile-state.js';
|
|
5
|
+
import { listProfiles, refreshInstanceProfile, resetProfile, startFlowLogin, statusFlowProfile } from '../cli/commands/profiles.js';
|
|
6
|
+
import { logout as clearLogin } from '../cli/commands/login.js';
|
|
7
|
+
import { startBackgroundWorker, stopBackgroundWorker, isWorkerRunning } from '../worker/lifecycle.js';
|
|
8
|
+
import { approvePending, listApprovals, listPending, revokeApproval } from '../security/flow-approvals.js';
|
|
9
|
+
import { readJsonFile } from '../shared/fs.js';
|
|
10
|
+
import { WebFlowError } from '../shared/errors.js';
|
|
11
|
+
import { translateSchema } from '../schema/translator.js';
|
|
12
|
+
import { loadConfig, setDefaultBrowserMode, setDefaultBrowserVisibility, setFlowBrowserMode, setFlowBrowserVisibility } from '../config/webflow-config.js';
|
|
13
|
+
import { registerRelayGatewayTools } from './relay-gateway.js';
|
|
14
|
+
function normalizeStructuredContent(result) {
|
|
15
|
+
if (typeof result === 'object' && result !== null && !Array.isArray(result)) {
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
return { result };
|
|
19
|
+
}
|
|
20
|
+
function errorToolResult(code, message, details = {}) {
|
|
21
|
+
const error = { code, message, ...details };
|
|
22
|
+
return {
|
|
23
|
+
isError: true,
|
|
24
|
+
content: [{ type: 'text', text: JSON.stringify(error, null, 2) }],
|
|
25
|
+
structuredContent: error
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function caughtErrorToolResult(error, fallbackCode = 'PROFILE_REQUEST_FAILED') {
|
|
29
|
+
const code = error instanceof WebFlowError ? error.code : fallbackCode;
|
|
30
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
31
|
+
return errorToolResult(code, message);
|
|
32
|
+
}
|
|
33
|
+
export async function registerFlowTools(server, projectRoot, options = {}) {
|
|
34
|
+
const { gateway } = options;
|
|
35
|
+
const { flows, diagnostics } = gateway ? { flows: [], diagnostics: [] } : await discoverFlows(projectRoot);
|
|
36
|
+
const runner = gateway ? undefined : new FlowRunner(projectRoot);
|
|
37
|
+
server.registerTool('get_config', {
|
|
38
|
+
title: 'get_config',
|
|
39
|
+
description: 'Read local browser-mode and browser-visibility preferences from this machine.',
|
|
40
|
+
inputSchema: z.object({})
|
|
41
|
+
}, async () => {
|
|
42
|
+
const config = await loadConfig();
|
|
43
|
+
return { content: [{ type: 'text', text: JSON.stringify(config, null, 2) }], structuredContent: normalizeStructuredContent(config) };
|
|
44
|
+
});
|
|
45
|
+
server.registerTool('set_browser_visibility', {
|
|
46
|
+
title: 'set_browser_visibility',
|
|
47
|
+
description: 'Persist whether managed Chrome runs visible or hidden. Default scope accepts visible/hidden/inherit; flow scope accepts visible/hidden/null.',
|
|
48
|
+
inputSchema: z.object({ scope: z.enum(['default', 'flow']), flowName: z.string().min(1).optional(), value: z.enum(['visible', 'hidden', 'inherit']).nullable() })
|
|
49
|
+
}, async ({ scope, flowName, value }) => {
|
|
50
|
+
if (scope === 'default') {
|
|
51
|
+
if (value === null)
|
|
52
|
+
return errorToolResult('INVALID_ARGUMENT', 'scope:"default" requires value "visible", "hidden", or "inherit".');
|
|
53
|
+
if (flowName)
|
|
54
|
+
return errorToolResult('INVALID_ARGUMENT', 'flowName must be omitted when scope is "default".');
|
|
55
|
+
const config = await setDefaultBrowserVisibility(value);
|
|
56
|
+
return { content: [{ type: 'text', text: JSON.stringify({ config }, null, 2) }], structuredContent: { config } };
|
|
57
|
+
}
|
|
58
|
+
if (!flowName)
|
|
59
|
+
return errorToolResult('INVALID_ARGUMENT', 'flowName is required when scope is "flow".');
|
|
60
|
+
if (value === 'inherit')
|
|
61
|
+
return errorToolResult('INVALID_ARGUMENT', 'scope:"flow" uses value:null to clear the override; "inherit" is only valid for scope:"default".');
|
|
62
|
+
const config = await setFlowBrowserVisibility(flowName, value);
|
|
63
|
+
return { content: [{ type: 'text', text: JSON.stringify({ config }, null, 2) }], structuredContent: { config } };
|
|
64
|
+
});
|
|
65
|
+
server.registerTool('set_browser_mode', {
|
|
66
|
+
title: 'set_browser_mode',
|
|
67
|
+
description: 'Persist whether flows prefer Playwright managed mode or real Chrome CDP. Default scope accepts managed/cdp/inherit; flow scope accepts managed/cdp/null.',
|
|
68
|
+
inputSchema: z.object({ scope: z.enum(['default', 'flow']), flowName: z.string().min(1).optional(), value: z.enum(['managed', 'cdp', 'inherit']).nullable() })
|
|
69
|
+
}, async ({ scope, flowName, value }) => {
|
|
70
|
+
if (scope === 'default') {
|
|
71
|
+
if (value === null)
|
|
72
|
+
return errorToolResult('INVALID_ARGUMENT', 'scope:"default" requires value "managed", "cdp", or "inherit".');
|
|
73
|
+
if (flowName)
|
|
74
|
+
return errorToolResult('INVALID_ARGUMENT', 'flowName must be omitted when scope is "default".');
|
|
75
|
+
const config = await setDefaultBrowserMode(value);
|
|
76
|
+
return { content: [{ type: 'text', text: JSON.stringify({ config }, null, 2) }], structuredContent: { config } };
|
|
77
|
+
}
|
|
78
|
+
if (!flowName)
|
|
79
|
+
return errorToolResult('INVALID_ARGUMENT', 'flowName is required when scope is "flow".');
|
|
80
|
+
if (value === 'inherit')
|
|
81
|
+
return errorToolResult('INVALID_ARGUMENT', 'scope:"flow" uses value:null to clear the override; "inherit" is only valid for scope:"default".');
|
|
82
|
+
const config = await setFlowBrowserMode(flowName, value);
|
|
83
|
+
return { content: [{ type: 'text', text: JSON.stringify({ config }, null, 2) }], structuredContent: { config } };
|
|
84
|
+
});
|
|
85
|
+
if (!gateway) {
|
|
86
|
+
server.registerTool('run_flow_with_options', {
|
|
87
|
+
title: 'run_flow_with_options',
|
|
88
|
+
description: 'Run a local flow once with optional one-off browserMode/browserVisibility overrides. Requires confirmed:true when passing browser options; does not persist config.',
|
|
89
|
+
inputSchema: z.object({
|
|
90
|
+
name: z.string().min(1),
|
|
91
|
+
input: z.record(z.string(), z.unknown()).optional(),
|
|
92
|
+
browserMode: z.enum(['managed', 'cdp']).optional(),
|
|
93
|
+
browserVisibility: z.enum(['visible', 'hidden']).optional(),
|
|
94
|
+
confirmed: z.boolean().optional()
|
|
95
|
+
})
|
|
96
|
+
}, async ({ name, input, browserMode, browserVisibility, confirmed }) => {
|
|
97
|
+
if ((browserMode || browserVisibility) && confirmed !== true) {
|
|
98
|
+
return errorToolResult('CONFIRMATION_REQUIRED', 'Ask the user for explicit confirmation before running with one-off browser options, then retry with confirmed:true.');
|
|
99
|
+
}
|
|
100
|
+
const flow = flows.find((entry) => entry.name === name);
|
|
101
|
+
if (!flow)
|
|
102
|
+
return errorToolResult('FLOW_NOT_FOUND', `No flow named "${name}" in this workspace.`);
|
|
103
|
+
const result = await runner.run(flow, input ?? {}, { browserMode, browserVisibility });
|
|
104
|
+
if (!result.ok) {
|
|
105
|
+
return {
|
|
106
|
+
isError: true,
|
|
107
|
+
content: [{ type: 'text', text: JSON.stringify(result.error, null, 2) }],
|
|
108
|
+
structuredContent: normalizeStructuredContent(result.error)
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const structuredContent = result.visibilityNudge
|
|
112
|
+
? { ...normalizeStructuredContent(result.result), askUser: result.visibilityNudge }
|
|
113
|
+
: normalizeStructuredContent(result.result);
|
|
114
|
+
return { content: [{ type: 'text', text: JSON.stringify(structuredContent, null, 2) }], structuredContent };
|
|
115
|
+
});
|
|
116
|
+
server.registerTool('check_profile_status', {
|
|
117
|
+
title: 'check_profile_status',
|
|
118
|
+
description: 'Check whether this machine has a probably-valid logged-in session for a flow that requires login. ' +
|
|
119
|
+
'Call this before running such a flow to avoid a failed run, and again after the user says they ' +
|
|
120
|
+
'finished a login you started with start_profile_login, to confirm it actually worked.',
|
|
121
|
+
inputSchema: z.object({ flowName: z.string().min(1) })
|
|
122
|
+
}, async ({ flowName }) => {
|
|
123
|
+
try {
|
|
124
|
+
const status = await statusFlowProfile(projectRoot, flowName);
|
|
125
|
+
return { content: [{ type: 'text', text: JSON.stringify(status, null, 2) }], structuredContent: normalizeStructuredContent(status) };
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
return caughtErrorToolResult(error, 'PROFILE_STATUS_REQUEST_FAILED');
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
server.registerTool('start_profile_login', {
|
|
132
|
+
title: 'start_profile_login',
|
|
133
|
+
description: 'Open a real, visible Chrome window on this machine so the user can log in manually to a flow\'s required ' +
|
|
134
|
+
'account (e.g. Google/X/Adobe). This only opens the window and returns immediately — it does NOT wait for ' +
|
|
135
|
+
'the human to finish, and does not log in on your behalf. Only call this when the user has explicitly ' +
|
|
136
|
+
'asked to log in, or has just agreed after you asked, then call it again with confirmed:true. After they ' +
|
|
137
|
+
'say they are done, call check_profile_status to verify it actually worked.',
|
|
138
|
+
inputSchema: z.object({ flowName: z.string().min(1), confirmed: z.boolean().optional() })
|
|
139
|
+
}, async ({ flowName, confirmed }) => {
|
|
140
|
+
if (confirmed !== true) {
|
|
141
|
+
return errorToolResult('CONFIRMATION_REQUIRED', 'Ask the user for explicit confirmation before opening a login window, then retry with confirmed:true.');
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const status = await startFlowLogin(projectRoot, flowName);
|
|
145
|
+
const structuredContent = {
|
|
146
|
+
...normalizeStructuredContent(status),
|
|
147
|
+
note: 'A Chrome window should now be open on this machine. Ask the user to log in and close the window when done, then call check_profile_status to confirm.'
|
|
148
|
+
};
|
|
149
|
+
return { content: [{ type: 'text', text: JSON.stringify(structuredContent, null, 2) }], structuredContent };
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
return caughtErrorToolResult(error, 'PROFILE_LOGIN_REQUEST_FAILED');
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
server.registerTool('logout', {
|
|
157
|
+
title: 'logout',
|
|
158
|
+
description: 'Remove the relay login this machine saved via "webflow login". Does not stop an already-running worker ' +
|
|
159
|
+
'— call stop_worker first if one is up. Only call this when the user has explicitly asked to log out.',
|
|
160
|
+
inputSchema: z.object({})
|
|
161
|
+
}, async () => {
|
|
162
|
+
const loggedOut = await clearLogin();
|
|
163
|
+
return { content: [{ type: 'text', text: JSON.stringify({ loggedOut }, null, 2) }], structuredContent: { loggedOut } };
|
|
164
|
+
});
|
|
165
|
+
server.registerTool('start_worker', {
|
|
166
|
+
title: 'start_worker',
|
|
167
|
+
description: 'Start this machine\'s worker in the background so it connects to the relay and can run flows requested ' +
|
|
168
|
+
'from any MCP client (not just this session) — it keeps running after this tool call returns. Requires the ' +
|
|
169
|
+
'user to have already run "webflow login" once and approved browser pairing; never ask them to paste a ' +
|
|
170
|
+
'worker credential here. If a worker is already running, reports that instead of starting a second one.',
|
|
171
|
+
inputSchema: z.object({
|
|
172
|
+
headed: z.boolean().optional(),
|
|
173
|
+
browserMode: z.enum(['managed', 'cdp']).optional(),
|
|
174
|
+
browserVisibility: z.enum(['visible', 'hidden']).optional()
|
|
175
|
+
})
|
|
176
|
+
}, async (args) => {
|
|
177
|
+
const result = await startBackgroundWorker(args);
|
|
178
|
+
if (!result.started) {
|
|
179
|
+
if (result.alreadyRunning) {
|
|
180
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: normalizeStructuredContent(result) };
|
|
181
|
+
}
|
|
182
|
+
return errorToolResult('WORKER_START_FAILED', result.reason);
|
|
183
|
+
}
|
|
184
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: normalizeStructuredContent(result) };
|
|
185
|
+
});
|
|
186
|
+
server.registerTool('stop_worker', {
|
|
187
|
+
title: 'stop_worker',
|
|
188
|
+
description: 'Stop the worker this machine started with start_worker, if one is running.',
|
|
189
|
+
inputSchema: z.object({})
|
|
190
|
+
}, async () => {
|
|
191
|
+
const result = await stopBackgroundWorker();
|
|
192
|
+
if (!result.stopped) {
|
|
193
|
+
return errorToolResult('WORKER_NOT_RUNNING', result.reason);
|
|
194
|
+
}
|
|
195
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: normalizeStructuredContent(result) };
|
|
196
|
+
});
|
|
197
|
+
server.registerTool('worker_status', {
|
|
198
|
+
title: 'worker_status',
|
|
199
|
+
description: 'Check whether a worker started by start_worker is currently running on this machine.',
|
|
200
|
+
inputSchema: z.object({})
|
|
201
|
+
}, async () => {
|
|
202
|
+
const info = await isWorkerRunning();
|
|
203
|
+
const result = info ? { running: true, ...info } : { running: false };
|
|
204
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: normalizeStructuredContent(result) };
|
|
205
|
+
});
|
|
206
|
+
server.registerTool('list_profiles', {
|
|
207
|
+
title: 'list_profiles',
|
|
208
|
+
description: 'List every saved login session (profile) on this machine and its current status.',
|
|
209
|
+
inputSchema: z.object({})
|
|
210
|
+
}, async () => {
|
|
211
|
+
const profiles = await listProfiles();
|
|
212
|
+
return { content: [{ type: 'text', text: JSON.stringify(profiles, null, 2) }], structuredContent: { profiles } };
|
|
213
|
+
});
|
|
214
|
+
server.registerTool('reset_profile', {
|
|
215
|
+
title: 'reset_profile',
|
|
216
|
+
description: 'Delete a saved login session on this machine, requiring a fresh manual login next time it is needed. ' +
|
|
217
|
+
'This cannot be undone — requires confirmed:true after the user explicitly agrees.',
|
|
218
|
+
inputSchema: z.object({ profileName: z.string().min(1), confirmed: z.boolean().optional() })
|
|
219
|
+
}, async ({ profileName, confirmed }) => {
|
|
220
|
+
if (confirmed !== true) {
|
|
221
|
+
return errorToolResult('CONFIRMATION_REQUIRED', 'Ask the user for explicit confirmation before deleting this login session, then retry with confirmed:true.');
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
const result = await resetProfile(profileName);
|
|
225
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: normalizeStructuredContent(result) };
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
return caughtErrorToolResult(error, 'PROFILE_RESET_FAILED');
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
server.registerTool('refresh_profile_instance', {
|
|
232
|
+
title: 'refresh_profile_instance',
|
|
233
|
+
description: 'Clone a saved login session into a fresh, separate numbered instance (for running the same login in ' +
|
|
234
|
+
'parallel or rotating sessions to avoid provider rate limits), overwriting that instance if it already exists.',
|
|
235
|
+
inputSchema: z.object({ profileName: z.string().min(1), instance: z.string().min(1) })
|
|
236
|
+
}, async ({ profileName, instance }) => {
|
|
237
|
+
try {
|
|
238
|
+
const result = await refreshInstanceProfile(profileName, instance);
|
|
239
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: normalizeStructuredContent(result) };
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
return caughtErrorToolResult(error, 'PROFILE_REFRESH_FAILED');
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
server.registerTool('list_flow_approvals', {
|
|
246
|
+
title: 'list_flow_approvals',
|
|
247
|
+
description: 'List signed flow updates that are approved or awaiting approval on this machine. This never returns flow code, schemas, or capabilities.',
|
|
248
|
+
inputSchema: z.object({}),
|
|
249
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
250
|
+
}, async () => {
|
|
251
|
+
try {
|
|
252
|
+
const [approved, pending] = await Promise.all([listApprovals(), listPending()]);
|
|
253
|
+
const result = { approved, pending };
|
|
254
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result };
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
return caughtErrorToolResult(error, 'FLOW_APPROVAL_LIST_FAILED');
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
server.registerTool('approve_flow_update', {
|
|
261
|
+
title: 'approve_flow_update',
|
|
262
|
+
description: 'Approve one exact signed flow digest on this machine. First call without confirmed:true to inspect the ' +
|
|
263
|
+
'version, digest, signer and trust domain. Ask the user, then repeat with confirmed:true plus the exact ' +
|
|
264
|
+
'flowDigest, releaseId and sequence returned by the first call.',
|
|
265
|
+
inputSchema: z.object({
|
|
266
|
+
flowName: z.string().min(1).max(128),
|
|
267
|
+
trustDomain: z.string().min(1).max(256).optional(),
|
|
268
|
+
confirmed: z.boolean().optional(),
|
|
269
|
+
flowDigest: z.string().regex(/^[a-f0-9]{64}$/).optional(),
|
|
270
|
+
releaseId: z.string().regex(/^[a-f0-9]{64}$/).optional(),
|
|
271
|
+
sequence: z.number().int().positive().optional()
|
|
272
|
+
}),
|
|
273
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }
|
|
274
|
+
}, async ({ flowName, trustDomain, confirmed, flowDigest, releaseId, sequence }) => {
|
|
275
|
+
try {
|
|
276
|
+
const matches = (await listPending()).filter((entry) => entry.flowName === flowName && (!trustDomain || entry.trustDomain === trustDomain));
|
|
277
|
+
if (matches.length === 0)
|
|
278
|
+
return errorToolResult('FLOW_APPROVAL_NOT_FOUND', `No pending signed update found for flow "${flowName}".`);
|
|
279
|
+
if (matches.length > 1)
|
|
280
|
+
return errorToolResult('FLOW_APPROVAL_AMBIGUOUS', `Specify trustDomain for flow "${flowName}".`);
|
|
281
|
+
const pending = matches[0];
|
|
282
|
+
if (confirmed !== true) {
|
|
283
|
+
return errorToolResult('CONFIRMATION_REQUIRED', 'Show this exact signed update to the user and ask for explicit approval before retrying with confirmed:true and the returned identity fields.', { pending, retryWith: { flowName, trustDomain: pending.trustDomain, flowDigest: pending.flowDigest, releaseId: pending.releaseId, sequence: pending.sequence, confirmed: true } });
|
|
284
|
+
}
|
|
285
|
+
if (!flowDigest || !releaseId || sequence === undefined) {
|
|
286
|
+
return errorToolResult('CONFIRMATION_BINDING_REQUIRED', 'confirmed:true requires the exact flowDigest, releaseId and sequence returned by the inspection call.');
|
|
287
|
+
}
|
|
288
|
+
const approved = await approvePending(flowName, pending.trustDomain, { flowDigest, releaseId, sequence });
|
|
289
|
+
return { content: [{ type: 'text', text: JSON.stringify({ approved }, null, 2) }], structuredContent: { approved } };
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
return caughtErrorToolResult(error, 'FLOW_APPROVAL_FAILED');
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
server.registerTool('revoke_flow_approval', {
|
|
296
|
+
title: 'revoke_flow_approval',
|
|
297
|
+
description: 'Revoke one exact local signed-flow approval. First call without confirmed:true to inspect it, ask the ' +
|
|
298
|
+
'user, then repeat with confirmed:true plus the exact flowDigest, releaseId and sequence returned.',
|
|
299
|
+
inputSchema: z.object({
|
|
300
|
+
flowName: z.string().min(1).max(128),
|
|
301
|
+
trustDomain: z.string().min(1).max(256).optional(),
|
|
302
|
+
confirmed: z.boolean().optional(),
|
|
303
|
+
flowDigest: z.string().regex(/^[a-f0-9]{64}$/).optional(),
|
|
304
|
+
releaseId: z.string().regex(/^[a-f0-9]{64}$/).optional(),
|
|
305
|
+
sequence: z.number().int().positive().optional()
|
|
306
|
+
}),
|
|
307
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }
|
|
308
|
+
}, async ({ flowName, trustDomain, confirmed, flowDigest, releaseId, sequence }) => {
|
|
309
|
+
try {
|
|
310
|
+
const matches = (await listApprovals()).filter((entry) => entry.flowName === flowName && (!trustDomain || entry.trustDomain === trustDomain));
|
|
311
|
+
if (matches.length === 0)
|
|
312
|
+
return errorToolResult('FLOW_APPROVAL_NOT_FOUND', `No approval found for flow "${flowName}".`);
|
|
313
|
+
if (matches.length > 1)
|
|
314
|
+
return errorToolResult('FLOW_APPROVAL_AMBIGUOUS', `Specify trustDomain for flow "${flowName}".`);
|
|
315
|
+
const approval = matches[0];
|
|
316
|
+
if (confirmed !== true) {
|
|
317
|
+
return errorToolResult('CONFIRMATION_REQUIRED', 'Show this exact approval to the user and ask before retrying with confirmed:true and the returned identity fields.', { approval, retryWith: { flowName, trustDomain: approval.trustDomain, flowDigest: approval.flowDigest, releaseId: approval.releaseId, sequence: approval.sequence, confirmed: true } });
|
|
318
|
+
}
|
|
319
|
+
if (!flowDigest || !releaseId || sequence === undefined) {
|
|
320
|
+
return errorToolResult('CONFIRMATION_BINDING_REQUIRED', 'confirmed:true requires the exact flowDigest, releaseId and sequence returned by the inspection call.');
|
|
321
|
+
}
|
|
322
|
+
await revokeApproval(flowName, approval.trustDomain, { flowDigest, releaseId, sequence });
|
|
323
|
+
const result = { revoked: true, flowName, trustDomain: approval.trustDomain };
|
|
324
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result };
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
return caughtErrorToolResult(error, 'FLOW_APPROVAL_REVOKE_FAILED');
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
if (gateway) {
|
|
331
|
+
registerRelayGatewayTools(server, gateway);
|
|
332
|
+
}
|
|
333
|
+
for (const flow of flows) {
|
|
334
|
+
const compiled = translateSchema(await readJsonFile(flow.schemaFile));
|
|
335
|
+
const module = await loadFlowModule(flow.entryFile);
|
|
336
|
+
const baseDescription = compiled.jsonSchema.description ?? `Run the ${flow.name} flow.`;
|
|
337
|
+
const loginUrl = module.capabilities?.startUrl && module.capabilities.startUrl !== 'about:blank' ? module.capabilities.startUrl : undefined;
|
|
338
|
+
const loginDescription = module.capabilities?.requiresLogin
|
|
339
|
+
? ` Requires logged-in profile "${module.capabilities.profile ?? `${flow.name}-default`}". Check it with "webflow profiles status --flow ${flow.name} --json"; if missing, run "${buildProfileLoginCommand(module.capabilities.profile ?? `${flow.name}-default`, loginUrl)}".`
|
|
340
|
+
: '';
|
|
341
|
+
const description = `${baseDescription}${loginDescription}`;
|
|
342
|
+
server.registerTool(flow.name, {
|
|
343
|
+
title: flow.name,
|
|
344
|
+
description,
|
|
345
|
+
inputSchema: compiled.zodSchema
|
|
346
|
+
}, async (arguments_) => {
|
|
347
|
+
const profileName = typeof arguments_.profile === 'string' ? arguments_.profile : undefined;
|
|
348
|
+
const result = await runner.run(flow, arguments_, { profileName, schema: compiled });
|
|
349
|
+
if (!result.ok) {
|
|
350
|
+
return {
|
|
351
|
+
isError: true,
|
|
352
|
+
content: [{ type: 'text', text: JSON.stringify(result.error, null, 2) }],
|
|
353
|
+
structuredContent: normalizeStructuredContent(result.error)
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
const structuredContent = result.visibilityNudge
|
|
357
|
+
? { ...normalizeStructuredContent(result.result), askUser: result.visibilityNudge }
|
|
358
|
+
: normalizeStructuredContent(result.result);
|
|
359
|
+
return {
|
|
360
|
+
content: [{ type: 'text', text: JSON.stringify(structuredContent, null, 2) }],
|
|
361
|
+
structuredContent
|
|
362
|
+
};
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
return { diagnostics, flows };
|
|
366
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface ResolvedProfile {
|
|
2
|
+
id: string;
|
|
3
|
+
/** Effective directory to launch against (per-instance when an instance is given). */
|
|
4
|
+
dir: string;
|
|
5
|
+
/** The shared, logged-in base profile this instance derives from. */
|
|
6
|
+
baseDir: string;
|
|
7
|
+
/** Instance suffix when running multiple agents in parallel, otherwise undefined. */
|
|
8
|
+
instance?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ProfileManagerOptions {
|
|
11
|
+
/** Override the global profiles root (used by tests for isolation). */
|
|
12
|
+
profilesDir?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ResolveProfileOptions {
|
|
15
|
+
/**
|
|
16
|
+
* Instance suffix for parallel agents. Each instance gets its own user-data-dir
|
|
17
|
+
* (a clone of the logged-in base profile) because a Chrome profile directory can
|
|
18
|
+
* only be opened by one process at a time. Off by default.
|
|
19
|
+
*/
|
|
20
|
+
instance?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare class ProfileManager {
|
|
23
|
+
private readonly profilesRoot;
|
|
24
|
+
constructor(_projectRoot?: string, options?: ProfileManagerOptions);
|
|
25
|
+
resolve(profileName?: string, flowName?: string, options?: ResolveProfileOptions): Promise<ResolvedProfile>;
|
|
26
|
+
/**
|
|
27
|
+
* Resolve and create the launch directory. When an instance is given and the
|
|
28
|
+
* per-instance dir does not exist yet, clone the logged-in base profile into it so
|
|
29
|
+
* the parallel agent inherits the session without fighting over the same directory.
|
|
30
|
+
*/
|
|
31
|
+
ensure(profileName?: string, flowName?: string, options?: ResolveProfileOptions): Promise<ResolvedProfile>;
|
|
32
|
+
release(_id: string): Promise<void>;
|
|
33
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { copyDir, ensureDir, pathExists } from '../shared/fs.js';
|
|
3
|
+
import { getProfilesRoot, sanitizeName } from '../shared/paths.js';
|
|
4
|
+
export class ProfileManager {
|
|
5
|
+
profilesRoot;
|
|
6
|
+
constructor(_projectRoot, options = {}) {
|
|
7
|
+
// Sessions are global, decoupled from any single repo. projectRoot is kept for
|
|
8
|
+
// call-site compatibility but no longer determines where profiles live.
|
|
9
|
+
this.profilesRoot = options.profilesDir ?? getProfilesRoot();
|
|
10
|
+
}
|
|
11
|
+
async resolve(profileName, flowName, options = {}) {
|
|
12
|
+
const rawName = profileName ?? `${flowName ?? 'flow'}-default`;
|
|
13
|
+
const id = sanitizeName(rawName);
|
|
14
|
+
const baseDir = resolve(this.profilesRoot, id);
|
|
15
|
+
if (options.instance) {
|
|
16
|
+
const instance = sanitizeName(options.instance);
|
|
17
|
+
return { id, baseDir, instance, dir: resolve(this.profilesRoot, `${id}__${instance}`) };
|
|
18
|
+
}
|
|
19
|
+
return { id, baseDir, dir: baseDir };
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Resolve and create the launch directory. When an instance is given and the
|
|
23
|
+
* per-instance dir does not exist yet, clone the logged-in base profile into it so
|
|
24
|
+
* the parallel agent inherits the session without fighting over the same directory.
|
|
25
|
+
*/
|
|
26
|
+
async ensure(profileName, flowName, options = {}) {
|
|
27
|
+
const resolved = await this.resolve(profileName, flowName, options);
|
|
28
|
+
if (resolved.instance && !(await pathExists(resolved.dir)) && (await pathExists(resolved.baseDir))) {
|
|
29
|
+
await copyDir(resolved.baseDir, resolved.dir);
|
|
30
|
+
}
|
|
31
|
+
await ensureDir(resolved.dir);
|
|
32
|
+
return resolved;
|
|
33
|
+
}
|
|
34
|
+
async release(_id) {
|
|
35
|
+
return Promise.resolve();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ResolvedProfile } from './profile-manager.js';
|
|
2
|
+
export type ProfileStatus = 'missing' | 'exists_unverified' | 'setup_completed' | 'verified' | 'login_required';
|
|
3
|
+
export type ProfileValidity = boolean | 'unknown';
|
|
4
|
+
export declare function looksLikeLoginWall(url: string | undefined): boolean;
|
|
5
|
+
export interface StoredProfileState {
|
|
6
|
+
profileId: string;
|
|
7
|
+
profileDir: string;
|
|
8
|
+
lastInteractiveSetupAt?: string;
|
|
9
|
+
lastSuccessfulRunAt?: string;
|
|
10
|
+
lastFlow?: string;
|
|
11
|
+
lastLoginRequiredAt?: string | null;
|
|
12
|
+
updatedAt: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ProfileStatusInfo extends StoredProfileState {
|
|
15
|
+
baseDir: string;
|
|
16
|
+
instance?: string;
|
|
17
|
+
stateFile: string;
|
|
18
|
+
exists: boolean;
|
|
19
|
+
status: ProfileStatus;
|
|
20
|
+
probablyValid: ProfileValidity;
|
|
21
|
+
loginUrl?: string;
|
|
22
|
+
setupCommand: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function buildProfileLoginCommand(profileId: string, loginUrl?: string): string;
|
|
25
|
+
export declare function readProfileState(profile: ResolvedProfile): Promise<StoredProfileState | undefined>;
|
|
26
|
+
export declare function markInteractiveSetup(profile: ResolvedProfile, flowName?: string): Promise<StoredProfileState>;
|
|
27
|
+
export declare function markSuccessfulRun(profile: ResolvedProfile, flowName: string): Promise<StoredProfileState>;
|
|
28
|
+
export declare function markLoginRequired(profile: ResolvedProfile, flowName: string): Promise<StoredProfileState>;
|
|
29
|
+
export declare function getProfileStatus(profile: ResolvedProfile, options?: {
|
|
30
|
+
loginUrl?: string;
|
|
31
|
+
}): Promise<ProfileStatusInfo>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { dirname, resolve } from 'node:path';
|
|
2
|
+
import { ensureDir, pathExists, readJsonFile, writeJsonFile } from '../shared/fs.js';
|
|
3
|
+
// Same substrings every login-backed flow already checks by hand against `page.url()`
|
|
4
|
+
// (see gemini-prompt, chatgpt-prompt, twitter, google-speech-generate) after being
|
|
5
|
+
// redirected mid-flow — centralized here so FlowRunner can turn that into a tracked
|
|
6
|
+
// `login_required` state instead of a generic step failure.
|
|
7
|
+
const LOGIN_WALL_MARKERS = ['accounts.google.com', 'signin', '/login', '/auth', '/i/flow/login'];
|
|
8
|
+
export function looksLikeLoginWall(url) {
|
|
9
|
+
if (!url)
|
|
10
|
+
return false;
|
|
11
|
+
const lower = url.toLowerCase();
|
|
12
|
+
return LOGIN_WALL_MARKERS.some((marker) => lower.includes(marker));
|
|
13
|
+
}
|
|
14
|
+
function profileStateFile(profile) {
|
|
15
|
+
return resolve(dirname(profile.baseDir), '.webflow-state', `${profile.id}.json`);
|
|
16
|
+
}
|
|
17
|
+
function nowIso() {
|
|
18
|
+
return new Date().toISOString();
|
|
19
|
+
}
|
|
20
|
+
function newerThan(left, right) {
|
|
21
|
+
if (!left)
|
|
22
|
+
return false;
|
|
23
|
+
if (!right)
|
|
24
|
+
return true;
|
|
25
|
+
return new Date(left).getTime() > new Date(right).getTime();
|
|
26
|
+
}
|
|
27
|
+
function shellArg(value) {
|
|
28
|
+
return JSON.stringify(value);
|
|
29
|
+
}
|
|
30
|
+
export function buildProfileLoginCommand(profileId, loginUrl) {
|
|
31
|
+
const urlPart = loginUrl ? ` --url ${shellArg(loginUrl)}` : '';
|
|
32
|
+
return `webflow profiles login ${profileId}${urlPart}`;
|
|
33
|
+
}
|
|
34
|
+
export async function readProfileState(profile) {
|
|
35
|
+
try {
|
|
36
|
+
return await readJsonFile(profileStateFile(profile));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function writeProfileState(profile, state) {
|
|
43
|
+
const stateFile = profileStateFile(profile);
|
|
44
|
+
await ensureDir(dirname(stateFile));
|
|
45
|
+
await writeJsonFile(stateFile, state);
|
|
46
|
+
return state;
|
|
47
|
+
}
|
|
48
|
+
async function patchProfileState(profile, patch) {
|
|
49
|
+
const current = await readProfileState(profile);
|
|
50
|
+
return writeProfileState(profile, {
|
|
51
|
+
profileId: profile.id,
|
|
52
|
+
profileDir: profile.baseDir,
|
|
53
|
+
...current,
|
|
54
|
+
...patch,
|
|
55
|
+
updatedAt: nowIso()
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
export async function markInteractiveSetup(profile, flowName) {
|
|
59
|
+
return patchProfileState(profile, {
|
|
60
|
+
lastInteractiveSetupAt: nowIso(),
|
|
61
|
+
lastFlow: flowName,
|
|
62
|
+
lastLoginRequiredAt: null
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
export async function markSuccessfulRun(profile, flowName) {
|
|
66
|
+
return patchProfileState(profile, {
|
|
67
|
+
lastSuccessfulRunAt: nowIso(),
|
|
68
|
+
lastFlow: flowName,
|
|
69
|
+
lastLoginRequiredAt: null
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
export async function markLoginRequired(profile, flowName) {
|
|
73
|
+
return patchProfileState(profile, {
|
|
74
|
+
lastLoginRequiredAt: nowIso(),
|
|
75
|
+
lastFlow: flowName
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
export async function getProfileStatus(profile, options = {}) {
|
|
79
|
+
const [exists, stored] = await Promise.all([pathExists(profile.dir), readProfileState(profile)]);
|
|
80
|
+
const latestGood = newerThan(stored?.lastSuccessfulRunAt, stored?.lastInteractiveSetupAt)
|
|
81
|
+
? stored?.lastSuccessfulRunAt
|
|
82
|
+
: stored?.lastInteractiveSetupAt;
|
|
83
|
+
let status;
|
|
84
|
+
let probablyValid;
|
|
85
|
+
if (!exists) {
|
|
86
|
+
status = 'missing';
|
|
87
|
+
probablyValid = false;
|
|
88
|
+
}
|
|
89
|
+
else if (newerThan(stored?.lastLoginRequiredAt, latestGood)) {
|
|
90
|
+
status = 'login_required';
|
|
91
|
+
probablyValid = false;
|
|
92
|
+
}
|
|
93
|
+
else if (stored?.lastSuccessfulRunAt) {
|
|
94
|
+
status = 'verified';
|
|
95
|
+
probablyValid = true;
|
|
96
|
+
}
|
|
97
|
+
else if (stored?.lastInteractiveSetupAt) {
|
|
98
|
+
status = 'setup_completed';
|
|
99
|
+
probablyValid = true;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
status = 'exists_unverified';
|
|
103
|
+
probablyValid = 'unknown';
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
profileId: profile.id,
|
|
107
|
+
profileDir: profile.dir,
|
|
108
|
+
baseDir: profile.baseDir,
|
|
109
|
+
instance: profile.instance,
|
|
110
|
+
stateFile: profileStateFile(profile),
|
|
111
|
+
exists,
|
|
112
|
+
status,
|
|
113
|
+
probablyValid,
|
|
114
|
+
loginUrl: options.loginUrl,
|
|
115
|
+
setupCommand: buildProfileLoginCommand(profile.id, options.loginUrl),
|
|
116
|
+
lastInteractiveSetupAt: stored?.lastInteractiveSetupAt,
|
|
117
|
+
lastSuccessfulRunAt: stored?.lastSuccessfulRunAt,
|
|
118
|
+
lastFlow: stored?.lastFlow,
|
|
119
|
+
lastLoginRequiredAt: stored?.lastLoginRequiredAt,
|
|
120
|
+
updatedAt: stored?.updatedAt ?? nowIso()
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { dirname } from 'node:path';
|
|
2
|
+
import { getRepertoirePath } from '../shared/paths.js';
|
|
3
|
+
import { ensureDir, readJsonFile, writeJsonFile } from '../shared/fs.js';
|
|
4
|
+
function emptyRepertoire() {
|
|
5
|
+
return { flows: [] };
|
|
6
|
+
}
|
|
7
|
+
/** Corrupt/hand-edited repertoire.json falls back to empty rather than crashing the worker. */
|
|
8
|
+
function sanitizeRepertoire(raw) {
|
|
9
|
+
if (typeof raw !== 'object' || raw === null)
|
|
10
|
+
return emptyRepertoire();
|
|
11
|
+
const rawFlows = raw.flows;
|
|
12
|
+
if (!Array.isArray(rawFlows))
|
|
13
|
+
return emptyRepertoire();
|
|
14
|
+
const flows = [];
|
|
15
|
+
for (const entry of rawFlows) {
|
|
16
|
+
if (typeof entry === 'object' && entry !== null && typeof entry.name === 'string') {
|
|
17
|
+
const addedAt = entry.addedAt;
|
|
18
|
+
flows.push({
|
|
19
|
+
name: entry.name,
|
|
20
|
+
addedAt: typeof addedAt === 'string' ? addedAt : new Date().toISOString()
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return { flows };
|
|
25
|
+
}
|
|
26
|
+
async function loadRepertoire() {
|
|
27
|
+
try {
|
|
28
|
+
const raw = await readJsonFile(getRepertoirePath());
|
|
29
|
+
return sanitizeRepertoire(raw);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return emptyRepertoire();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function saveRepertoire(repertoire) {
|
|
36
|
+
const path = getRepertoirePath();
|
|
37
|
+
await ensureDir(dirname(path));
|
|
38
|
+
await writeJsonFile(path, repertoire);
|
|
39
|
+
}
|
|
40
|
+
export async function listRepertoire() {
|
|
41
|
+
const repertoire = await loadRepertoire();
|
|
42
|
+
return repertoire.flows.map((entry) => entry.name);
|
|
43
|
+
}
|
|
44
|
+
export async function addToRepertoire(flowName) {
|
|
45
|
+
const repertoire = await loadRepertoire();
|
|
46
|
+
if (!repertoire.flows.some((entry) => entry.name === flowName)) {
|
|
47
|
+
repertoire.flows.push({ name: flowName, addedAt: new Date().toISOString() });
|
|
48
|
+
await saveRepertoire(repertoire);
|
|
49
|
+
}
|
|
50
|
+
return repertoire.flows.map((entry) => entry.name);
|
|
51
|
+
}
|
|
52
|
+
export async function removeFromRepertoire(flowName) {
|
|
53
|
+
const repertoire = await loadRepertoire();
|
|
54
|
+
const filtered = repertoire.flows.filter((entry) => entry.name !== flowName);
|
|
55
|
+
if (filtered.length !== repertoire.flows.length) {
|
|
56
|
+
await saveRepertoire({ flows: filtered });
|
|
57
|
+
}
|
|
58
|
+
return filtered.map((entry) => entry.name);
|
|
59
|
+
}
|