fullstackgtm 0.10.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/CHANGELOG.md +381 -0
- package/INSTALL_FOR_AGENTS.md +87 -0
- package/LICENSE +202 -0
- package/README.md +230 -0
- package/dist/audit.d.ts +7 -0
- package/dist/audit.js +202 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +6 -0
- package/dist/cli.d.ts +38 -0
- package/dist/cli.js +915 -0
- package/dist/config.d.ts +36 -0
- package/dist/config.js +85 -0
- package/dist/connector.d.ts +30 -0
- package/dist/connector.js +94 -0
- package/dist/connectors/hubspot.d.ts +20 -0
- package/dist/connectors/hubspot.js +409 -0
- package/dist/connectors/hubspotAuth.d.ts +42 -0
- package/dist/connectors/hubspotAuth.js +189 -0
- package/dist/connectors/salesforce.d.ts +26 -0
- package/dist/connectors/salesforce.js +318 -0
- package/dist/connectors/salesforceAuth.d.ts +44 -0
- package/dist/connectors/salesforceAuth.js +120 -0
- package/dist/connectors/stripe.d.ts +27 -0
- package/dist/connectors/stripe.js +176 -0
- package/dist/credentials.d.ts +75 -0
- package/dist/credentials.js +197 -0
- package/dist/demo.d.ts +20 -0
- package/dist/demo.js +169 -0
- package/dist/diff.d.ts +46 -0
- package/dist/diff.js +107 -0
- package/dist/format.d.ts +3 -0
- package/dist/format.js +109 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +17 -0
- package/dist/mappings.d.ts +8 -0
- package/dist/mappings.js +123 -0
- package/dist/mcp-bin.d.ts +2 -0
- package/dist/mcp-bin.js +33 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +140 -0
- package/dist/merge.d.ts +48 -0
- package/dist/merge.js +145 -0
- package/dist/planStore.d.ts +31 -0
- package/dist/planStore.js +116 -0
- package/dist/rules.d.ts +24 -0
- package/dist/rules.js +512 -0
- package/dist/sampleData.d.ts +2 -0
- package/dist/sampleData.js +115 -0
- package/dist/types.d.ts +294 -0
- package/dist/types.js +8 -0
- package/docs/api.md +72 -0
- package/docs/roadmap-to-1.0.md +121 -0
- package/llms.txt +25 -0
- package/package.json +76 -0
- package/src/audit.ts +242 -0
- package/src/bin.ts +7 -0
- package/src/cli.ts +1042 -0
- package/src/config.ts +113 -0
- package/src/connector.ts +140 -0
- package/src/connectors/hubspot.ts +528 -0
- package/src/connectors/hubspotAuth.ts +246 -0
- package/src/connectors/salesforce.ts +420 -0
- package/src/connectors/salesforceAuth.ts +167 -0
- package/src/connectors/stripe.ts +215 -0
- package/src/credentials.ts +282 -0
- package/src/demo.ts +200 -0
- package/src/diff.ts +158 -0
- package/src/format.ts +162 -0
- package/src/index.ts +129 -0
- package/src/mappings.ts +157 -0
- package/src/mcp-bin.ts +32 -0
- package/src/mcp.ts +185 -0
- package/src/merge.ts +235 -0
- package/src/planStore.ts +155 -0
- package/src/rules.ts +539 -0
- package/src/sampleData.ts +117 -0
- package/src/types.ts +372 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export declare const DEFAULT_OAUTH_SCOPES: string[];
|
|
2
|
+
export declare const DEFAULT_LOOPBACK_PORT = 8763;
|
|
3
|
+
export type HubspotTokenSet = {
|
|
4
|
+
accessToken: string;
|
|
5
|
+
refreshToken?: string;
|
|
6
|
+
expiresAt: number;
|
|
7
|
+
};
|
|
8
|
+
export declare function validateHubspotToken(token: string, fetchImpl?: typeof fetch): Promise<{
|
|
9
|
+
ok: boolean;
|
|
10
|
+
detail: string;
|
|
11
|
+
}>;
|
|
12
|
+
export declare function exchangeHubspotCode(options: {
|
|
13
|
+
clientId: string;
|
|
14
|
+
clientSecret: string;
|
|
15
|
+
redirectUri: string;
|
|
16
|
+
code: string;
|
|
17
|
+
fetchImpl?: typeof fetch;
|
|
18
|
+
}): Promise<HubspotTokenSet>;
|
|
19
|
+
export declare function refreshHubspotToken(options: {
|
|
20
|
+
clientId: string;
|
|
21
|
+
clientSecret: string;
|
|
22
|
+
refreshToken: string;
|
|
23
|
+
fetchImpl?: typeof fetch;
|
|
24
|
+
}): Promise<HubspotTokenSet>;
|
|
25
|
+
export type LoopbackLoginOptions = {
|
|
26
|
+
clientId: string;
|
|
27
|
+
clientSecret: string;
|
|
28
|
+
/** Must match a redirect URL registered on the HubSpot app: http://localhost:<port>/callback */
|
|
29
|
+
port?: number;
|
|
30
|
+
scopes?: string[];
|
|
31
|
+
timeoutMs?: number;
|
|
32
|
+
fetchImpl?: typeof fetch;
|
|
33
|
+
/** Receives the authorize URL; default prints it and best-effort opens a browser. */
|
|
34
|
+
openUrl?: (url: string) => void | Promise<void>;
|
|
35
|
+
log?: (message: string) => void;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* RFC 8252 loopback OAuth: serve one request on 127.0.0.1, send the user to
|
|
39
|
+
* the provider consent page, capture the code locally, exchange it directly.
|
|
40
|
+
*/
|
|
41
|
+
export declare function runHubspotLoopbackLogin(options: LoopbackLoginOptions): Promise<HubspotTokenSet>;
|
|
42
|
+
export declare function openInBrowser(url: string): Promise<void>;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
/**
|
|
4
|
+
* HubSpot CLI authentication.
|
|
5
|
+
*
|
|
6
|
+
* Two paths, ordered by how little web they need:
|
|
7
|
+
* 1. Private app token — no OAuth at all; created once in HubSpot settings.
|
|
8
|
+
* 2. Bring-your-own-app OAuth with an RFC 8252 loopback redirect — the
|
|
9
|
+
* browser is used for exactly one thing (the consent grant); the CLI
|
|
10
|
+
* captures the code on 127.0.0.1 and performs the exchange itself.
|
|
11
|
+
*
|
|
12
|
+
* HubSpot does not support the device-authorization grant or PKCE-only
|
|
13
|
+
* public clients, so the loopback flow requires the user's own app client
|
|
14
|
+
* id/secret, which are kept locally for silent refresh.
|
|
15
|
+
*/
|
|
16
|
+
const HS_AUTH_URL = "https://app.hubspot.com/oauth/authorize";
|
|
17
|
+
const HS_TOKEN_URL = "https://api.hubapi.com/oauth/v1/token";
|
|
18
|
+
const HS_API_URL = "https://api.hubapi.com";
|
|
19
|
+
export const DEFAULT_OAUTH_SCOPES = [
|
|
20
|
+
"crm.objects.deals.read",
|
|
21
|
+
"crm.objects.owners.read",
|
|
22
|
+
"crm.objects.companies.read",
|
|
23
|
+
"crm.objects.contacts.read",
|
|
24
|
+
"crm.objects.deals.write",
|
|
25
|
+
];
|
|
26
|
+
export const DEFAULT_LOOPBACK_PORT = 8763;
|
|
27
|
+
/**
|
|
28
|
+
* OAuth error responses can echo request parameters (client_secret, code).
|
|
29
|
+
* Surface only the standard `error`/`error_description` fields, never the raw
|
|
30
|
+
* body, so secrets never reach logs.
|
|
31
|
+
*/
|
|
32
|
+
async function safeTokenError(response) {
|
|
33
|
+
let description;
|
|
34
|
+
try {
|
|
35
|
+
const data = await response.json();
|
|
36
|
+
description = data?.error_description ?? data?.error ?? data?.message;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Non-JSON body — withhold it entirely.
|
|
40
|
+
}
|
|
41
|
+
return description
|
|
42
|
+
? `${response.status} (${String(description).slice(0, 200)})`
|
|
43
|
+
: `HTTP ${response.status} ${response.statusText}`.trim();
|
|
44
|
+
}
|
|
45
|
+
export async function validateHubspotToken(token, fetchImpl = fetch) {
|
|
46
|
+
const response = await fetchImpl(`${HS_API_URL}/crm/v3/owners?limit=1`, {
|
|
47
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
48
|
+
});
|
|
49
|
+
if (response.ok) {
|
|
50
|
+
return { ok: true, detail: "Token accepted by the HubSpot CRM API." };
|
|
51
|
+
}
|
|
52
|
+
const body = await response.text();
|
|
53
|
+
return { ok: false, detail: `HubSpot rejected the token (${response.status}): ${body}` };
|
|
54
|
+
}
|
|
55
|
+
async function tokenRequest(params, fetchImpl) {
|
|
56
|
+
const response = await fetchImpl(HS_TOKEN_URL, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
59
|
+
body: new URLSearchParams(params).toString(),
|
|
60
|
+
});
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
throw new Error(`HubSpot token request failed: ${await safeTokenError(response)}`);
|
|
63
|
+
}
|
|
64
|
+
const data = await response.json();
|
|
65
|
+
if (!data.access_token)
|
|
66
|
+
throw new Error("HubSpot token response had no access_token.");
|
|
67
|
+
return {
|
|
68
|
+
accessToken: data.access_token,
|
|
69
|
+
refreshToken: data.refresh_token,
|
|
70
|
+
expiresAt: Date.now() + (data.expires_in ?? 1800) * 1000,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export async function exchangeHubspotCode(options) {
|
|
74
|
+
return tokenRequest({
|
|
75
|
+
grant_type: "authorization_code",
|
|
76
|
+
client_id: options.clientId,
|
|
77
|
+
client_secret: options.clientSecret,
|
|
78
|
+
redirect_uri: options.redirectUri,
|
|
79
|
+
code: options.code,
|
|
80
|
+
}, options.fetchImpl ?? fetch);
|
|
81
|
+
}
|
|
82
|
+
export async function refreshHubspotToken(options) {
|
|
83
|
+
return tokenRequest({
|
|
84
|
+
grant_type: "refresh_token",
|
|
85
|
+
client_id: options.clientId,
|
|
86
|
+
client_secret: options.clientSecret,
|
|
87
|
+
refresh_token: options.refreshToken,
|
|
88
|
+
}, options.fetchImpl ?? fetch);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* RFC 8252 loopback OAuth: serve one request on 127.0.0.1, send the user to
|
|
92
|
+
* the provider consent page, capture the code locally, exchange it directly.
|
|
93
|
+
*/
|
|
94
|
+
export async function runHubspotLoopbackLogin(options) {
|
|
95
|
+
const port = options.port ?? DEFAULT_LOOPBACK_PORT;
|
|
96
|
+
const scopes = options.scopes ?? DEFAULT_OAUTH_SCOPES;
|
|
97
|
+
const log = options.log ?? ((message) => console.error(message));
|
|
98
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
99
|
+
const state = randomBytes(16).toString("hex");
|
|
100
|
+
const code = await new Promise((resolve, reject) => {
|
|
101
|
+
const server = createServer((request, response) => {
|
|
102
|
+
const url = new URL(request.url ?? "/", `http://localhost:${port}`);
|
|
103
|
+
if (url.pathname !== "/callback") {
|
|
104
|
+
response.writeHead(404).end();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const receivedState = url.searchParams.get("state");
|
|
108
|
+
const receivedCode = url.searchParams.get("code");
|
|
109
|
+
const error = url.searchParams.get("error");
|
|
110
|
+
response.writeHead(200, { "Content-Type": "text/html" });
|
|
111
|
+
response.end("<html><body><p>FullStackGTM CLI login complete. You can close this tab.</p></body></html>");
|
|
112
|
+
server.close();
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
if (error) {
|
|
115
|
+
reject(new Error(`HubSpot authorization failed: ${error}`));
|
|
116
|
+
}
|
|
117
|
+
else if (receivedState !== state) {
|
|
118
|
+
reject(new Error("OAuth state mismatch; aborting login."));
|
|
119
|
+
}
|
|
120
|
+
else if (!receivedCode) {
|
|
121
|
+
reject(new Error("HubSpot redirected without an authorization code."));
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
resolve(receivedCode);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
const timer = setTimeout(() => {
|
|
128
|
+
server.close();
|
|
129
|
+
reject(new Error("Timed out waiting for the OAuth redirect."));
|
|
130
|
+
}, options.timeoutMs ?? 5 * 60 * 1000);
|
|
131
|
+
server.on("error", (error) => {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
reject(error);
|
|
134
|
+
});
|
|
135
|
+
server.listen(port, "127.0.0.1", () => {
|
|
136
|
+
const authorizeUrl = `${HS_AUTH_URL}?` +
|
|
137
|
+
new URLSearchParams({
|
|
138
|
+
response_type: "code",
|
|
139
|
+
client_id: options.clientId,
|
|
140
|
+
redirect_uri: redirectUri,
|
|
141
|
+
scope: scopes.join(" "),
|
|
142
|
+
state,
|
|
143
|
+
}).toString();
|
|
144
|
+
log(`Open this URL to authorize (waiting on ${redirectUri}):\n\n ${authorizeUrl}\n`);
|
|
145
|
+
void (options.openUrl ?? openInBrowser)(authorizeUrl);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
return exchangeHubspotCode({
|
|
149
|
+
clientId: options.clientId,
|
|
150
|
+
clientSecret: options.clientSecret,
|
|
151
|
+
redirectUri,
|
|
152
|
+
code,
|
|
153
|
+
fetchImpl: options.fetchImpl,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
export async function openInBrowser(url) {
|
|
157
|
+
// The URL may come from an external source (e.g. a broker deployment's
|
|
158
|
+
// verification URL). Only ever hand a well-formed http(s) URL to the OS
|
|
159
|
+
// opener — this prevents a leading `-` from being read as a flag by
|
|
160
|
+
// xdg-open and refuses non-web schemes outright.
|
|
161
|
+
let parsed;
|
|
162
|
+
try {
|
|
163
|
+
parsed = new URL(url);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
169
|
+
return;
|
|
170
|
+
const safeUrl = parsed.toString();
|
|
171
|
+
const { spawn } = await import("node:child_process");
|
|
172
|
+
try {
|
|
173
|
+
if (process.platform === "darwin") {
|
|
174
|
+
spawn("open", [safeUrl], { stdio: "ignore", detached: true }).on("error", () => { });
|
|
175
|
+
}
|
|
176
|
+
else if (process.platform === "win32") {
|
|
177
|
+
// `start` is a cmd builtin, not an executable; the empty "" is the
|
|
178
|
+
// window title so a URL with characters isn't mis-parsed as the title.
|
|
179
|
+
spawn("cmd", ["/c", "start", "", safeUrl], { stdio: "ignore", detached: true }).on("error", () => { });
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
// `--` ends option parsing so the URL is never treated as a flag.
|
|
183
|
+
spawn("xdg-open", ["--", safeUrl], { stdio: "ignore", detached: true }).on("error", () => { });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// Printing the URL is the contract; opening a browser is best-effort.
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type FieldMappings } from "../mappings.ts";
|
|
2
|
+
import type { GtmConnector } from "../types.ts";
|
|
3
|
+
export type SalesforceConnection = {
|
|
4
|
+
accessToken: string;
|
|
5
|
+
/** e.g. https://yourorg.my.salesforce.com */
|
|
6
|
+
instanceUrl: string;
|
|
7
|
+
};
|
|
8
|
+
export type SalesforceConnectorOptions = {
|
|
9
|
+
/** Returns an access token plus the instance URL it belongs to. */
|
|
10
|
+
getConnection: () => SalesforceConnection | Promise<SalesforceConnection>;
|
|
11
|
+
/** Per-org canonical-to-provider field overrides. Defaults cover standard fields. */
|
|
12
|
+
fieldMappings?: FieldMappings;
|
|
13
|
+
apiVersion?: string;
|
|
14
|
+
/** Injectable fetch for testing. */
|
|
15
|
+
fetchImpl?: typeof fetch;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Reference connector for Salesforce.
|
|
19
|
+
*
|
|
20
|
+
* Reads run SOQL with cursor pagination; writes PATCH sobjects directly.
|
|
21
|
+
* Like the HubSpot connector, it never drops records it cannot fully resolve
|
|
22
|
+
* (ownerless or amountless opportunities are returned so audit rules can
|
|
23
|
+
* surface the gaps). Probabilities are normalized to 0..1 to match the
|
|
24
|
+
* canonical model.
|
|
25
|
+
*/
|
|
26
|
+
export declare function createSalesforceConnector(options: SalesforceConnectorOptions): Required<GtmConnector>;
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, readMappedValue, } from "../mappings.js";
|
|
2
|
+
const DEFAULT_API_VERSION = "v59.0";
|
|
3
|
+
const SOBJECT_TYPES = {
|
|
4
|
+
account: "Account",
|
|
5
|
+
contact: "Contact",
|
|
6
|
+
deal: "Opportunity",
|
|
7
|
+
};
|
|
8
|
+
const MAPPING_OBJECT_TYPES = {
|
|
9
|
+
account: "accounts",
|
|
10
|
+
contact: "contacts",
|
|
11
|
+
deal: "deals",
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Reference connector for Salesforce.
|
|
15
|
+
*
|
|
16
|
+
* Reads run SOQL with cursor pagination; writes PATCH sobjects directly.
|
|
17
|
+
* Like the HubSpot connector, it never drops records it cannot fully resolve
|
|
18
|
+
* (ownerless or amountless opportunities are returned so audit rules can
|
|
19
|
+
* surface the gaps). Probabilities are normalized to 0..1 to match the
|
|
20
|
+
* canonical model.
|
|
21
|
+
*/
|
|
22
|
+
export function createSalesforceConnector(options) {
|
|
23
|
+
const apiVersion = options.apiVersion ?? DEFAULT_API_VERSION;
|
|
24
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
25
|
+
const mappings = options.fieldMappings;
|
|
26
|
+
async function request(path, init = {}) {
|
|
27
|
+
const connection = await options.getConnection();
|
|
28
|
+
const url = path.startsWith("http")
|
|
29
|
+
? path
|
|
30
|
+
: `${connection.instanceUrl.replace(/\/$/, "")}${path}`;
|
|
31
|
+
const response = await fetchImpl(url, {
|
|
32
|
+
...init,
|
|
33
|
+
headers: {
|
|
34
|
+
Authorization: `Bearer ${connection.accessToken}`,
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
...(init.headers ?? {}),
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
const body = await response.text();
|
|
41
|
+
throw new Error(`Salesforce API error ${response.status}: ${body}`);
|
|
42
|
+
}
|
|
43
|
+
// Salesforce PATCH returns 204 No Content on success.
|
|
44
|
+
const text = await response.text();
|
|
45
|
+
return text ? JSON.parse(text) : null;
|
|
46
|
+
}
|
|
47
|
+
async function query(soql) {
|
|
48
|
+
const records = [];
|
|
49
|
+
let next = `/services/data/${apiVersion}/query?q=${encodeURIComponent(soql)}`;
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
while (next) {
|
|
52
|
+
// Defend against a repeated nextRecordsUrl (would loop forever).
|
|
53
|
+
if (seen.has(next))
|
|
54
|
+
break;
|
|
55
|
+
seen.add(next);
|
|
56
|
+
const data = await request(next);
|
|
57
|
+
records.push(...(data?.records ?? []));
|
|
58
|
+
next = data?.nextRecordsUrl ?? undefined;
|
|
59
|
+
}
|
|
60
|
+
return records;
|
|
61
|
+
}
|
|
62
|
+
function readMapped(source, objectType, targetField, fallbackField) {
|
|
63
|
+
return readMappedValue(source, mappings, objectType, targetField, fallbackField);
|
|
64
|
+
}
|
|
65
|
+
function selectFields(objectType) {
|
|
66
|
+
return mappedFields(mappings, objectType, SALESFORCE_DEFAULT_FIELD_MAPPINGS[objectType]).join(", ");
|
|
67
|
+
}
|
|
68
|
+
async function assembleSnapshot(whereClause) {
|
|
69
|
+
const sfUsers = await query(`SELECT ${selectFields("owners")} FROM User${whereClause}`);
|
|
70
|
+
const users = sfUsers.map((user) => {
|
|
71
|
+
const id = String(readMapped(user, "owners", "id", "Id"));
|
|
72
|
+
const email = stringOrUndefined(readMapped(user, "owners", "email", "Email"));
|
|
73
|
+
return {
|
|
74
|
+
id,
|
|
75
|
+
provider: "salesforce",
|
|
76
|
+
crmId: id,
|
|
77
|
+
identities: [{ provider: "salesforce", externalId: id }],
|
|
78
|
+
name: stringOrFallback(readMapped(user, "owners", "name", "Name"), email ?? `User ${id}`),
|
|
79
|
+
email,
|
|
80
|
+
title: stringOrUndefined(readMapped(user, "owners", "title", "Title")),
|
|
81
|
+
active: Boolean(readMapped(user, "owners", "isActive", "IsActive")),
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
const sfAccounts = await query(`SELECT ${selectFields("accounts")} FROM Account${whereClause}`);
|
|
85
|
+
const accounts = sfAccounts.map((account) => {
|
|
86
|
+
const id = String(readMapped(account, "accounts", "id", "Id"));
|
|
87
|
+
return {
|
|
88
|
+
id,
|
|
89
|
+
provider: "salesforce",
|
|
90
|
+
crmId: id,
|
|
91
|
+
identities: [{ provider: "salesforce", externalId: id }],
|
|
92
|
+
name: stringOrFallback(readMapped(account, "accounts", "name", "Name"), "Unknown Account"),
|
|
93
|
+
domain: stringOrUndefined(readMapped(account, "accounts", "domain", "Website")),
|
|
94
|
+
industry: stringOrUndefined(readMapped(account, "accounts", "industry", "Industry")),
|
|
95
|
+
employeeCount: numberOrUndefined(readMapped(account, "accounts", "employeeCount", "NumberOfEmployees")),
|
|
96
|
+
annualRevenue: numberOrUndefined(readMapped(account, "accounts", "annualRevenue", "AnnualRevenue")),
|
|
97
|
+
ownerId: stringOrUndefined(readMapped(account, "accounts", "ownerId", "OwnerId")),
|
|
98
|
+
raw: account,
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
const sfContacts = await query(`SELECT ${selectFields("contacts")} FROM Contact${whereClause}`);
|
|
102
|
+
const contacts = sfContacts.map((contact) => {
|
|
103
|
+
const id = String(readMapped(contact, "contacts", "id", "Id"));
|
|
104
|
+
return {
|
|
105
|
+
id,
|
|
106
|
+
provider: "salesforce",
|
|
107
|
+
crmId: id,
|
|
108
|
+
identities: [{ provider: "salesforce", externalId: id }],
|
|
109
|
+
accountId: stringOrUndefined(readMapped(contact, "contacts", "accountId", "AccountId")),
|
|
110
|
+
firstName: stringOrUndefined(readMapped(contact, "contacts", "firstName", "FirstName")),
|
|
111
|
+
lastName: stringOrUndefined(readMapped(contact, "contacts", "lastName", "LastName")),
|
|
112
|
+
email: stringOrUndefined(readMapped(contact, "contacts", "email", "Email")),
|
|
113
|
+
phone: stringOrUndefined(readMapped(contact, "contacts", "phone", "Phone")),
|
|
114
|
+
title: stringOrUndefined(readMapped(contact, "contacts", "title", "Title")),
|
|
115
|
+
ownerId: stringOrUndefined(readMapped(contact, "contacts", "ownerId", "OwnerId")),
|
|
116
|
+
raw: contact,
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
const sfOpportunities = await query(`SELECT ${selectFields("deals")} FROM Opportunity${whereClause}`);
|
|
120
|
+
const deals = sfOpportunities.map((opportunity) => {
|
|
121
|
+
const id = String(readMapped(opportunity, "deals", "id", "Id"));
|
|
122
|
+
const probability = numberOrUndefined(readMapped(opportunity, "deals", "probability", "Probability"));
|
|
123
|
+
const isClosed = Boolean(readMapped(opportunity, "deals", "isClosed", "IsClosed"));
|
|
124
|
+
const isWon = Boolean(readMapped(opportunity, "deals", "isWon", "IsWon"));
|
|
125
|
+
const forecastCategoryName = stringOrUndefined(readMapped(opportunity, "deals", "forecastCategoryName", "ForecastCategory"));
|
|
126
|
+
const forecastCategory = isWon
|
|
127
|
+
? "closed_won"
|
|
128
|
+
: isClosed
|
|
129
|
+
? "closed_lost"
|
|
130
|
+
: // Map Salesforce's native ForecastCategory (e.g. "BestCase",
|
|
131
|
+
// "Commit", "Pipeline") to the canonical snake_case form, falling
|
|
132
|
+
// back to "pipeline" when absent.
|
|
133
|
+
(forecastCategoryName
|
|
134
|
+
? forecastCategoryName
|
|
135
|
+
.replace(/([a-z])([A-Z])/g, "$1_$2")
|
|
136
|
+
.toLowerCase()
|
|
137
|
+
: "pipeline");
|
|
138
|
+
return {
|
|
139
|
+
id,
|
|
140
|
+
provider: "salesforce",
|
|
141
|
+
crmId: id,
|
|
142
|
+
identities: [{ provider: "salesforce", externalId: id }],
|
|
143
|
+
accountId: stringOrUndefined(readMapped(opportunity, "deals", "accountId", "AccountId")),
|
|
144
|
+
ownerId: stringOrUndefined(readMapped(opportunity, "deals", "ownerId", "OwnerId")),
|
|
145
|
+
name: stringOrFallback(readMapped(opportunity, "deals", "name", "Name"), "Untitled Opportunity"),
|
|
146
|
+
amount: numberOrUndefined(readMapped(opportunity, "deals", "amount", "Amount")),
|
|
147
|
+
stage: stringOrUndefined(readMapped(opportunity, "deals", "stage", "StageName")),
|
|
148
|
+
closeDate: stringOrUndefined(readMapped(opportunity, "deals", "closeDate", "CloseDate"))?.split("T")[0],
|
|
149
|
+
dealType: stringOrUndefined(readMapped(opportunity, "deals", "dealType", "Type")),
|
|
150
|
+
forecastCategory,
|
|
151
|
+
// Salesforce probabilities are 0..100; canonical is 0..1.
|
|
152
|
+
probability: probability === undefined ? undefined : probability / 100,
|
|
153
|
+
isClosed,
|
|
154
|
+
isWon,
|
|
155
|
+
lastActivityAt: stringOrUndefined(readMapped(opportunity, "deals", "lastActivityAt", "LastActivityDate")),
|
|
156
|
+
raw: opportunity,
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
return {
|
|
160
|
+
generatedAt: new Date().toISOString(),
|
|
161
|
+
provider: "salesforce",
|
|
162
|
+
users,
|
|
163
|
+
accounts,
|
|
164
|
+
contacts,
|
|
165
|
+
deals,
|
|
166
|
+
// Task/Event reads come with engagement support; staleness falls back
|
|
167
|
+
// to close dates until then.
|
|
168
|
+
activities: [],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
async function fetchSnapshot() {
|
|
172
|
+
return assembleSnapshot("");
|
|
173
|
+
}
|
|
174
|
+
/** Records modified since `sinceIso`, filtered on `SystemModstamp`. */
|
|
175
|
+
async function fetchChanges(sinceIso) {
|
|
176
|
+
const sinceMs = Date.parse(sinceIso);
|
|
177
|
+
if (!Number.isFinite(sinceMs))
|
|
178
|
+
throw new Error(`Invalid since timestamp: ${sinceIso}`);
|
|
179
|
+
return assembleSnapshot(` WHERE SystemModstamp >= ${new Date(sinceMs).toISOString()}`);
|
|
180
|
+
}
|
|
181
|
+
function humanizeField(field) {
|
|
182
|
+
return field
|
|
183
|
+
.replace(/_task$/, "")
|
|
184
|
+
.replace(/[_-]+/g, " ")
|
|
185
|
+
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
186
|
+
.trim();
|
|
187
|
+
}
|
|
188
|
+
async function setField(operation) {
|
|
189
|
+
const sobjectType = SOBJECT_TYPES[operation.objectType];
|
|
190
|
+
const mappingType = MAPPING_OBJECT_TYPES[operation.objectType];
|
|
191
|
+
if (!sobjectType || !mappingType || !operation.field) {
|
|
192
|
+
return {
|
|
193
|
+
operationId: operation.id,
|
|
194
|
+
status: "skipped",
|
|
195
|
+
detail: "Field writes are only supported for accounts, contacts, and deals with an explicit field.",
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const defaults = SALESFORCE_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
|
|
199
|
+
const field = mappedField(mappings, mappingType, operation.field, defaults[operation.field] ?? operation.field);
|
|
200
|
+
const value = operation.operation === "clear_field" ? null : String(operation.afterValue ?? "");
|
|
201
|
+
await request(`/services/data/${apiVersion}/sobjects/${sobjectType}/${encodeURIComponent(operation.objectId)}`, { method: "PATCH", body: JSON.stringify({ [field]: value }) });
|
|
202
|
+
return {
|
|
203
|
+
operationId: operation.id,
|
|
204
|
+
status: "applied",
|
|
205
|
+
detail: `Set ${field} on ${sobjectType}/${operation.objectId}.`,
|
|
206
|
+
providerData: { sobjectType, field },
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
async function createTask(operation) {
|
|
210
|
+
// Salesforce activities relate via WhatId (account/opportunity) or WhoId
|
|
211
|
+
// (contact). Subject is required.
|
|
212
|
+
const reference = operation.objectType === "contact"
|
|
213
|
+
? { WhoId: operation.objectId }
|
|
214
|
+
: operation.objectType === "account" || operation.objectType === "deal"
|
|
215
|
+
? { WhatId: operation.objectId }
|
|
216
|
+
: null;
|
|
217
|
+
if (!reference) {
|
|
218
|
+
return {
|
|
219
|
+
operationId: operation.id,
|
|
220
|
+
status: "skipped",
|
|
221
|
+
detail: "Tasks can be attached to accounts, contacts, and deals.",
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const response = await request(`/services/data/${apiVersion}/sobjects/Task`, {
|
|
225
|
+
method: "POST",
|
|
226
|
+
body: JSON.stringify({
|
|
227
|
+
Subject: operation.field ? humanizeField(operation.field) : "Follow up",
|
|
228
|
+
Description: String(operation.afterValue ?? operation.reason ?? ""),
|
|
229
|
+
Status: "Not Started",
|
|
230
|
+
Priority: "Normal",
|
|
231
|
+
...reference,
|
|
232
|
+
}),
|
|
233
|
+
});
|
|
234
|
+
return {
|
|
235
|
+
operationId: operation.id,
|
|
236
|
+
status: "applied",
|
|
237
|
+
detail: `Created task on ${operation.objectType}/${operation.objectId}.`,
|
|
238
|
+
providerData: { id: response?.id },
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
async function archiveRecord(operation) {
|
|
242
|
+
const sobjectType = SOBJECT_TYPES[operation.objectType];
|
|
243
|
+
if (!sobjectType) {
|
|
244
|
+
return {
|
|
245
|
+
operationId: operation.id,
|
|
246
|
+
status: "skipped",
|
|
247
|
+
detail: "archive_record is supported for accounts, contacts, and deals.",
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
await request(`/services/data/${apiVersion}/sobjects/${sobjectType}/${encodeURIComponent(operation.objectId)}`, { method: "DELETE" });
|
|
251
|
+
return {
|
|
252
|
+
operationId: operation.id,
|
|
253
|
+
status: "applied",
|
|
254
|
+
detail: `Deleted ${sobjectType}/${operation.objectId}.`,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
async function applyOperation(operation) {
|
|
258
|
+
try {
|
|
259
|
+
switch (operation.operation) {
|
|
260
|
+
case "set_field":
|
|
261
|
+
case "clear_field":
|
|
262
|
+
// link_record on a deal is just setting AccountId in Salesforce.
|
|
263
|
+
return await setField(operation);
|
|
264
|
+
case "link_record":
|
|
265
|
+
return await setField({ ...operation, operation: "set_field" });
|
|
266
|
+
case "create_task":
|
|
267
|
+
return await createTask(operation);
|
|
268
|
+
case "archive_record":
|
|
269
|
+
return await archiveRecord(operation);
|
|
270
|
+
default:
|
|
271
|
+
return {
|
|
272
|
+
operationId: operation.id,
|
|
273
|
+
status: "skipped",
|
|
274
|
+
detail: `Unknown operation ${operation.operation}.`,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
return {
|
|
280
|
+
operationId: operation.id,
|
|
281
|
+
status: "failed",
|
|
282
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
async function readField(objectType, objectId, field) {
|
|
287
|
+
const sobjectType = SOBJECT_TYPES[objectType];
|
|
288
|
+
const mappingType = MAPPING_OBJECT_TYPES[objectType];
|
|
289
|
+
if (!sobjectType || !mappingType) {
|
|
290
|
+
throw new Error(`Field reads are only supported for accounts, contacts, and deals.`);
|
|
291
|
+
}
|
|
292
|
+
const defaults = SALESFORCE_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
|
|
293
|
+
const sfField = mappedField(mappings, mappingType, field, defaults[field] ?? field);
|
|
294
|
+
const data = await request(`/services/data/${apiVersion}/sobjects/${sobjectType}/${encodeURIComponent(objectId)}?fields=${encodeURIComponent(sfField)}`);
|
|
295
|
+
return data?.[sfField] ?? null;
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
provider: "salesforce",
|
|
299
|
+
fetchSnapshot,
|
|
300
|
+
fetchChanges,
|
|
301
|
+
applyOperation,
|
|
302
|
+
readField,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
function stringOrUndefined(value) {
|
|
306
|
+
if (value === undefined || value === null || value === "")
|
|
307
|
+
return undefined;
|
|
308
|
+
return String(value);
|
|
309
|
+
}
|
|
310
|
+
function stringOrFallback(value, fallback) {
|
|
311
|
+
return stringOrUndefined(value) ?? fallback;
|
|
312
|
+
}
|
|
313
|
+
function numberOrUndefined(value) {
|
|
314
|
+
if (value === undefined || value === null || value === "")
|
|
315
|
+
return undefined;
|
|
316
|
+
const parsed = typeof value === "number" ? value : Number.parseFloat(String(value));
|
|
317
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
318
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Salesforce CLI authentication.
|
|
3
|
+
*
|
|
4
|
+
* Salesforce supports the device-authorization grant natively, which is the
|
|
5
|
+
* ideal CLI shape: no localhost server, no client secret — just a code the
|
|
6
|
+
* user confirms on any device. Requires a Connected App with device flow
|
|
7
|
+
* enabled; only its consumer key (client id) is needed.
|
|
8
|
+
*/
|
|
9
|
+
export type SalesforceTokenSet = {
|
|
10
|
+
accessToken: string;
|
|
11
|
+
refreshToken?: string;
|
|
12
|
+
instanceUrl: string;
|
|
13
|
+
/** Conservative local expiry; Salesforce session length is org-defined. */
|
|
14
|
+
expiresAt: number;
|
|
15
|
+
};
|
|
16
|
+
export type SalesforceDeviceAuthorization = {
|
|
17
|
+
deviceCode: string;
|
|
18
|
+
userCode: string;
|
|
19
|
+
verificationUri: string;
|
|
20
|
+
intervalSeconds: number;
|
|
21
|
+
};
|
|
22
|
+
export declare function startSalesforceDeviceLogin(options: {
|
|
23
|
+
clientId: string;
|
|
24
|
+
loginUrl?: string;
|
|
25
|
+
fetchImpl?: typeof fetch;
|
|
26
|
+
}): Promise<SalesforceDeviceAuthorization>;
|
|
27
|
+
export declare function pollSalesforceDeviceLogin(options: {
|
|
28
|
+
clientId: string;
|
|
29
|
+
deviceCode: string;
|
|
30
|
+
intervalSeconds: number;
|
|
31
|
+
loginUrl?: string;
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
fetchImpl?: typeof fetch;
|
|
34
|
+
}): Promise<SalesforceTokenSet>;
|
|
35
|
+
export declare function refreshSalesforceToken(options: {
|
|
36
|
+
clientId: string;
|
|
37
|
+
refreshToken: string;
|
|
38
|
+
loginUrl?: string;
|
|
39
|
+
fetchImpl?: typeof fetch;
|
|
40
|
+
}): Promise<SalesforceTokenSet>;
|
|
41
|
+
export declare function validateSalesforceToken(accessToken: string, instanceUrl: string, fetchImpl?: typeof fetch): Promise<{
|
|
42
|
+
ok: boolean;
|
|
43
|
+
detail: string;
|
|
44
|
+
}>;
|