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.
Files changed (77) hide show
  1. package/CHANGELOG.md +381 -0
  2. package/INSTALL_FOR_AGENTS.md +87 -0
  3. package/LICENSE +202 -0
  4. package/README.md +230 -0
  5. package/dist/audit.d.ts +7 -0
  6. package/dist/audit.js +202 -0
  7. package/dist/bin.d.ts +2 -0
  8. package/dist/bin.js +6 -0
  9. package/dist/cli.d.ts +38 -0
  10. package/dist/cli.js +915 -0
  11. package/dist/config.d.ts +36 -0
  12. package/dist/config.js +85 -0
  13. package/dist/connector.d.ts +30 -0
  14. package/dist/connector.js +94 -0
  15. package/dist/connectors/hubspot.d.ts +20 -0
  16. package/dist/connectors/hubspot.js +409 -0
  17. package/dist/connectors/hubspotAuth.d.ts +42 -0
  18. package/dist/connectors/hubspotAuth.js +189 -0
  19. package/dist/connectors/salesforce.d.ts +26 -0
  20. package/dist/connectors/salesforce.js +318 -0
  21. package/dist/connectors/salesforceAuth.d.ts +44 -0
  22. package/dist/connectors/salesforceAuth.js +120 -0
  23. package/dist/connectors/stripe.d.ts +27 -0
  24. package/dist/connectors/stripe.js +176 -0
  25. package/dist/credentials.d.ts +75 -0
  26. package/dist/credentials.js +197 -0
  27. package/dist/demo.d.ts +20 -0
  28. package/dist/demo.js +169 -0
  29. package/dist/diff.d.ts +46 -0
  30. package/dist/diff.js +107 -0
  31. package/dist/format.d.ts +3 -0
  32. package/dist/format.js +109 -0
  33. package/dist/index.d.ts +18 -0
  34. package/dist/index.js +17 -0
  35. package/dist/mappings.d.ts +8 -0
  36. package/dist/mappings.js +123 -0
  37. package/dist/mcp-bin.d.ts +2 -0
  38. package/dist/mcp-bin.js +33 -0
  39. package/dist/mcp.d.ts +1 -0
  40. package/dist/mcp.js +140 -0
  41. package/dist/merge.d.ts +48 -0
  42. package/dist/merge.js +145 -0
  43. package/dist/planStore.d.ts +31 -0
  44. package/dist/planStore.js +116 -0
  45. package/dist/rules.d.ts +24 -0
  46. package/dist/rules.js +512 -0
  47. package/dist/sampleData.d.ts +2 -0
  48. package/dist/sampleData.js +115 -0
  49. package/dist/types.d.ts +294 -0
  50. package/dist/types.js +8 -0
  51. package/docs/api.md +72 -0
  52. package/docs/roadmap-to-1.0.md +121 -0
  53. package/llms.txt +25 -0
  54. package/package.json +76 -0
  55. package/src/audit.ts +242 -0
  56. package/src/bin.ts +7 -0
  57. package/src/cli.ts +1042 -0
  58. package/src/config.ts +113 -0
  59. package/src/connector.ts +140 -0
  60. package/src/connectors/hubspot.ts +528 -0
  61. package/src/connectors/hubspotAuth.ts +246 -0
  62. package/src/connectors/salesforce.ts +420 -0
  63. package/src/connectors/salesforceAuth.ts +167 -0
  64. package/src/connectors/stripe.ts +215 -0
  65. package/src/credentials.ts +282 -0
  66. package/src/demo.ts +200 -0
  67. package/src/diff.ts +158 -0
  68. package/src/format.ts +162 -0
  69. package/src/index.ts +129 -0
  70. package/src/mappings.ts +157 -0
  71. package/src/mcp-bin.ts +32 -0
  72. package/src/mcp.ts +185 -0
  73. package/src/merge.ts +235 -0
  74. package/src/planStore.ts +155 -0
  75. package/src/rules.ts +539 -0
  76. package/src/sampleData.ts +117 -0
  77. package/src/types.ts +372 -0
