fullstackgtm 0.44.0 → 0.46.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 +293 -1
- package/INSTALL_FOR_AGENTS.md +13 -11
- package/README.md +45 -24
- package/dist/audit.d.ts +3 -1
- package/dist/audit.js +29 -2
- package/dist/backfill.d.ts +86 -0
- package/dist/backfill.js +251 -0
- package/dist/bin.js +4 -1
- package/dist/cli/audit.d.ts +15 -0
- package/dist/cli/audit.js +392 -0
- package/dist/cli/auth.d.ts +82 -0
- package/dist/cli/auth.js +607 -0
- package/dist/cli/backfill.d.ts +1 -0
- package/dist/cli/backfill.js +125 -0
- package/dist/cli/backfillRuns.d.ts +1 -0
- package/dist/cli/backfillRuns.js +187 -0
- package/dist/cli/call.d.ts +1 -0
- package/dist/cli/call.js +387 -0
- package/dist/cli/capabilities.d.ts +30 -0
- package/dist/cli/capabilities.js +197 -0
- package/dist/cli/draft.d.ts +7 -0
- package/dist/cli/draft.js +87 -0
- package/dist/cli/enrich.d.ts +8 -0
- package/dist/cli/enrich.js +806 -0
- package/dist/cli/fix.d.ts +33 -0
- package/dist/cli/fix.js +346 -0
- package/dist/cli/help.d.ts +25 -0
- package/dist/cli/help.js +646 -0
- package/dist/cli/icp.d.ts +7 -0
- package/dist/cli/icp.js +229 -0
- package/dist/cli/init.d.ts +7 -0
- package/dist/cli/init.js +58 -0
- package/dist/cli/market.d.ts +1 -0
- package/dist/cli/market.js +391 -0
- package/dist/cli/plans.d.ts +5 -0
- package/dist/cli/plans.js +456 -0
- package/dist/cli/schedule.d.ts +8 -0
- package/dist/cli/schedule.js +479 -0
- package/dist/cli/shared.d.ts +71 -0
- package/dist/cli/shared.js +342 -0
- package/dist/cli/signals.d.ts +7 -0
- package/dist/cli/signals.js +412 -0
- package/dist/cli/suggest.d.ts +49 -0
- package/dist/cli/suggest.js +135 -0
- package/dist/cli/tam.d.ts +9 -0
- package/dist/cli/tam.js +387 -0
- package/dist/cli/ui.d.ts +142 -0
- package/dist/cli/ui.js +427 -0
- package/dist/cli.d.ts +1 -57
- package/dist/cli.js +123 -5157
- package/dist/connector.d.ts +23 -0
- package/dist/connector.js +47 -0
- package/dist/connectors/hubspot.d.ts +10 -1
- package/dist/connectors/hubspot.js +244 -10
- package/dist/connectors/hubspotAuth.js +5 -1
- package/dist/connectors/linkedin.js +14 -14
- package/dist/connectors/salesforce.d.ts +10 -1
- package/dist/connectors/salesforce.js +39 -6
- package/dist/connectors/signalSources.js +7 -59
- package/dist/connectors/stripe.d.ts +37 -0
- package/dist/connectors/stripe.js +103 -31
- package/dist/enrich.d.ts +7 -0
- package/dist/enrich.js +7 -0
- package/dist/health.d.ts +11 -69
- package/dist/health.js +4 -134
- package/dist/healthScore.d.ts +71 -0
- package/dist/healthScore.js +143 -0
- package/dist/icp.d.ts +15 -11
- package/dist/icp.js +19 -36
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/judge.d.ts +2 -0
- package/dist/judge.js +6 -0
- package/dist/llm.d.ts +29 -0
- package/dist/llm.js +206 -0
- package/dist/market.d.ts +39 -1
- package/dist/market.js +116 -44
- package/dist/marketClassify.d.ts +11 -1
- package/dist/marketClassify.js +17 -2
- package/dist/marketTaxonomy.d.ts +6 -1
- package/dist/marketTaxonomy.js +4 -2
- package/dist/mcp-bin.js +31 -2
- package/dist/mcp.js +372 -166
- package/dist/progress.d.ts +96 -0
- package/dist/progress.js +142 -0
- package/dist/runReport.d.ts +24 -0
- package/dist/runReport.js +139 -4
- package/dist/schedule.d.ts +80 -2
- package/dist/schedule.js +254 -1
- package/dist/spoolFiles.d.ts +44 -0
- package/dist/spoolFiles.js +114 -0
- package/dist/types.d.ts +29 -1
- package/docs/api.md +75 -8
- package/docs/architecture.md +2 -0
- package/docs/linkedin-connector-spec.md +1 -1
- package/docs/signal-spool-format.md +13 -0
- package/llms.txt +18 -9
- package/package.json +10 -3
- package/skills/fullstackgtm/SKILL.md +2 -1
- package/src/audit.ts +27 -1
- package/src/backfill.ts +340 -0
- package/src/bin.ts +5 -1
- package/src/cli/audit.ts +447 -0
- package/src/cli/auth.ts +669 -0
- package/src/cli/backfill.ts +156 -0
- package/src/cli/backfillRuns.ts +198 -0
- package/src/cli/call.ts +414 -0
- package/src/cli/capabilities.ts +215 -0
- package/src/cli/draft.ts +101 -0
- package/src/cli/enrich.ts +908 -0
- package/src/cli/fix.ts +373 -0
- package/src/cli/help.ts +702 -0
- package/src/cli/icp.ts +265 -0
- package/src/cli/init.ts +65 -0
- package/src/cli/market.ts +423 -0
- package/src/cli/plans.ts +524 -0
- package/src/cli/schedule.ts +526 -0
- package/src/cli/shared.ts +392 -0
- package/src/cli/signals.ts +434 -0
- package/src/cli/suggest.ts +151 -0
- package/src/cli/tam.ts +435 -0
- package/src/cli/ui.ts +497 -0
- package/src/cli.ts +122 -5855
- package/src/connector.ts +66 -0
- package/src/connectors/hubspot.ts +273 -9
- package/src/connectors/hubspotAuth.ts +5 -1
- package/src/connectors/linkedin.ts +14 -14
- package/src/connectors/salesforce.ts +51 -3
- package/src/connectors/signalSources.ts +7 -56
- package/src/connectors/stripe.ts +154 -34
- package/src/enrich.ts +13 -0
- package/src/health.ts +14 -213
- package/src/healthScore.ts +223 -0
- package/src/icp.ts +19 -35
- package/src/index.ts +28 -1
- package/src/judge.ts +7 -0
- package/src/llm.ts +239 -0
- package/src/market.ts +157 -44
- package/src/marketClassify.ts +26 -2
- package/src/marketTaxonomy.ts +10 -2
- package/src/mcp-bin.ts +34 -2
- package/src/mcp.ts +270 -63
- package/src/progress.ts +197 -0
- package/src/runReport.ts +159 -4
- package/src/schedule.ts +310 -3
- package/src/spoolFiles.ts +116 -0
- package/src/types.ts +32 -2
- package/docs/dx-punch-list.md +0 -87
- package/docs/roadmap-to-1.0.md +0 -211
package/src/connector.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { dedupeKey } from "./dedupe.ts";
|
|
2
|
+
import { APPLY_STAGES, type ProgressEmitter } from "./progress.ts";
|
|
2
3
|
import { requiresHumanInput } from "./rules.ts";
|
|
3
4
|
import type {
|
|
4
5
|
CanonicalGtmSnapshot,
|
|
@@ -105,6 +106,29 @@ export type ApplyPatchPlanOptions = {
|
|
|
105
106
|
* edited mid-apply is conflicted out instead of overwritten. Default 25.
|
|
106
107
|
*/
|
|
107
108
|
recheckEvery?: number;
|
|
109
|
+
/**
|
|
110
|
+
* Per-operation progress as the run executes (presentation only — a
|
|
111
|
+
* throwing callback never affects the run). `completed` counts every
|
|
112
|
+
* resolved operation including skips and conflicts; `total` is the plan's
|
|
113
|
+
* full operation count.
|
|
114
|
+
*/
|
|
115
|
+
onOperation?: (progress: ApplyProgress) => void;
|
|
116
|
+
/**
|
|
117
|
+
* Shared progress vocabulary (src/progress.ts): the run emits the
|
|
118
|
+
* APPLY_STAGES (preflight → operations → results), one `opResult` per
|
|
119
|
+
* resolved operation (id + status only — no values), and `items` over the
|
|
120
|
+
* plan's operations. Additive alongside `onOperation`; presentation only.
|
|
121
|
+
*/
|
|
122
|
+
progress?: ProgressEmitter;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export type ApplyProgress = {
|
|
126
|
+
completed: number;
|
|
127
|
+
total: number;
|
|
128
|
+
applied: number;
|
|
129
|
+
failed: number;
|
|
130
|
+
conflicts: number;
|
|
131
|
+
skipped: number;
|
|
108
132
|
};
|
|
109
133
|
|
|
110
134
|
const FIELD_WRITE_OPERATIONS = new Set(["set_field", "clear_field", "link_record"]);
|
|
@@ -132,6 +156,9 @@ export async function applyPatchPlan(
|
|
|
132
156
|
}
|
|
133
157
|
|
|
134
158
|
const startedAt = new Date().toISOString();
|
|
159
|
+
const emitStage = (stage: (typeof APPLY_STAGES)[number]) =>
|
|
160
|
+
options.progress?.stage(stage, APPLY_STAGES.indexOf(stage), APPLY_STAGES.length);
|
|
161
|
+
emitStage("preflight");
|
|
135
162
|
const approved = new Set(options.approvedOperationIds);
|
|
136
163
|
const checkConflicts =
|
|
137
164
|
options.checkConflicts ?? typeof connector.readField === "function";
|
|
@@ -208,6 +235,7 @@ export async function applyPatchPlan(
|
|
|
208
235
|
: "Operation was not approved.",
|
|
209
236
|
});
|
|
210
237
|
}
|
|
238
|
+
emitStage("results");
|
|
211
239
|
return {
|
|
212
240
|
planId: plan.id,
|
|
213
241
|
provider: connector.provider,
|
|
@@ -313,7 +341,43 @@ export async function applyPatchPlan(
|
|
|
313
341
|
}
|
|
314
342
|
}
|
|
315
343
|
|
|
344
|
+
// Progress ticker state: every loop iteration below pushes at most one
|
|
345
|
+
// result; `lastNotified` guards the rare early-throw path so an iteration
|
|
346
|
+
// that pushed nothing reports nothing.
|
|
347
|
+
const progressCounts = { applied: 0, failed: 0, conflicts: 0, skipped: 0 };
|
|
348
|
+
const resultsBefore = results.length;
|
|
349
|
+
let lastNotified = results.length;
|
|
350
|
+
const notifyProgress = () => {
|
|
351
|
+
if ((!options.onOperation && !options.progress) || results.length === lastNotified) return;
|
|
352
|
+
lastNotified = results.length;
|
|
353
|
+
const last = results[results.length - 1];
|
|
354
|
+
if (last.status === "applied") progressCounts.applied += 1;
|
|
355
|
+
else if (last.status === "failed") progressCounts.failed += 1;
|
|
356
|
+
else if (last.status === "conflict") progressCounts.conflicts += 1;
|
|
357
|
+
else progressCounts.skipped += 1;
|
|
358
|
+
// Shared vocabulary: operation id + status only — a conflict/failure
|
|
359
|
+
// detail can echo field values, which never leave the machine.
|
|
360
|
+
options.progress?.opResult(last.operationId, last.status);
|
|
361
|
+
options.progress?.items(results.length - resultsBefore, plan.operations.length);
|
|
362
|
+
if (options.onOperation) {
|
|
363
|
+
try {
|
|
364
|
+
options.onOperation({
|
|
365
|
+
completed: results.length - resultsBefore,
|
|
366
|
+
total: plan.operations.length,
|
|
367
|
+
...progressCounts,
|
|
368
|
+
});
|
|
369
|
+
} catch {
|
|
370
|
+
// progress is presentation-only
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
emitStage("operations");
|
|
316
376
|
for (const operation of plan.operations) {
|
|
377
|
+
// Report the previous iteration's result (guarded: no-op if it pushed
|
|
378
|
+
// nothing). One-iteration lag keeps this a two-line hook instead of a
|
|
379
|
+
// notify after all ten push sites; the loop-exit call below flushes the last.
|
|
380
|
+
notifyProgress();
|
|
317
381
|
const batchedResult = batched.get(operation.id);
|
|
318
382
|
if (batchedResult) {
|
|
319
383
|
results.push(batchedResult);
|
|
@@ -403,6 +467,8 @@ export async function applyPatchPlan(
|
|
|
403
467
|
});
|
|
404
468
|
}
|
|
405
469
|
}
|
|
470
|
+
notifyProgress();
|
|
471
|
+
emitStage("results");
|
|
406
472
|
|
|
407
473
|
return {
|
|
408
474
|
planId: plan.id,
|
|
@@ -18,7 +18,9 @@ import type {
|
|
|
18
18
|
GtmObjectType,
|
|
19
19
|
PatchOperation,
|
|
20
20
|
PatchOperationResult,
|
|
21
|
+
SnapshotProgress,
|
|
21
22
|
} from "../types.ts";
|
|
23
|
+
import { SNAPSHOT_PULL_STAGES, type ProgressEmitter } from "../progress.ts";
|
|
22
24
|
|
|
23
25
|
const DEFAULT_API_BASE_URL = "https://api.hubapi.com";
|
|
24
26
|
|
|
@@ -30,6 +32,14 @@ export type HubspotConnectorOptions = {
|
|
|
30
32
|
apiBaseUrl?: string;
|
|
31
33
|
/** Injectable fetch for testing. */
|
|
32
34
|
fetchImpl?: typeof fetch;
|
|
35
|
+
/** Per-page snapshot-pull progress (presentation only — errors are swallowed). */
|
|
36
|
+
onProgress?: (progress: SnapshotProgress) => void;
|
|
37
|
+
/**
|
|
38
|
+
* Shared progress vocabulary (src/progress.ts): the snapshot pull emits a
|
|
39
|
+
* `stage` per object type and an `items` heartbeat per page. Additive
|
|
40
|
+
* alongside the legacy `onProgress` callback; both are presentation-only.
|
|
41
|
+
*/
|
|
42
|
+
progress?: ProgressEmitter;
|
|
33
43
|
};
|
|
34
44
|
|
|
35
45
|
const OBJECT_PATHS: Partial<Record<GtmObjectType, string>> = {
|
|
@@ -44,6 +54,15 @@ const MAPPING_OBJECT_TYPES: Partial<Record<GtmObjectType, Exclude<CrmObjectType,
|
|
|
44
54
|
deal: "deals",
|
|
45
55
|
};
|
|
46
56
|
|
|
57
|
+
// SnapshotProgress object types → the shared SNAPSHOT_PULL_STAGES names, so
|
|
58
|
+
// the CLI checklist and the app StageTimeline read the same stage labels.
|
|
59
|
+
const PULL_STAGE_BY_TYPE: Record<SnapshotProgress["objectType"], (typeof SNAPSHOT_PULL_STAGES)[number]> = {
|
|
60
|
+
user: "owners",
|
|
61
|
+
account: "accounts",
|
|
62
|
+
contact: "contacts",
|
|
63
|
+
deal: "deals",
|
|
64
|
+
};
|
|
65
|
+
|
|
47
66
|
/**
|
|
48
67
|
* Reference connector for HubSpot.
|
|
49
68
|
*
|
|
@@ -64,6 +83,32 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
64
83
|
// match value (email): HubSpot search is eventually consistent, so a contact
|
|
65
84
|
// created earlier in this apply run is invisible to a later search.
|
|
66
85
|
const createdContactsByMatch = new Map<string, string>();
|
|
86
|
+
// Same-run dedup for `create_record` deal creates, keyed by
|
|
87
|
+
// matchKey:matchValue (e.g. stripe_invoice_id:in_123) — same
|
|
88
|
+
// eventual-consistency rationale as the contact cache.
|
|
89
|
+
const createdDealsByMatch = new Map<string, string>();
|
|
90
|
+
// One /crm/v3/pipelines/deals read per connector lifetime (one apply run):
|
|
91
|
+
// pipeline/stage metadata doesn't change mid-run, and deal creates resolve
|
|
92
|
+
// the closed-won stage from it per operation.
|
|
93
|
+
let dealPipelinesCache: Promise<any[]> | undefined;
|
|
94
|
+
// Custom deal dedupe properties ensured (or confirmed missing) this run.
|
|
95
|
+
const ensuredDealProperties = new Map<string, boolean>();
|
|
96
|
+
|
|
97
|
+
// Per-page snapshot-pull progress: the legacy onProgress callback plus the
|
|
98
|
+
// shared emitter (stage on the first page of each object type, items per
|
|
99
|
+
// page). Both are presentation-only — callers already swallow errors.
|
|
100
|
+
let lastPullStage: string | undefined;
|
|
101
|
+
const pullProgress = (objectType: SnapshotProgress["objectType"], fetched: number) => {
|
|
102
|
+
options.onProgress?.({ objectType, fetched });
|
|
103
|
+
const emitter = options.progress;
|
|
104
|
+
if (!emitter) return;
|
|
105
|
+
const stage = PULL_STAGE_BY_TYPE[objectType];
|
|
106
|
+
if (stage !== lastPullStage) {
|
|
107
|
+
lastPullStage = stage;
|
|
108
|
+
emitter.stage(stage, SNAPSHOT_PULL_STAGES.indexOf(stage), SNAPSHOT_PULL_STAGES.length);
|
|
109
|
+
}
|
|
110
|
+
emitter.items(fetched);
|
|
111
|
+
};
|
|
67
112
|
|
|
68
113
|
async function request(path: string, init: RequestInit = {}): Promise<any> {
|
|
69
114
|
const token = await options.getAccessToken();
|
|
@@ -123,7 +168,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
123
168
|
return map;
|
|
124
169
|
}
|
|
125
170
|
|
|
126
|
-
async function list(path: string): Promise<any[]> {
|
|
171
|
+
async function list(path: string, onPage?: (fetched: number) => void): Promise<any[]> {
|
|
127
172
|
const results: any[] = [];
|
|
128
173
|
let after: string | undefined;
|
|
129
174
|
const seen = new Set<string>();
|
|
@@ -137,6 +182,11 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
137
182
|
const separator = path.includes("?") ? "&" : "?";
|
|
138
183
|
const data = await request(`${path}${after ? `${separator}after=${encodeURIComponent(after)}` : ""}`);
|
|
139
184
|
results.push(...(data.results ?? []));
|
|
185
|
+
try {
|
|
186
|
+
onPage?.(results.length);
|
|
187
|
+
} catch {
|
|
188
|
+
// progress is presentation-only; never let it fail a pull
|
|
189
|
+
}
|
|
140
190
|
after = data.paging?.next?.after;
|
|
141
191
|
} while (after);
|
|
142
192
|
return results;
|
|
@@ -149,7 +199,9 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
149
199
|
withAssociations: boolean,
|
|
150
200
|
) => Promise<any[]>,
|
|
151
201
|
): Promise<CanonicalGtmSnapshot> {
|
|
152
|
-
const owners = await list("/crm/v3/owners?limit=100")
|
|
202
|
+
const owners = await list("/crm/v3/owners?limit=100", (fetched) =>
|
|
203
|
+
pullProgress("user", fetched),
|
|
204
|
+
);
|
|
153
205
|
const users: CanonicalUser[] = owners
|
|
154
206
|
.filter((owner) => owner.id)
|
|
155
207
|
.map((owner) => ({
|
|
@@ -296,6 +348,9 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
296
348
|
};
|
|
297
349
|
});
|
|
298
350
|
|
|
351
|
+
// Deliver any throttled trailing items heartbeat before the pull returns.
|
|
352
|
+
options.progress?.flush();
|
|
353
|
+
|
|
299
354
|
return {
|
|
300
355
|
generatedAt: new Date().toISOString(),
|
|
301
356
|
provider: "hubspot",
|
|
@@ -310,9 +365,11 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
310
365
|
}
|
|
311
366
|
|
|
312
367
|
async function fetchSnapshot(): Promise<CanonicalGtmSnapshot> {
|
|
368
|
+
const canonicalType = { companies: "account", contacts: "contact", deals: "deal" } as const;
|
|
313
369
|
return assembleSnapshot((objectType, properties, withAssociations) =>
|
|
314
370
|
list(
|
|
315
371
|
`/crm/v3/objects/${objectType}?limit=100&properties=${properties}${withAssociations ? "&associations=companies" : ""}`,
|
|
372
|
+
(fetched) => pullProgress(canonicalType[objectType], fetched),
|
|
316
373
|
),
|
|
317
374
|
);
|
|
318
375
|
}
|
|
@@ -597,12 +654,215 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
597
654
|
return id;
|
|
598
655
|
}
|
|
599
656
|
|
|
657
|
+
/** Exact-value deal lookup (by any searchable property) for resolve-first creates. */
|
|
658
|
+
async function searchDealsBy(property: string, value: string): Promise<string[]> {
|
|
659
|
+
const data = await request(`/crm/v3/objects/deals/search`, {
|
|
660
|
+
method: "POST",
|
|
661
|
+
body: JSON.stringify({
|
|
662
|
+
filterGroups: [{ filters: [{ propertyName: property, operator: "EQ", value }] }],
|
|
663
|
+
properties: [property],
|
|
664
|
+
limit: 3,
|
|
665
|
+
}),
|
|
666
|
+
});
|
|
667
|
+
return ((data?.results ?? []) as Array<{ id: string }>).map((row) => String(row.id));
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Make sure the deal dedupe property (e.g. "stripe_invoice_id") exists so
|
|
672
|
+
* both the resolve-first search and the stamped value are real. Best-effort
|
|
673
|
+
* create on a confirmed miss; returns false when the property can neither
|
|
674
|
+
* be read nor created — the caller must SKIP then (creating a deal without
|
|
675
|
+
* its dedupe key would make every replay a duplicate).
|
|
676
|
+
*/
|
|
677
|
+
async function ensureDealProperty(name: string): Promise<boolean> {
|
|
678
|
+
const cached = ensuredDealProperties.get(name);
|
|
679
|
+
if (cached !== undefined) return cached;
|
|
680
|
+
let ok: boolean;
|
|
681
|
+
try {
|
|
682
|
+
await request(`/crm/v3/properties/deals/${encodeURIComponent(name)}`);
|
|
683
|
+
ok = true;
|
|
684
|
+
} catch {
|
|
685
|
+
// Missing (404) or unreadable — try to create it; a failure here means
|
|
686
|
+
// we cannot guarantee the dedupe key, so report false.
|
|
687
|
+
try {
|
|
688
|
+
await request(`/crm/v3/properties/deals`, {
|
|
689
|
+
method: "POST",
|
|
690
|
+
body: JSON.stringify({
|
|
691
|
+
name,
|
|
692
|
+
label: name,
|
|
693
|
+
type: "string",
|
|
694
|
+
fieldType: "text",
|
|
695
|
+
groupName: "dealinformation",
|
|
696
|
+
hasUniqueValue: false,
|
|
697
|
+
}),
|
|
698
|
+
});
|
|
699
|
+
ok = true;
|
|
700
|
+
} catch {
|
|
701
|
+
ok = false;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
ensuredDealProperties.set(name, ok);
|
|
705
|
+
return ok;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Resolve the target pipeline and its closed-won stage from the portal's
|
|
710
|
+
* own pipeline metadata (one read per run). The pipeline is picked by id or
|
|
711
|
+
* case-insensitive label (`hint`), else the default (lowest displayOrder);
|
|
712
|
+
* the closed-won stage is the one whose metadata says isClosed with
|
|
713
|
+
* probability 1 — NEVER a substring guess against stage names. Returns null
|
|
714
|
+
* when either is unresolvable so the caller skips instead of guessing.
|
|
715
|
+
*/
|
|
716
|
+
async function resolveClosedWonStage(
|
|
717
|
+
hint: string | undefined,
|
|
718
|
+
): Promise<{ pipelineId: string; stageId: string } | null> {
|
|
719
|
+
if (!dealPipelinesCache) {
|
|
720
|
+
dealPipelinesCache = request("/crm/v3/pipelines/deals").then(
|
|
721
|
+
(data) => (data?.results ?? []) as any[],
|
|
722
|
+
);
|
|
723
|
+
// A failed read must not poison the cache for the rest of the run.
|
|
724
|
+
dealPipelinesCache = dealPipelinesCache.catch(() => []);
|
|
725
|
+
}
|
|
726
|
+
const pipelines = await dealPipelinesCache;
|
|
727
|
+
if (pipelines.length === 0) return null;
|
|
728
|
+
let pipeline: any | undefined;
|
|
729
|
+
if (hint) {
|
|
730
|
+
const wanted = hint.trim().toLowerCase();
|
|
731
|
+
pipeline = pipelines.find(
|
|
732
|
+
(candidate) =>
|
|
733
|
+
String(candidate?.id ?? "").toLowerCase() === wanted ||
|
|
734
|
+
String(candidate?.label ?? "").trim().toLowerCase() === wanted,
|
|
735
|
+
);
|
|
736
|
+
if (!pipeline) return null; // an explicit hint that matches nothing is an error, not "use default"
|
|
737
|
+
} else {
|
|
738
|
+
pipeline = [...pipelines].sort(
|
|
739
|
+
(a, b) => Number(a?.displayOrder ?? 0) - Number(b?.displayOrder ?? 0),
|
|
740
|
+
)[0];
|
|
741
|
+
}
|
|
742
|
+
if (!pipeline?.id) return null;
|
|
743
|
+
const stage = (pipeline.stages ?? []).find((candidate: any) => {
|
|
744
|
+
const meta = candidate?.metadata ?? {};
|
|
745
|
+
return (
|
|
746
|
+
candidate?.id &&
|
|
747
|
+
String(meta.isClosed).toLowerCase() === "true" &&
|
|
748
|
+
Number(meta.probability) === 1
|
|
749
|
+
);
|
|
750
|
+
});
|
|
751
|
+
if (!stage) return null;
|
|
752
|
+
return { pipelineId: String(pipeline.id), stageId: String(stage.id) };
|
|
753
|
+
}
|
|
754
|
+
|
|
600
755
|
/**
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
|
|
756
|
+
* Deal branch of create_record (backfill: one closed-won deal per paid
|
|
757
|
+
* invoice). Resolve-first on the custom dedupe property (ensured to exist
|
|
758
|
+
* first), stage resolved from pipeline metadata, company association
|
|
759
|
+
* best-effort — same contract as the contact branch.
|
|
760
|
+
*/
|
|
761
|
+
async function createDealRecord(
|
|
762
|
+
operation: PatchOperation,
|
|
763
|
+
payload: CreateRecordPayload,
|
|
764
|
+
matchValue: string,
|
|
765
|
+
): Promise<PatchOperationResult> {
|
|
766
|
+
const matchKey = String(payload.matchKey ?? "").trim();
|
|
767
|
+
if (!matchKey) {
|
|
768
|
+
return { operationId: operation.id, status: "skipped", detail: "create_record for deals needs a matchKey (the dedupe property, e.g. stripe_invoice_id)." };
|
|
769
|
+
}
|
|
770
|
+
const dedupeKey = `${matchKey}:${matchValue.toLowerCase()}`;
|
|
771
|
+
const alreadyCreated = createdDealsByMatch.get(dedupeKey);
|
|
772
|
+
if (alreadyCreated) {
|
|
773
|
+
return { operationId: operation.id, status: "skipped", detail: `Deal ${matchKey}=${matchValue} already created earlier in this run; not duplicating.`, providerData: { id: alreadyCreated, existing: true } };
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// The dedupe property is usually CUSTOM — ensure it exists before the
|
|
777
|
+
// search; searching a property HubSpot doesn't have 400s, and creating a
|
|
778
|
+
// deal without the key stamped would defeat every future resolve-first.
|
|
779
|
+
const propertyOk = await ensureDealProperty(matchKey);
|
|
780
|
+
if (!propertyOk) {
|
|
781
|
+
return { operationId: operation.id, status: "skipped", detail: `Deal dedupe property "${matchKey}" does not exist and could not be created; refusing to create a deal without its dedupe key.` };
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const existing = await searchDealsBy(matchKey, matchValue);
|
|
785
|
+
if (existing.length > 0) {
|
|
786
|
+
createdDealsByMatch.set(dedupeKey, existing[0]);
|
|
787
|
+
return { operationId: operation.id, status: "skipped", detail: `Deal ${matchKey}=${matchValue} already exists (${existing.join(", ")}); resolve-first declined to create.`, providerData: { id: existing[0], existing: true } };
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Stage: only the provider-neutral "closed_won" sentinel is understood,
|
|
791
|
+
// and it must resolve to a real stage id from pipeline metadata.
|
|
792
|
+
if (payload.dealStage !== "closed_won") {
|
|
793
|
+
return { operationId: operation.id, status: "skipped", detail: 'create_record for deals needs dealStage "closed_won" (the connector resolves the real stage id from pipeline metadata).' };
|
|
794
|
+
}
|
|
795
|
+
const resolved = await resolveClosedWonStage(payload.dealPipeline);
|
|
796
|
+
if (!resolved) {
|
|
797
|
+
return {
|
|
798
|
+
operationId: operation.id,
|
|
799
|
+
status: "skipped",
|
|
800
|
+
detail: payload.dealPipeline
|
|
801
|
+
? `Could not resolve pipeline "${payload.dealPipeline}" (by id or label) to a closed-won stage; not guessing a stage.`
|
|
802
|
+
: "Could not resolve the default deal pipeline's closed-won stage (isClosed + probability 1) from pipeline metadata; not guessing a stage.",
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const properties: Record<string, string> = {
|
|
807
|
+
...payload.properties,
|
|
808
|
+
[matchKey]: matchValue,
|
|
809
|
+
pipeline: resolved.pipelineId,
|
|
810
|
+
dealstage: resolved.stageId,
|
|
811
|
+
};
|
|
812
|
+
if (payload.ownerId && !properties.hubspot_owner_id) {
|
|
813
|
+
properties.hubspot_owner_id = String(payload.ownerId);
|
|
814
|
+
}
|
|
815
|
+
let created;
|
|
816
|
+
try {
|
|
817
|
+
created = await request(`/crm/v3/objects/deals`, {
|
|
818
|
+
method: "POST",
|
|
819
|
+
body: JSON.stringify({ properties: { ...properties, hs_object_source_detail_2: `fullstackgtm backfill (${operation.id})` } }),
|
|
820
|
+
});
|
|
821
|
+
} catch {
|
|
822
|
+
// Some portals reject writes to source-detail properties — the provenance
|
|
823
|
+
// stamp is best-effort, the create is not.
|
|
824
|
+
created = await request(`/crm/v3/objects/deals`, {
|
|
825
|
+
method: "POST",
|
|
826
|
+
body: JSON.stringify({ properties }),
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
const dealId = String(created.id);
|
|
830
|
+
createdDealsByMatch.set(dedupeKey, dealId);
|
|
831
|
+
|
|
832
|
+
let companyNote = "";
|
|
833
|
+
if (payload.associateCompanyName) {
|
|
834
|
+
try {
|
|
835
|
+
const companyId = await resolveOrCreateCompany(payload.associateCompanyName, payload.associateCompanyDomain, operation.id);
|
|
836
|
+
if (companyId) {
|
|
837
|
+
await request(
|
|
838
|
+
`/crm/v4/objects/deals/${encodeURIComponent(dealId)}/associations/default/companies/${encodeURIComponent(companyId)}`,
|
|
839
|
+
{ method: "PUT" },
|
|
840
|
+
);
|
|
841
|
+
const domainNote = payload.associateCompanyDomain ? ` (${payload.associateCompanyDomain})` : "";
|
|
842
|
+
companyNote = ` Linked to company "${payload.associateCompanyName}"${domainNote} (${companyId}).`;
|
|
843
|
+
} else {
|
|
844
|
+
companyNote = ` Company "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
|
|
845
|
+
}
|
|
846
|
+
} catch (error) {
|
|
847
|
+
// Association is best-effort — the deal create already succeeded.
|
|
848
|
+
companyNote = ` (company link failed: ${error instanceof Error ? error.message : String(error)})`;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
return {
|
|
852
|
+
operationId: operation.id,
|
|
853
|
+
status: "applied",
|
|
854
|
+
detail: `Created deal ${matchKey}=${matchValue} (${dealId}) in stage ${resolved.stageId}.${companyNote}`,
|
|
855
|
+
providerData: { id: dealId, created: true, pipelineId: resolved.pipelineId, stageId: resolved.stageId },
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Create a NET-NEW record (a sourced lead, or a backfilled deal).
|
|
861
|
+
* Resolve-first: re-checks the dedupe key against the live CRM (the
|
|
862
|
+
* plan-time snapshot can be stale) and creates ONLY on a confirmed miss — a
|
|
863
|
+
* record a concurrent writer already added is returned as `skipped`, never
|
|
864
|
+
* duplicated. Contacts and deals can be linked to a resolved-or-created
|
|
865
|
+
* company in the same step.
|
|
606
866
|
*/
|
|
607
867
|
async function createRecord(operation: PatchOperation): Promise<PatchOperationResult> {
|
|
608
868
|
const payload = operation.afterValue as CreateRecordPayload | undefined;
|
|
@@ -610,14 +870,18 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
610
870
|
return { operationId: operation.id, status: "skipped", detail: "create_record needs a CreateRecordPayload afterValue." };
|
|
611
871
|
}
|
|
612
872
|
const objectPath = OBJECT_PATHS[operation.objectType];
|
|
613
|
-
if (operation.objectType !== "contact" && operation.objectType !== "account") {
|
|
614
|
-
return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts and
|
|
873
|
+
if (operation.objectType !== "contact" && operation.objectType !== "account" && operation.objectType !== "deal") {
|
|
874
|
+
return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts, accounts, and deals." };
|
|
615
875
|
}
|
|
616
876
|
const matchValue = String(payload.matchValue ?? "").trim();
|
|
617
877
|
if (!matchValue) {
|
|
618
878
|
return { operationId: operation.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." };
|
|
619
879
|
}
|
|
620
880
|
|
|
881
|
+
if (operation.objectType === "deal") {
|
|
882
|
+
return createDealRecord(operation, payload, matchValue);
|
|
883
|
+
}
|
|
884
|
+
|
|
621
885
|
if (operation.objectType === "account") {
|
|
622
886
|
const id = await resolveOrCreateCompany(matchValue, stringOrUndefined(payload.properties?.domain), operation.id);
|
|
623
887
|
if (id === null) {
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createServer } from "node:http";
|
|
2
1
|
import { randomBytes } from "node:crypto";
|
|
3
2
|
|
|
4
3
|
/**
|
|
@@ -153,6 +152,11 @@ export async function runHubspotLoopbackLogin(
|
|
|
153
152
|
const log = options.log ?? ((message: string) => console.error(message));
|
|
154
153
|
const redirectUri = `http://localhost:${port}/callback`;
|
|
155
154
|
const state = randomBytes(16).toString("hex");
|
|
155
|
+
// Deferred: `node:http` transitively compiles node's bundled undici on
|
|
156
|
+
// import (~10ms+, 12% of a demo-audit run's CPU profile). This module is in
|
|
157
|
+
// the eager graph of every CLI invocation via help.ts/auth.ts, but the
|
|
158
|
+
// server is only needed during the interactive OAuth loopback login.
|
|
159
|
+
const { createServer } = await import("node:http");
|
|
156
160
|
|
|
157
161
|
const code = await new Promise<string>((resolve, reject) => {
|
|
158
162
|
const server = createServer((request, response) => {
|
|
@@ -212,37 +212,37 @@ export function normalizeHeyReachLead(raw: any): LinkedInProspect {
|
|
|
212
212
|
|
|
213
213
|
export const FAKE_LINKEDIN_PROSPECTS: LinkedInProspect[] = [
|
|
214
214
|
{
|
|
215
|
-
firstName: "
|
|
216
|
-
lastName: "
|
|
217
|
-
fullName: "
|
|
215
|
+
firstName: "Example",
|
|
216
|
+
lastName: "ProspectAlpha",
|
|
217
|
+
fullName: "Example Prospect Alpha",
|
|
218
218
|
headline: "VP RevOps",
|
|
219
219
|
jobTitle: "VP Revenue Operations",
|
|
220
220
|
company: "Northwind Logistics",
|
|
221
|
-
profileUrl: "https://
|
|
221
|
+
profileUrl: "https://example.com/linkedin/example-prospect-alpha",
|
|
222
222
|
location: "Chicago, IL",
|
|
223
|
-
email: "
|
|
223
|
+
email: "prospect.alpha@example.com",
|
|
224
224
|
emailStatus: "verified",
|
|
225
225
|
},
|
|
226
226
|
{
|
|
227
|
-
firstName: "
|
|
228
|
-
lastName: "
|
|
229
|
-
fullName: "
|
|
227
|
+
firstName: "Example",
|
|
228
|
+
lastName: "ProspectBeta",
|
|
229
|
+
fullName: "Example Prospect Beta",
|
|
230
230
|
headline: "Head of Sales Ops",
|
|
231
231
|
jobTitle: "Head of Sales Operations",
|
|
232
232
|
company: "Cobalt Health",
|
|
233
|
-
profileUrl: "https://
|
|
233
|
+
profileUrl: "https://example.com/linkedin/example-prospect-beta",
|
|
234
234
|
location: "Austin, TX",
|
|
235
235
|
},
|
|
236
236
|
{
|
|
237
|
-
firstName: "
|
|
238
|
-
lastName: "
|
|
239
|
-
fullName: "
|
|
237
|
+
firstName: "Example",
|
|
238
|
+
lastName: "ProspectGamma",
|
|
239
|
+
fullName: "Example Prospect Gamma",
|
|
240
240
|
headline: "RevOps Manager",
|
|
241
241
|
jobTitle: "Revenue Operations Manager",
|
|
242
242
|
company: "Ferro Systems",
|
|
243
|
-
profileUrl: "https://
|
|
243
|
+
profileUrl: "https://example.com/linkedin/example-prospect-gamma",
|
|
244
244
|
location: "Remote",
|
|
245
|
-
email: "
|
|
245
|
+
email: "prospect.gamma@example.com",
|
|
246
246
|
emailStatus: "guessed",
|
|
247
247
|
},
|
|
248
248
|
];
|
|
@@ -17,7 +17,9 @@ import type {
|
|
|
17
17
|
GtmObjectType,
|
|
18
18
|
PatchOperation,
|
|
19
19
|
PatchOperationResult,
|
|
20
|
+
SnapshotProgress,
|
|
20
21
|
} from "../types.ts";
|
|
22
|
+
import { SNAPSHOT_PULL_STAGES, type ProgressEmitter } from "../progress.ts";
|
|
21
23
|
|
|
22
24
|
const DEFAULT_API_VERSION = "v59.0";
|
|
23
25
|
|
|
@@ -35,6 +37,14 @@ export type SalesforceConnectorOptions = {
|
|
|
35
37
|
apiVersion?: string;
|
|
36
38
|
/** Injectable fetch for testing. */
|
|
37
39
|
fetchImpl?: typeof fetch;
|
|
40
|
+
/** Per-page snapshot-pull progress (presentation only — errors are swallowed). */
|
|
41
|
+
onProgress?: (progress: SnapshotProgress) => void;
|
|
42
|
+
/**
|
|
43
|
+
* Shared progress vocabulary (src/progress.ts): the snapshot pull emits a
|
|
44
|
+
* `stage` per object type and an `items` heartbeat per page. Additive
|
|
45
|
+
* alongside the legacy `onProgress` callback; both are presentation-only.
|
|
46
|
+
*/
|
|
47
|
+
progress?: ProgressEmitter;
|
|
38
48
|
};
|
|
39
49
|
|
|
40
50
|
const SOBJECT_TYPES: Partial<Record<GtmObjectType, string>> = {
|
|
@@ -56,6 +66,15 @@ const MAPPING_OBJECT_TYPES: Partial<Record<GtmObjectType, Exclude<CrmObjectType,
|
|
|
56
66
|
deal: "deals",
|
|
57
67
|
};
|
|
58
68
|
|
|
69
|
+
// SnapshotProgress object types → the shared SNAPSHOT_PULL_STAGES names, so
|
|
70
|
+
// the CLI checklist and the app StageTimeline read the same stage labels.
|
|
71
|
+
const PULL_STAGE_BY_TYPE: Record<SnapshotProgress["objectType"], (typeof SNAPSHOT_PULL_STAGES)[number]> = {
|
|
72
|
+
user: "owners",
|
|
73
|
+
account: "accounts",
|
|
74
|
+
contact: "contacts",
|
|
75
|
+
deal: "deals",
|
|
76
|
+
};
|
|
77
|
+
|
|
59
78
|
/**
|
|
60
79
|
* Reference connector for Salesforce.
|
|
61
80
|
*
|
|
@@ -161,7 +180,7 @@ export function createSalesforceConnector(
|
|
|
161
180
|
return text ? JSON.parse(text) : null;
|
|
162
181
|
}
|
|
163
182
|
|
|
164
|
-
async function query(soql: string): Promise<any[]> {
|
|
183
|
+
async function query(soql: string, onPage?: (fetched: number) => void): Promise<any[]> {
|
|
165
184
|
const records: any[] = [];
|
|
166
185
|
let next: string | undefined = `/services/data/${apiVersion}/query?q=${encodeURIComponent(soql)}`;
|
|
167
186
|
const seen = new Set<string>();
|
|
@@ -171,6 +190,11 @@ export function createSalesforceConnector(
|
|
|
171
190
|
seen.add(next);
|
|
172
191
|
const data = await request(next);
|
|
173
192
|
records.push(...(data?.records ?? []));
|
|
193
|
+
try {
|
|
194
|
+
onPage?.(records.length);
|
|
195
|
+
} catch {
|
|
196
|
+
// progress is presentation-only; never let it fail a pull
|
|
197
|
+
}
|
|
174
198
|
next = data?.nextRecordsUrl ?? undefined;
|
|
175
199
|
}
|
|
176
200
|
return records;
|
|
@@ -194,7 +218,25 @@ export function createSalesforceConnector(
|
|
|
194
218
|
}
|
|
195
219
|
|
|
196
220
|
async function assembleSnapshot(whereClause: string): Promise<CanonicalGtmSnapshot> {
|
|
197
|
-
|
|
221
|
+
// Per-page snapshot-pull progress: the legacy onProgress callback plus the
|
|
222
|
+
// shared emitter (stage on the first page of each object type, items per
|
|
223
|
+
// page). Both are presentation-only — `query` already swallows errors.
|
|
224
|
+
let lastPullStage: string | undefined;
|
|
225
|
+
const progressFor = (objectType: SnapshotProgress["objectType"]) => (fetched: number) => {
|
|
226
|
+
options.onProgress?.({ objectType, fetched });
|
|
227
|
+
const emitter = options.progress;
|
|
228
|
+
if (!emitter) return;
|
|
229
|
+
const stage = PULL_STAGE_BY_TYPE[objectType];
|
|
230
|
+
if (stage !== lastPullStage) {
|
|
231
|
+
lastPullStage = stage;
|
|
232
|
+
emitter.stage(stage, SNAPSHOT_PULL_STAGES.indexOf(stage), SNAPSHOT_PULL_STAGES.length);
|
|
233
|
+
}
|
|
234
|
+
emitter.items(fetched);
|
|
235
|
+
};
|
|
236
|
+
const sfUsers = await query(
|
|
237
|
+
`SELECT ${selectFields("owners")} FROM User${whereClause}`,
|
|
238
|
+
progressFor("user"),
|
|
239
|
+
);
|
|
198
240
|
const users: CanonicalUser[] = sfUsers.map((user) => {
|
|
199
241
|
const id = String(readMapped(user, "owners", "id", "Id"));
|
|
200
242
|
const email = stringOrUndefined(readMapped(user, "owners", "email", "Email"));
|
|
@@ -212,6 +254,7 @@ export function createSalesforceConnector(
|
|
|
212
254
|
|
|
213
255
|
const sfAccounts = await query(
|
|
214
256
|
`SELECT ${selectFields("accounts")} FROM Account${whereClause}`,
|
|
257
|
+
progressFor("account"),
|
|
215
258
|
);
|
|
216
259
|
const accounts: CanonicalAccount[] = sfAccounts.map((account) => {
|
|
217
260
|
const id = String(readMapped(account, "accounts", "id", "Id"));
|
|
@@ -236,6 +279,7 @@ export function createSalesforceConnector(
|
|
|
236
279
|
|
|
237
280
|
const sfContacts = await query(
|
|
238
281
|
`SELECT ${selectFields("contacts")} FROM Contact${whereClause}`,
|
|
282
|
+
progressFor("contact"),
|
|
239
283
|
);
|
|
240
284
|
const contacts: CanonicalContact[] = sfContacts.map((contact) => {
|
|
241
285
|
const id = String(readMapped(contact, "contacts", "id", "Id"));
|
|
@@ -257,6 +301,7 @@ export function createSalesforceConnector(
|
|
|
257
301
|
|
|
258
302
|
const sfOpportunities = await query(
|
|
259
303
|
`SELECT ${selectFields("deals")} FROM Opportunity${whereClause}`,
|
|
304
|
+
progressFor("deal"),
|
|
260
305
|
);
|
|
261
306
|
const deals: CanonicalDeal[] = sfOpportunities.map((opportunity) => {
|
|
262
307
|
const id = String(readMapped(opportunity, "deals", "id", "Id"));
|
|
@@ -306,6 +351,9 @@ export function createSalesforceConnector(
|
|
|
306
351
|
};
|
|
307
352
|
});
|
|
308
353
|
|
|
354
|
+
// Deliver any throttled trailing items heartbeat before the pull returns.
|
|
355
|
+
options.progress?.flush();
|
|
356
|
+
|
|
309
357
|
return {
|
|
310
358
|
generatedAt: new Date().toISOString(),
|
|
311
359
|
provider: "salesforce",
|
|
@@ -550,7 +598,7 @@ export function createSalesforceConnector(
|
|
|
550
598
|
return { operationId: operation.id, status: "skipped", detail: "create_record needs a CreateRecordPayload afterValue." };
|
|
551
599
|
}
|
|
552
600
|
if (operation.objectType !== "contact" && operation.objectType !== "account") {
|
|
553
|
-
return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts and accounts." };
|
|
601
|
+
return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts and accounts on Salesforce (deal/Opportunity creation is HubSpot-only for now)." };
|
|
554
602
|
}
|
|
555
603
|
const matchValue = String(payload.matchValue ?? "").trim();
|
|
556
604
|
if (!matchValue) {
|