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