@@ -0,0 +1,167 @@
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
+
10
+ const DEFAULT_LOGIN_URL = "https://login.salesforce.com";
11
+
12
+ export type SalesforceTokenSet = {
13
+ accessToken: string;
14
+ refreshToken?: string;
15
+ instanceUrl: string;
16
+ /** Conservative local expiry; Salesforce session length is org-defined. */
17
+ expiresAt: number;
18
+ };
19
+
20
+ export type SalesforceDeviceAuthorization = {
21
+ deviceCode: string;
22
+ userCode: string;
23
+ verificationUri: string;
24
+ intervalSeconds: number;
25
+ };
26
+
27
+ const SESSION_TTL_MS = 2 * 60 * 60 * 1000;
28
+
29
+ function tokenUrl(loginUrl: string) {
30
+ return `${loginUrl.replace(/\/$/, "")}/services/oauth2/token`;
31
+ }
32
+
33
+ /**
34
+ * OAuth error responses can echo request parameters. Surface only the
35
+ * standard `error`/`error_description` fields, never the raw body.
36
+ */
37
+ async function safeTokenError(response: Response): Promise<string> {
38
+ let description: string | undefined;
39
+ try {
40
+ const data = await response.json();
41
+ description = data?.error_description ?? data?.error;
42
+ } catch {
43
+ // Non-JSON body — withhold it entirely.
44
+ }
45
+ return description
46
+ ? `${response.status} (${String(description).slice(0, 200)})`
47
+ : `HTTP ${response.status} ${response.statusText}`.trim();
48
+ }
49
+
50
+ export async function startSalesforceDeviceLogin(options: {
51
+ clientId: string;
52
+ loginUrl?: string;
53
+ fetchImpl?: typeof fetch;
54
+ }): Promise<SalesforceDeviceAuthorization> {
55
+ const fetchImpl = options.fetchImpl ?? fetch;
56
+ const response = await fetchImpl(tokenUrl(options.loginUrl ?? DEFAULT_LOGIN_URL), {
57
+ method: "POST",
58
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
59
+ body: new URLSearchParams({
60
+ response_type: "device_code",
61
+ client_id: options.clientId,
62
+ scope: "api refresh_token",
63
+ }).toString(),
64
+ });
65
+ if (!response.ok) {
66
+ throw new Error(`Salesforce device authorization failed: ${await safeTokenError(response)}`);
67
+ }
68
+ const data = await response.json();
69
+ return {
70
+ deviceCode: data.device_code,
71
+ userCode: data.user_code,
72
+ verificationUri: data.verification_uri,
73
+ intervalSeconds: Number(data.interval ?? 5),
74
+ };
75
+ }
76
+
77
+ export async function pollSalesforceDeviceLogin(options: {
78
+ clientId: string;
79
+ deviceCode: string;
80
+ intervalSeconds: number;
81
+ loginUrl?: string;
82
+ timeoutMs?: number;
83
+ fetchImpl?: typeof fetch;
84
+ }): Promise<SalesforceTokenSet> {
85
+ const fetchImpl = options.fetchImpl ?? fetch;
86
+ const url = tokenUrl(options.loginUrl ?? DEFAULT_LOGIN_URL);
87
+ const deadline = Date.now() + (options.timeoutMs ?? 10 * 60 * 1000);
88
+ let intervalMs = Math.max(1, options.intervalSeconds) * 1000;
89
+
90
+ while (Date.now() < deadline) {
91
+ await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
92
+ const response = await fetchImpl(url, {
93
+ method: "POST",
94
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
95
+ body: new URLSearchParams({
96
+ grant_type: "device",
97
+ client_id: options.clientId,
98
+ code: options.deviceCode,
99
+ }).toString(),
100
+ });
101
+ const data = await response.json().catch(() => ({}));
102
+ if (response.ok && data.access_token && data.instance_url) {
103
+ return {
104
+ accessToken: data.access_token,
105
+ refreshToken: data.refresh_token,
106
+ instanceUrl: data.instance_url,
107
+ expiresAt: Date.now() + SESSION_TTL_MS,
108
+ };
109
+ }
110
+ if (data.error === "authorization_pending") continue;
111
+ if (data.error === "slow_down") {
112
+ intervalMs += 5000;
113
+ continue;
114
+ }
115
+ throw new Error(
116
+ `Salesforce device login failed: ${data.error_description ?? data.error ?? response.status}`,
117
+ );
118
+ }
119
+ throw new Error("Timed out waiting for Salesforce device authorization.");
120
+ }
121
+
122
+ export async function refreshSalesforceToken(options: {
123
+ clientId: string;
124
+ refreshToken: string;
125
+ loginUrl?: string;
126
+ fetchImpl?: typeof fetch;
127
+ }): Promise<SalesforceTokenSet> {
128
+ const fetchImpl = options.fetchImpl ?? fetch;
129
+ const response = await fetchImpl(tokenUrl(options.loginUrl ?? DEFAULT_LOGIN_URL), {
130
+ method: "POST",
131
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
132
+ body: new URLSearchParams({
133
+ grant_type: "refresh_token",
134
+ client_id: options.clientId,
135
+ refresh_token: options.refreshToken,
136
+ }).toString(),
137
+ });
138
+ if (!response.ok) {
139
+ throw new Error(`Salesforce token refresh failed: ${await safeTokenError(response)}`);
140
+ }
141
+ const data = await response.json();
142
+ if (!data.access_token || !data.instance_url) {
143
+ throw new Error("Salesforce refresh response had no access_token/instance_url.");
144
+ }
145
+ return {
146
+ accessToken: data.access_token,
147
+ refreshToken: data.refresh_token ?? options.refreshToken,
148
+ instanceUrl: data.instance_url,
149
+ expiresAt: Date.now() + SESSION_TTL_MS,
150
+ };
151
+ }
152
+
153
+ export async function validateSalesforceToken(
154
+ accessToken: string,
155
+ instanceUrl: string,
156
+ fetchImpl: typeof fetch = fetch,
157
+ ): Promise<{ ok: boolean; detail: string }> {
158
+ const response = await fetchImpl(
159
+ `${instanceUrl.replace(/\/$/, "")}/services/oauth2/userinfo`,
160
+ { headers: { Authorization: `Bearer ${accessToken}` } },
161
+ );
162
+ if (response.ok) {
163
+ return { ok: true, detail: "Token accepted by the Salesforce API." };
164
+ }
165
+ const body = await response.text();
166
+ return { ok: false, detail: `Salesforce rejected the token (${response.status}): ${body}` };
167
+ }
@@ -0,0 +1,215 @@
1
+ import type {
2
+ CanonicalAccount,
3
+ CanonicalContact,
4
+ CanonicalDeal,
5
+ CanonicalGtmSnapshot,
6
+ GtmConnector,
7
+ PatchOperation,
8
+ PatchOperationResult,
9
+ } from "../types.ts";
10
+
11
+ const DEFAULT_API_BASE_URL = "https://api.stripe.com";
12
+
13
+ export type StripeConnectorOptions = {
14
+ /** Stripe secret key (sk_...) or restricted key. */
15
+ getApiKey: () => string | Promise<string>;
16
+ apiBaseUrl?: string;
17
+ /** Injectable fetch for testing. */
18
+ fetchImpl?: typeof fetch;
19
+ };
20
+
21
+ /**
22
+ * Read-only billing connector for Stripe — the first non-CRM connector,
23
+ * proving the GtmConnector contract generalizes to billing systems.
24
+ *
25
+ * Semantics:
26
+ * - READ-ONLY: `applyOperation` always returns a `skipped` result and never
27
+ * writes to Stripe; `readField` is intentionally not implemented, so apply
28
+ * orchestration treats every field as unreadable rather than guessing.
29
+ * - Customers map to canonical accounts, plus a canonical contact when the
30
+ * customer has an email address.
31
+ * - Subscriptions map to canonical deals: active/trialing subscriptions are
32
+ * closed-won, canceled/incomplete_expired/unpaid are closed-lost, and
33
+ * anything else (e.g. past_due, incomplete) is still open.
34
+ * - Stripe amounts are minor units (cents); the canonical model uses major
35
+ * units, so amounts are divided by 100 at this boundary.
36
+ * - Billing systems have no sales users and no activities, so both
37
+ * collections are always empty.
38
+ */
39
+ export function createStripeConnector(options: StripeConnectorOptions): GtmConnector {
40
+ const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
41
+ const fetchImpl = options.fetchImpl ?? fetch;
42
+
43
+ async function request(path: string): Promise<any> {
44
+ const apiKey = await options.getApiKey();
45
+ const response = await fetchImpl(`${baseUrl}${path}`, {
46
+ headers: { Authorization: `Bearer ${apiKey}` },
47
+ });
48
+ if (!response.ok) {
49
+ const body = await response.text();
50
+ throw new Error(`Stripe API error ${response.status}: ${body}`);
51
+ }
52
+ return response.json();
53
+ }
54
+
55
+ /** Stripe list pagination: follow `has_more` with `starting_after=<last id>`. */
56
+ async function list(path: string): Promise<any[]> {
57
+ const results: any[] = [];
58
+ let startingAfter: string | undefined;
59
+ do {
60
+ const separator = path.includes("?") ? "&" : "?";
61
+ const data = await request(
62
+ `${path}${startingAfter ? `${separator}starting_after=${encodeURIComponent(startingAfter)}` : ""}`,
63
+ );
64
+ const page: any[] = data.data ?? [];
65
+ results.push(...page);
66
+ startingAfter =
67
+ data.has_more === true && page.length > 0 ? String(page.at(-1).id) : undefined;
68
+ } while (startingAfter);
69
+ return results;
70
+ }
71
+
72
+ async function assembleSnapshot(createdGte?: number): Promise<CanonicalGtmSnapshot> {
73
+ // Stripe filters list endpoints on the integer `created` field via
74
+ // created[gte]=<unix seconds>; omitted on a full snapshot.
75
+ const createdFilter =
76
+ createdGte === undefined ? "" : `&created[gte]=${createdGte}`;
77
+ const customers = await list(`/v1/customers?limit=100${createdFilter}`);
78
+ const accounts: CanonicalAccount[] = [];
79
+ const contacts: CanonicalContact[] = [];
80
+ for (const customer of customers) {
81
+ if (!customer.id) continue;
82
+ const id = String(customer.id);
83
+ const email = stringOrUndefined(customer.email);
84
+ // The email domain is the cross-system match key: mergeSnapshots
85
+ // collapses this billing account onto the same company's CRM account
86
+ // by domain, so revenue data lands on the right merged record.
87
+ const domain = email?.includes("@") ? email.split("@").at(-1)!.toLowerCase() : undefined;
88
+ accounts.push({
89
+ id,
90
+ provider: "stripe",
91
+ crmId: id,
92
+ identities: [{ provider: "stripe", externalId: id }],
93
+ name: stringOrUndefined(customer.name) ?? email ?? `Customer ${id}`,
94
+ domain,
95
+ raw: customer,
96
+ });
97
+ if (email) {
98
+ // Stripe customers carry a single billing email, not split names;
99
+ // name parts stay undefined so merge-by-email can fill them in.
100
+ contacts.push({
101
+ id: `${id}_contact`,
102
+ provider: "stripe",
103
+ crmId: id,
104
+ identities: [{ provider: "stripe", externalId: id }],
105
+ accountId: id,
106
+ email,
107
+ raw: customer,
108
+ });
109
+ }
110
+ }
111
+
112
+ const subscriptions = await list(
113
+ `/v1/subscriptions?status=all&limit=100${createdFilter}`,
114
+ );
115
+ const deals: CanonicalDeal[] = subscriptions
116
+ .filter((subscription) => subscription.id)
117
+ .map((subscription) => {
118
+ const id = String(subscription.id);
119
+ const items: any[] = subscription.items?.data ?? [];
120
+ const firstPrice = items[0]?.price;
121
+ const status = stringOrUndefined(subscription.status);
122
+ const isWon = status === "active" || status === "trialing";
123
+ const isLost =
124
+ status === "canceled" || status === "incomplete_expired" || status === "unpaid";
125
+ // Stripe prices are minor units (cents); canonical amounts are major
126
+ // units. Metered/tiered prices have a null unit_amount — their value
127
+ // isn't knowable from the subscription alone, so leave the amount
128
+ // undefined rather than understating it as 0 (an amountless deal the
129
+ // audit can flag, instead of a silently wrong number).
130
+ const hasUnpricedItem = items.some(
131
+ (item) => numberOrUndefined(item.price?.unit_amount) === undefined,
132
+ );
133
+ const amountCents = hasUnpricedItem
134
+ ? undefined
135
+ : items.reduce(
136
+ (sum, item) =>
137
+ sum +
138
+ (numberOrUndefined(item.price?.unit_amount) ?? 0) *
139
+ (numberOrUndefined(item.quantity) ?? 1),
140
+ 0,
141
+ );
142
+ return {
143
+ id,
144
+ provider: "stripe",
145
+ crmId: id,
146
+ identities: [{ provider: "stripe", externalId: id }],
147
+ accountId: stringOrUndefined(subscription.customer),
148
+ name: `Subscription ${firstPrice?.nickname ?? firstPrice?.id ?? id}`,
149
+ amount: amountCents === undefined ? undefined : amountCents / 100,
150
+ currency: stringOrUndefined(firstPrice?.currency)?.toUpperCase(),
151
+ stage: status,
152
+ isClosed: isWon || isLost,
153
+ isWon,
154
+ closeDate:
155
+ typeof subscription.start_date === "number"
156
+ ? new Date(subscription.start_date * 1000).toISOString().split("T")[0]
157
+ : undefined,
158
+ raw: subscription,
159
+ };
160
+ });
161
+
162
+ return {
163
+ generatedAt: new Date().toISOString(),
164
+ provider: "stripe",
165
+ // Billing systems have neither sales users nor activities.
166
+ users: [],
167
+ accounts,
168
+ contacts,
169
+ deals,
170
+ activities: [],
171
+ };
172
+ }
173
+
174
+ async function fetchSnapshot(): Promise<CanonicalGtmSnapshot> {
175
+ return assembleSnapshot();
176
+ }
177
+
178
+ /**
179
+ * Customers and subscriptions created since `sinceIso`. Stripe filters on the
180
+ * integer `created` field (unix seconds), so the ISO timestamp is converted
181
+ * to unix seconds for the `created[gte]` query param. Note this catches
182
+ * newly-created records only, not status changes on existing ones.
183
+ */
184
+ async function fetchChanges(sinceIso: string): Promise<CanonicalGtmSnapshot> {
185
+ const sinceMs = Date.parse(sinceIso);
186
+ if (!Number.isFinite(sinceMs)) throw new Error(`Invalid since timestamp: ${sinceIso}`);
187
+ return assembleSnapshot(Math.floor(sinceMs / 1000));
188
+ }
189
+
190
+ async function applyOperation(operation: PatchOperation): Promise<PatchOperationResult> {
191
+ return {
192
+ operationId: operation.id,
193
+ status: "skipped",
194
+ detail: "The stripe connector is read-only.",
195
+ };
196
+ }
197
+
198
+ return {
199
+ provider: "stripe",
200
+ fetchSnapshot,
201
+ fetchChanges,
202
+ applyOperation,
203
+ };
204
+ }
205
+
206
+ function stringOrUndefined(value: unknown): string | undefined {
207
+ if (value === undefined || value === null || value === "") return undefined;
208
+ return String(value);
209
+ }
210
+
211
+ function numberOrUndefined(value: unknown): number | undefined {
212
+ if (value === undefined || value === null || value === "") return undefined;
213
+ const parsed = typeof value === "number" ? value : Number.parseFloat(String(value));
214
+ return Number.isFinite(parsed) ? parsed : undefined;
215
+ }
@@ -0,0 +1,282 @@
1
+ import {
2
+ chmodSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ unlinkSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { refreshHubspotToken } from "./connectors/hubspotAuth.ts";
12
+ import { refreshSalesforceToken } from "./connectors/salesforceAuth.ts";
13
+
14
+ /**
15
+ * Local CLI credential store: ~/.fullstackgtm/credentials.json (0600), or
16
+ * $FSGTM_HOME/credentials.json when set. Environment tokens always win over
17
+ * stored credentials so CI and agent sandboxes never touch the filesystem.
18
+ */
19
+
20
+ export type StoredCredential = {
21
+ kind: "private_app" | "oauth" | "broker";
22
+ accessToken: string;
23
+ refreshToken?: string;
24
+ /** Epoch ms when the access token expires (oauth only). */
25
+ expiresAt?: number;
26
+ /** Client credentials kept for refresh when the user brings their own app. */
27
+ clientId?: string;
28
+ clientSecret?: string;
29
+ scopes?: string[];
30
+ /** Hosted FullStackGTM deployment the broker token belongs to. */
31
+ baseUrl?: string;
32
+ /** Salesforce instance URL the access token belongs to. */
33
+ instanceUrl?: string;
34
+ /** Salesforce login host used for device flow refresh. */
35
+ loginUrl?: string;
36
+ createdAt: string;
37
+ updatedAt: string;
38
+ };
39
+
40
+ type CredentialsFile = {
41
+ version: 1;
42
+ providers: Record<string, StoredCredential>;
43
+ };
44
+
45
+ export function credentialsDir(): string {
46
+ return process.env.FSGTM_HOME ?? join(homedir(), ".fullstackgtm");
47
+ }
48
+
49
+ export function credentialsPath(): string {
50
+ return join(credentialsDir(), "credentials.json");
51
+ }
52
+
53
+ /**
54
+ * Create the fullstackgtm home directory and force it to 0700 even if it
55
+ * already existed at looser permissions (mkdirSync's `mode` is ignored for
56
+ * existing directories, and is subject to umask for new ones). All writers of
57
+ * sensitive data under the home — credentials and plan files — go through
58
+ * this so directory permissions are actually enforced, not merely requested.
59
+ */
60
+ export function ensureSecureHomeDir(): string {
61
+ const dir = credentialsDir();
62
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
63
+ try {
64
+ chmodSync(dir, 0o700);
65
+ } catch {
66
+ // Non-POSIX filesystems (e.g. Windows) ignore chmod; nothing to enforce.
67
+ }
68
+ return dir;
69
+ }
70
+
71
+ /** Write a 0600 file under the home, enforcing the mode even on rewrite. */
72
+ export function writeSecureFile(path: string, contents: string) {
73
+ writeFileSync(path, contents, { mode: 0o600 });
74
+ try {
75
+ chmodSync(path, 0o600);
76
+ } catch {
77
+ // Non-POSIX filesystems ignore chmod.
78
+ }
79
+ }
80
+
81
+ function readFile(): CredentialsFile {
82
+ try {
83
+ const parsed = JSON.parse(readFileSync(credentialsPath(), "utf8"));
84
+ if (parsed && typeof parsed === "object" && parsed.version === 1 && parsed.providers) {
85
+ return parsed as CredentialsFile;
86
+ }
87
+ } catch {
88
+ // Missing or unreadable file falls through to an empty store.
89
+ }
90
+ return { version: 1, providers: {} };
91
+ }
92
+
93
+ export function getCredential(provider: string): StoredCredential | null {
94
+ return readFile().providers[provider] ?? null;
95
+ }
96
+
97
+ export function storeCredential(provider: string, credential: StoredCredential) {
98
+ const file = readFile();
99
+ file.providers[provider] = credential;
100
+ ensureSecureHomeDir();
101
+ writeSecureFile(credentialsPath(), `${JSON.stringify(file, null, 2)}\n`);
102
+ }
103
+
104
+ export function deleteCredential(provider: string): boolean {
105
+ const file = readFile();
106
+ if (!file.providers[provider]) return false;
107
+ delete file.providers[provider];
108
+ if (Object.keys(file.providers).length === 0 && existsSync(credentialsPath())) {
109
+ unlinkSync(credentialsPath());
110
+ return true;
111
+ }
112
+ writeSecureFile(credentialsPath(), `${JSON.stringify(file, null, 2)}\n`);
113
+ return true;
114
+ }
115
+
116
+ const REFRESH_SKEW_MS = 2 * 60 * 1000;
117
+
118
+ export type HubspotConnection = {
119
+ accessToken: string;
120
+ /** Org-level field mapping overrides, supplied by broker deployments. */
121
+ fieldMappings?: unknown;
122
+ };
123
+
124
+ /**
125
+ * Resolve HubSpot access from stored credentials. Direct logins win over the
126
+ * broker so an operator can always override the team default:
127
+ * 1. stored hubspot login (private app token, or OAuth with silent refresh)
128
+ * 2. stored broker pairing — exchanges the CLI token for a short-lived
129
+ * provider token minted by the hosted deployment from the org's stored
130
+ * sync credentials (and inherits the org's field mappings)
131
+ */
132
+ export async function resolveHubspotConnection(
133
+ options: { fetchImpl?: typeof fetch } = {},
134
+ ): Promise<HubspotConnection | null> {
135
+ const credential = getCredential("hubspot");
136
+ if (credential) {
137
+ return { accessToken: await directHubspotToken(credential, options) };
138
+ }
139
+
140
+ const minted = await brokerMint("hubspot", options);
141
+ if (!minted) return null;
142
+ return { accessToken: minted.accessToken, fieldMappings: minted.fieldMappings };
143
+ }
144
+
145
+ export type SalesforceStoredConnection = {
146
+ accessToken: string;
147
+ instanceUrl: string;
148
+ /** Org-level field mapping overrides, supplied by broker deployments. */
149
+ fieldMappings?: unknown;
150
+ };
151
+
152
+ /**
153
+ * Resolve Salesforce access from stored credentials, same ladder shape as
154
+ * HubSpot: direct login (with silent device-flow refresh) wins over broker.
155
+ */
156
+ export async function resolveSalesforceConnection(
157
+ options: { fetchImpl?: typeof fetch } = {},
158
+ ): Promise<SalesforceStoredConnection | null> {
159
+ const credential = getCredential("salesforce");
160
+ if (credential) {
161
+ if (!credential.instanceUrl) {
162
+ throw new Error(
163
+ "Stored Salesforce credential has no instance URL. Run `fullstackgtm login salesforce` again.",
164
+ );
165
+ }
166
+ const needsRefresh =
167
+ credential.kind === "oauth" &&
168
+ credential.expiresAt !== undefined &&
169
+ Date.now() > credential.expiresAt - REFRESH_SKEW_MS;
170
+ if (!needsRefresh) {
171
+ return { accessToken: credential.accessToken, instanceUrl: credential.instanceUrl };
172
+ }
173
+ if (!credential.refreshToken || !credential.clientId) {
174
+ throw new Error(
175
+ "Stored Salesforce token is expired and cannot be refreshed. Run `fullstackgtm login salesforce` again.",
176
+ );
177
+ }
178
+ const refreshed = await refreshSalesforceToken({
179
+ clientId: credential.clientId,
180
+ refreshToken: credential.refreshToken,
181
+ loginUrl: credential.loginUrl,
182
+ fetchImpl: options.fetchImpl,
183
+ });
184
+ storeCredential("salesforce", {
185
+ ...credential,
186
+ accessToken: refreshed.accessToken,
187
+ refreshToken: refreshed.refreshToken ?? credential.refreshToken,
188
+ instanceUrl: refreshed.instanceUrl,
189
+ expiresAt: refreshed.expiresAt,
190
+ updatedAt: new Date().toISOString(),
191
+ });
192
+ return { accessToken: refreshed.accessToken, instanceUrl: refreshed.instanceUrl };
193
+ }
194
+
195
+ const minted = await brokerMint("salesforce", options);
196
+ if (!minted) return null;
197
+ if (!minted.instanceUrl) {
198
+ throw new Error("The hosted deployment returned no Salesforce instance URL.");
199
+ }
200
+ return {
201
+ accessToken: minted.accessToken,
202
+ instanceUrl: minted.instanceUrl,
203
+ fieldMappings: minted.fieldMappings,
204
+ };
205
+ }
206
+
207
+ async function brokerMint(
208
+ provider: "hubspot" | "salesforce",
209
+ options: { fetchImpl?: typeof fetch },
210
+ ): Promise<{ accessToken: string; instanceUrl?: string; fieldMappings?: unknown } | null> {
211
+ const broker = getCredential("broker");
212
+ if (!broker?.baseUrl) return null;
213
+ const fetchImpl = options.fetchImpl ?? fetch;
214
+ const response = await fetchImpl(`${broker.baseUrl.replace(/\/$/, "")}/api/cli/token`, {
215
+ method: "POST",
216
+ headers: {
217
+ Authorization: `Bearer ${broker.accessToken}`,
218
+ "Content-Type": "application/json",
219
+ },
220
+ body: JSON.stringify({ provider }),
221
+ });
222
+ if (response.status === 401) {
223
+ throw new Error(
224
+ `The hosted deployment rejected this CLI's broker token. Run \`fullstackgtm login --via ${broker.baseUrl}\` again.`,
225
+ );
226
+ }
227
+ if (!response.ok) {
228
+ // Withhold the deployment's raw response body from the error.
229
+ throw new Error(
230
+ `Broker token exchange failed: HTTP ${response.status} ${response.statusText}`.trim(),
231
+ );
232
+ }
233
+ const connection = await response.json();
234
+ return {
235
+ accessToken: String(connection.accessToken),
236
+ instanceUrl: connection.instanceUrl ? String(connection.instanceUrl) : undefined,
237
+ fieldMappings: connection.fieldMappings ?? undefined,
238
+ };
239
+ }
240
+
241
+ /**
242
+ * Resolve a usable HubSpot access token (direct login or broker). Returns
243
+ * null when nothing is stored (callers fall back to instructions).
244
+ */
245
+ export async function resolveHubspotAccessToken(
246
+ options: { fetchImpl?: typeof fetch } = {},
247
+ ): Promise<string | null> {
248
+ const connection = await resolveHubspotConnection(options);
249
+ return connection?.accessToken ?? null;
250
+ }
251
+
252
+ async function directHubspotToken(
253
+ credential: StoredCredential,
254
+ options: { fetchImpl?: typeof fetch },
255
+ ): Promise<string> {
256
+ const needsRefresh =
257
+ credential.kind === "oauth" &&
258
+ credential.expiresAt !== undefined &&
259
+ Date.now() > credential.expiresAt - REFRESH_SKEW_MS;
260
+
261
+ if (!needsRefresh) return credential.accessToken;
262
+ if (!credential.refreshToken || !credential.clientId || !credential.clientSecret) {
263
+ throw new Error(
264
+ "Stored HubSpot OAuth token is expired and cannot be refreshed. Run `fullstackgtm login hubspot` again.",
265
+ );
266
+ }
267
+
268
+ const refreshed = await refreshHubspotToken({
269
+ clientId: credential.clientId,
270
+ clientSecret: credential.clientSecret,
271
+ refreshToken: credential.refreshToken,
272
+ fetchImpl: options.fetchImpl,
273
+ });
274
+ storeCredential("hubspot", {
275
+ ...credential,
276
+ accessToken: refreshed.accessToken,
277
+ refreshToken: refreshed.refreshToken ?? credential.refreshToken,
278
+ expiresAt: refreshed.expiresAt,
279
+ updatedAt: new Date().toISOString(),
280
+ });
281
+ return refreshed.accessToken;
282
+ }