@fargowealth/fargo-ai-cli 1.2.1 → 1.4.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.
@@ -0,0 +1,364 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { callMcpTool } from "./mcp-client.mjs";
5
+ import { mcpToolPayload } from "./mcp-result.mjs";
6
+ import { captureLocalWebsiteProfile, resolveLocalWebsiteProfileDirectory } from "./website-browser-profile.mjs";
7
+ import {
8
+ assertWebsiteConnectorRuntimeCompatibility,
9
+ executeWebsiteConnectorOperation,
10
+ parseWebsiteConnectorDefinition,
11
+ websiteConnectorRuntimeDescriptor
12
+ } from "./website-connector-definition.mjs";
13
+
14
+ const WEBSITE_BRIDGE_APP_ID = "website-bridge";
15
+ const WEBSITE_BRIDGE_URL = "https://website-bridge.fargowealth.app";
16
+
17
+ export async function runWebsiteCommand(args, stdout, store, options = {}) {
18
+ const action = args[1];
19
+ if (action === "--help" || action === "-h" || action === "help") {
20
+ stdout.write(WEBSITE_HELP);
21
+ return;
22
+ }
23
+ if (action === "validate") {
24
+ const definition = await readDefinition(args);
25
+ assertWebsiteConnectorRuntimeCompatibility(definition);
26
+ stdout.write(`${JSON.stringify(validationSummary(definition))}\n`);
27
+ return;
28
+ }
29
+ if (action === "runtime") {
30
+ stdout.write(`${JSON.stringify(websiteConnectorRuntimeDescriptor())}\n`);
31
+ return;
32
+ }
33
+ if (action === "release") {
34
+ const definition = await readDefinition(args);
35
+ const expectedRevision = optionalInteger(args, "--expected-revision");
36
+ const result = await callWebsiteBridge(store, "website_connector_release", {
37
+ definition,
38
+ ...(expectedRevision === undefined ? {} : { expectedRevision })
39
+ }, { ...options, confirmation: args.includes("--confirm") });
40
+ stdout.write(`${JSON.stringify(result)}\n`);
41
+ return;
42
+ }
43
+ if (action === "list") {
44
+ stdout.write(`${JSON.stringify(await callWebsiteBridge(store, "website_connector_list", {}, options))}\n`);
45
+ return;
46
+ }
47
+ if (action === "operations") {
48
+ stdout.write(`${JSON.stringify(await callWebsiteBridge(store, "website_connector_operations", {
49
+ connectorId: requiredOption(args, "--connector")
50
+ }, options))}\n`);
51
+ return;
52
+ }
53
+ if (action === "call") {
54
+ const mode = websiteCallMode(args);
55
+ const write = args.includes("--write");
56
+ if (mode === "local") {
57
+ const definition = await readDefinition(args);
58
+ const connectorId = optionValue(args, "--connector")?.trim();
59
+ if (connectorId && connectorId !== definition.connectorId) {
60
+ throw new Error("--connector must match the Connector definition in --file");
61
+ }
62
+ const operationId = requiredOption(args, "--operation");
63
+ const operation = definition.operations.find((candidate) => candidate.operationId === operationId);
64
+ if (!operation) throw new Error("website_operation_not_found");
65
+ if (!operation.readOnly && (!write || !args.includes("--confirm"))) {
66
+ throw new Error("Local write operations require --write --confirm");
67
+ }
68
+ let storageState;
69
+ if (operation.requiresConnection) {
70
+ const capture = options.captureWebsiteProfile ?? captureLocalWebsiteProfile;
71
+ storageState = await capture(localProfileSpec(definition), {
72
+ chromePath: optionValue(args, "--chrome-path"),
73
+ noPrompt: args.includes("--no-prompt"),
74
+ profileDirectory: await resolveLocalWebsiteProfileDirectory(definition.connectorId, {
75
+ profileDirectory: optionValue(args, "--profile-dir")
76
+ }),
77
+ purpose: "local-execution",
78
+ settleMs: optionalInteger(args, "--settle-ms"),
79
+ stderr: options.stderr,
80
+ stdin: options.stdin
81
+ });
82
+ }
83
+ const execute = options.executeWebsiteOperation ?? executeWebsiteConnectorOperation;
84
+ const result = await execute(definition, operationId, await readArguments(args), {
85
+ fetchImpl: options.fetchImpl,
86
+ readOnlyOnly: !write,
87
+ ...(storageState ? { storageState } : {})
88
+ });
89
+ stdout.write(`${JSON.stringify({ ...result, executionMode: "local" })}\n`);
90
+ return;
91
+ }
92
+ const result = await callWebsiteBridge(store, write ? "website_connector_execute" : "website_connector_read", {
93
+ connectorId: requiredOption(args, "--connector"),
94
+ operationId: requiredOption(args, "--operation"),
95
+ arguments: await readArguments(args)
96
+ }, { ...options, confirmation: args.includes("--confirm") });
97
+ stdout.write(`${JSON.stringify({ ...result, executionMode: "remote" })}\n`);
98
+ return;
99
+ }
100
+ if (action === "connection-status") {
101
+ stdout.write(`${JSON.stringify(await callWebsiteBridge(store, "website_connection_status", {
102
+ connectorId: requiredOption(args, "--connector")
103
+ }, options))}\n`);
104
+ return;
105
+ }
106
+ if (action === "connect" || action === "sync") {
107
+ const connectorId = requiredOption(args, "--connector");
108
+ const spec = await callWebsiteBridge(store, "website_connection_profile_spec", { connectorId }, options);
109
+ const capture = options.captureWebsiteProfile ?? captureLocalWebsiteProfile;
110
+ const profile = await capture(spec, {
111
+ chromePath: optionValue(args, "--chrome-path"),
112
+ noPrompt: args.includes("--no-prompt"),
113
+ profileDirectory: optionValue(args, "--profile-dir"),
114
+ settleMs: optionalInteger(args, "--settle-ms"),
115
+ stderr: options.stderr,
116
+ stdin: options.stdin
117
+ });
118
+ const result = await uploadWebsiteProfile(store, spec, profile, {
119
+ ...options,
120
+ connectorId,
121
+ mode: action === "sync" ? "sync" : "establish"
122
+ });
123
+ stdout.write(`${JSON.stringify(result)}\n`);
124
+ return;
125
+ }
126
+ if (action === "authorize") {
127
+ stdout.write(`${JSON.stringify(await callWebsiteBridge(store, "website_connection_authorize", {
128
+ connectorId: requiredOption(args, "--connector")
129
+ }, options))}\n`);
130
+ return;
131
+ }
132
+ if (action === "authorize-complete") {
133
+ stdout.write(`${JSON.stringify(await callWebsiteBridge(store, "website_connection_authorize_complete", {
134
+ connectorId: requiredOption(args, "--connector")
135
+ }, options))}\n`);
136
+ return;
137
+ }
138
+ if (action === "binding") {
139
+ stdout.write(`${JSON.stringify({
140
+ appId: WEBSITE_BRIDGE_APP_ID,
141
+ manifestVersion: "fargo.app-mcp/v1",
142
+ requiresCurrentUserDelegation: true,
143
+ authority: { mode: "invoking_user" },
144
+ allowedToolNames: [
145
+ "website_connector_list",
146
+ "website_connector_operations",
147
+ "website_connector_read",
148
+ "website_connector_execute",
149
+ "website_connection_status"
150
+ ]
151
+ }, null, 2)}\n`);
152
+ return;
153
+ }
154
+ throw new Error(WEBSITE_USAGE);
155
+ }
156
+
157
+ async function callWebsiteBridge(store, toolName, toolArguments, options) {
158
+ return mcpToolPayload(await callMcpTool(store, "app_mcp_tool_call", {
159
+ appId: WEBSITE_BRIDGE_APP_ID,
160
+ toolName,
161
+ arguments: toolArguments,
162
+ ...(options.confirmation === true ? { confirmation: true } : {})
163
+ }, options));
164
+ }
165
+
166
+ async function readDefinition(args) {
167
+ const path = resolve(requiredOption(args, "--file"));
168
+ let value;
169
+ try {
170
+ value = JSON.parse(await readFile(path, "utf8"));
171
+ } catch (error) {
172
+ if (error instanceof SyntaxError) throw new Error("Website Connector definition must be valid JSON");
173
+ throw error;
174
+ }
175
+ return parseWebsiteConnectorDefinition(value);
176
+ }
177
+
178
+ async function readArguments(args) {
179
+ const inline = optionValue(args, "--args");
180
+ const file = optionValue(args, "--args-file");
181
+ if (inline !== undefined && file !== undefined) throw new Error("Use only one of --args or --args-file");
182
+ const text = file === undefined ? inline ?? "{}" : await readFile(resolve(file), "utf8");
183
+ try {
184
+ const value = JSON.parse(text);
185
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid");
186
+ return value;
187
+ } catch {
188
+ throw new Error("Website operation arguments must be a JSON object");
189
+ }
190
+ }
191
+
192
+ function validationSummary(definition) {
193
+ const canonical = JSON.stringify(definition);
194
+ return {
195
+ valid: true,
196
+ schemaVersion: definition.schemaVersion,
197
+ connectorId: definition.connectorId,
198
+ displayName: definition.displayName,
199
+ operationCount: definition.operations.length,
200
+ requiresConnection: definition.operations.some((operation) => operation.requiresConnection),
201
+ runtime: websiteConnectorRuntimeDescriptor(),
202
+ sha256: createHash("sha256").update(canonical).digest("hex")
203
+ };
204
+ }
205
+
206
+ function websiteCallMode(args) {
207
+ if (args.includes("--local")) return "local";
208
+ const mode = optionValue(args, "--mode") ?? "remote";
209
+ if (!["auto", "local", "remote"].includes(mode)) throw new Error("--mode must be auto, local, or remote");
210
+ return mode === "auto" ? (optionValue(args, "--file") ? "local" : "remote") : mode;
211
+ }
212
+
213
+ function localProfileSpec(definition) {
214
+ if (definition.auth.kind !== "browser_storage" || !definition.loginUrl) {
215
+ throw new Error("Website Connector does not define a local browser Profile");
216
+ }
217
+ return {
218
+ allowedOrigins: definition.allowedOrigins,
219
+ auth: {
220
+ headerSources: definition.auth.headerSources.map(({ keySuffix, origin }) => ({ keySuffix, origin })),
221
+ sendCookies: definition.auth.sendCookies
222
+ },
223
+ connection: {},
224
+ connectorId: definition.connectorId,
225
+ loginUrl: definition.loginUrl,
226
+ profileSchemaVersion: "fargo.browser-profile/v1"
227
+ };
228
+ }
229
+
230
+ function optionalInteger(args, name) {
231
+ const raw = optionValue(args, name);
232
+ if (raw === undefined) return undefined;
233
+ const value = Number(raw);
234
+ if (!Number.isInteger(value) || value < 0) throw new Error(`${name} must be a non-negative integer`);
235
+ return value;
236
+ }
237
+
238
+ function requiredOption(args, name) {
239
+ const value = optionValue(args, name)?.trim();
240
+ if (!value || value.startsWith("--")) throw new Error(`Missing required option ${name}`);
241
+ return value;
242
+ }
243
+
244
+ function optionValue(args, name) {
245
+ const inline = args.find((value) => value.startsWith(`${name}=`));
246
+ if (inline) return inline.slice(name.length + 1);
247
+ const index = args.indexOf(name);
248
+ return index >= 0 ? args[index + 1] : undefined;
249
+ }
250
+
251
+ export async function uploadWebsiteProfile(store, spec, profile, options) {
252
+ assertWebsiteProfile(profile, options.connectorId);
253
+ const body = JSON.stringify(profile);
254
+ const bodyBytes = Buffer.byteLength(body, "utf8");
255
+ if (bodyBytes > 512_000) throw new Error("Website Profile exceeds the 512 KB upload limit");
256
+ const uploadSecret = randomBytes(32).toString("base64url");
257
+ const mode = options.mode === "sync" ? "sync" : "establish";
258
+ const currentVersion = Number(spec?.connection?.profileVersion ?? 0);
259
+ if (
260
+ mode === "sync"
261
+ && (
262
+ !Number.isInteger(currentVersion)
263
+ || currentVersion < 1
264
+ || !["active", "needs_reauth"].includes(spec?.connection?.state)
265
+ )
266
+ ) {
267
+ throw new Error("Website Connection must already exist before it can be synchronized");
268
+ }
269
+ const capability = await callWebsiteBridge(store, "website_connection_import_begin", {
270
+ connectorId: options.connectorId,
271
+ mode,
272
+ profileSha256: createHash("sha256").update(body).digest("hex"),
273
+ uploadSecretSha256: createHash("sha256").update(uploadSecret).digest("hex"),
274
+ ...(mode === "sync" ? { expectedProfileVersion: currentVersion } : {})
275
+ }, options);
276
+ if (
277
+ capability?.method !== "POST"
278
+ || !Number.isInteger(capability?.maxBytes)
279
+ || capability.maxBytes < bodyBytes
280
+ || capability.maxBytes > 512_000
281
+ || !Number.isFinite(Date.parse(capability?.expiresAt))
282
+ || Date.parse(capability.expiresAt) <= Date.now()
283
+ ) throw new Error("Website Profile upload capability is invalid");
284
+ const uploadUrl = validatedUploadUrl(capability?.uploadUrl, options);
285
+ const fetchImpl = options.fetchImpl ?? fetch;
286
+ const response = await fetchImpl(uploadUrl, {
287
+ body,
288
+ headers: {
289
+ Accept: "application/json",
290
+ "Content-Type": "application/json",
291
+ "X-Fargo-Profile-Upload-Secret": uploadSecret
292
+ },
293
+ method: "POST",
294
+ redirect: "error"
295
+ });
296
+ let payload;
297
+ try {
298
+ payload = await response.json();
299
+ } catch {
300
+ throw new Error(`Website Profile upload failed (${response.status})`);
301
+ }
302
+ if (!response.ok) throw new Error(payload?.error || `Website Profile upload failed (${response.status})`);
303
+ return payload;
304
+ }
305
+
306
+ function assertWebsiteProfile(profile, connectorId) {
307
+ if (
308
+ !profile
309
+ || typeof profile !== "object"
310
+ || Array.isArray(profile)
311
+ || profile.schemaVersion !== "fargo.browser-profile/v1"
312
+ || profile.connectorId !== connectorId
313
+ || !Number.isFinite(Date.parse(profile.capturedAt))
314
+ || !Array.isArray(profile.cookies)
315
+ || !Array.isArray(profile.origins)
316
+ ) throw new Error("Local Website Profile is invalid");
317
+ }
318
+
319
+ function validatedUploadUrl(value, options) {
320
+ const expectedOrigin = new URL(options.websiteBridgeUrl ?? process.env.FARGO_WEBSITE_BRIDGE_URL ?? WEBSITE_BRIDGE_URL).origin;
321
+ let url;
322
+ try {
323
+ url = new URL(String(value || ""));
324
+ } catch {
325
+ throw new Error("Website Profile upload capability is invalid");
326
+ }
327
+ if (
328
+ url.protocol !== "https:"
329
+ || url.origin !== expectedOrigin
330
+ || !/^\/v1\/website\/profile-imports\/wpi_[0-9a-f-]{36}$/.test(url.pathname)
331
+ || url.search
332
+ || url.hash
333
+ || url.username
334
+ || url.password
335
+ ) throw new Error("Website Profile upload capability is invalid");
336
+ return url.toString();
337
+ }
338
+
339
+ export const WEBSITE_USAGE = "Usage: fargo website validate|runtime|release|list|operations|call|connect|sync|connection-status|authorize|authorize-complete|binding";
340
+
341
+ export const WEBSITE_HELP = `Usage: fargo website <command> [options]
342
+
343
+ Connector authoring:
344
+ validate --file <path>
345
+ runtime
346
+ release --file <path> [--expected-revision <n>] --confirm
347
+ list
348
+ operations --connector <id>
349
+
350
+ Connection and execution:
351
+ connect --connector <id> [--chrome-path <path>] [--profile-dir <path>]
352
+ sync --connector <id> [--no-prompt] [--settle-ms <n>]
353
+ connection-status --connector <id>
354
+ call --connector <id> --operation <id> [--args <json> | --args-file <path>] [--mode auto|remote]
355
+ call --file <path> --operation <id> --mode local [--profile-dir <path>] [--no-prompt]
356
+ call --connector <id> --operation <id> --write [--confirm]
357
+
358
+ Cloud login fallback:
359
+ authorize --connector <id>
360
+ authorize-complete --connector <id>
361
+
362
+ Teammate:
363
+ binding
364
+ `;
@@ -0,0 +1 @@
1
+ export * from "@fargowealth/website-connector-runtime";
@@ -0,0 +1 @@
1
+ export * from "@fargowealth/website-connector-runtime";