fullstackgtm 0.45.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 +109 -1
- package/INSTALL_FOR_AGENTS.md +13 -11
- package/README.md +27 -18
- package/dist/backfill.d.ts +86 -0
- package/dist/backfill.js +251 -0
- package/dist/bin.js +4 -1
- package/dist/cli/auth.d.ts +2 -0
- package/dist/cli/auth.js +119 -12
- 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.js +23 -9
- package/dist/cli/enrich.js +28 -10
- package/dist/cli/fix.js +9 -7
- package/dist/cli/help.js +60 -23
- package/dist/cli/plans.js +13 -11
- package/dist/cli/shared.d.ts +4 -3
- package/dist/cli/shared.js +31 -20
- package/dist/cli/ui.d.ts +21 -0
- package/dist/cli/ui.js +53 -1
- package/dist/cli.js +37 -41
- package/dist/connector.d.ts +8 -0
- package/dist/connector.js +22 -10
- package/dist/connectors/hubspot.d.ts +7 -0
- package/dist/connectors/hubspot.js +236 -9
- package/dist/connectors/hubspotAuth.js +5 -1
- package/dist/connectors/linkedin.js +14 -14
- package/dist/connectors/salesforce.d.ts +7 -0
- package/dist/connectors/salesforce.js +28 -2
- 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/index.d.ts +3 -1
- package/dist/index.js +3 -1
- 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 +9 -1
- package/dist/marketClassify.js +10 -1
- package/dist/marketTaxonomy.d.ts +6 -1
- package/dist/marketTaxonomy.js +4 -2
- package/dist/mcp-bin.js +29 -0
- package/dist/mcp.js +117 -4
- 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/types.d.ts +18 -1
- package/docs/api.md +2 -1
- package/docs/architecture.md +2 -0
- package/docs/linkedin-connector-spec.md +1 -1
- package/llms.txt +3 -3
- package/package.json +10 -3
- package/skills/fullstackgtm/SKILL.md +1 -0
- package/src/backfill.ts +340 -0
- package/src/bin.ts +5 -1
- package/src/cli/auth.ts +135 -15
- package/src/cli/backfill.ts +156 -0
- package/src/cli/backfillRuns.ts +198 -0
- package/src/cli/call.ts +26 -10
- package/src/cli/enrich.ts +33 -10
- package/src/cli/fix.ts +11 -10
- package/src/cli/help.ts +61 -23
- package/src/cli/plans.ts +15 -14
- package/src/cli/shared.ts +44 -27
- package/src/cli/ui.ts +72 -1
- package/src/cli.ts +38 -41
- package/src/connector.ts +29 -9
- package/src/connectors/hubspot.ts +261 -9
- package/src/connectors/hubspotAuth.ts +5 -1
- package/src/connectors/linkedin.ts +14 -14
- package/src/connectors/salesforce.ts +34 -2
- 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/index.ts +28 -1
- package/src/llm.ts +239 -0
- package/src/market.ts +157 -44
- package/src/marketClassify.ts +18 -1
- package/src/marketTaxonomy.ts +10 -2
- package/src/mcp-bin.ts +32 -0
- package/src/mcp.ts +140 -6
- package/src/progress.ts +197 -0
- package/src/runReport.ts +159 -4
- package/src/types.ts +20 -2
- package/docs/dx-punch-list.md +0 -87
- package/docs/roadmap-to-1.0.md +0 -211
package/dist/cli.js
CHANGED
|
@@ -1,22 +1,14 @@
|
|
|
1
1
|
import { activeProfile, listProfiles, setActiveProfile } from "./credentials.js";
|
|
2
|
-
import { audit, healthCommand, reportCommand, rulesCommand, snapshotCommand } from "./cli/audit.js";
|
|
3
|
-
import { doctorCommand, login, logout } from "./cli/auth.js";
|
|
4
2
|
import { capabilitiesCommand, printCommandHelpJson, robotDocsCommand, unknownCommandEnvelope } from "./cli/capabilities.js";
|
|
5
|
-
import { callCommand } from "./cli/call.js";
|
|
6
|
-
import { draftCommand } from "./cli/draft.js";
|
|
7
|
-
import { enrichCommand } from "./cli/enrich.js";
|
|
8
|
-
import { bulkUpdateCommand, dedupeCommand, fixCommand, reassignCommand, resolveCommand, suggest } from "./cli/fix.js";
|
|
9
3
|
import { BESPOKE_HELP, commandHelp, HELP, shortUsage, stylizeShortUsage, usage } from "./cli/help.js";
|
|
10
|
-
import { icpCommand } from "./cli/icp.js";
|
|
11
|
-
import { initCommand } from "./cli/init.js";
|
|
12
|
-
import { marketCommand } from "./cli/market.js";
|
|
13
|
-
import { apply, auditLogCommand, diffCommand, mergeCommand, plansCommand } from "./cli/plans.js";
|
|
14
|
-
import { scheduleCommand } from "./cli/schedule.js";
|
|
15
4
|
import { readPackageInfo } from "./cli/shared.js";
|
|
16
5
|
import { correctedCommand, detectFlagTypo, suggestCommand, unknownFlagEnvelope } from "./cli/suggest.js";
|
|
17
6
|
import { colorEnabled, paint } from "./cli/ui.js";
|
|
18
|
-
|
|
19
|
-
import
|
|
7
|
+
// Verb modules load lazily inside their dispatch branch below. The dispatcher
|
|
8
|
+
// used to import all of them eagerly, so `--version` compiled the full
|
|
9
|
+
// 78-module graph (audit, market, enrich, signals, schedule, …) before
|
|
10
|
+
// printing one line. Each branch now `await import()`s only its own module;
|
|
11
|
+
// help/typo/error paths and the exports above stay eager and byte-identical.
|
|
20
12
|
/**
|
|
21
13
|
* Pull the global `--profile <name>` flag out of argv (it may appear before
|
|
22
14
|
* or after the command) and activate it. Stripping it keeps positional
|
|
@@ -126,95 +118,99 @@ export async function runCli(argv) {
|
|
|
126
118
|
return;
|
|
127
119
|
}
|
|
128
120
|
if (command === "login") {
|
|
129
|
-
await login(args);
|
|
121
|
+
await (await import("./cli/auth.js")).login(args);
|
|
130
122
|
return;
|
|
131
123
|
}
|
|
132
124
|
if (command === "logout") {
|
|
133
|
-
logout(args);
|
|
125
|
+
(await import("./cli/auth.js")).logout(args);
|
|
134
126
|
return;
|
|
135
127
|
}
|
|
136
128
|
if (command === "snapshot") {
|
|
137
|
-
await snapshotCommand(args);
|
|
129
|
+
await (await import("./cli/audit.js")).snapshotCommand(args);
|
|
138
130
|
return;
|
|
139
131
|
}
|
|
140
132
|
if (command === "audit") {
|
|
141
|
-
await audit(args);
|
|
133
|
+
await (await import("./cli/audit.js")).audit(args);
|
|
142
134
|
return;
|
|
143
135
|
}
|
|
144
136
|
if (command === "report") {
|
|
145
|
-
await reportCommand(args);
|
|
137
|
+
await (await import("./cli/audit.js")).reportCommand(args);
|
|
146
138
|
return;
|
|
147
139
|
}
|
|
148
140
|
if (command === "rules") {
|
|
149
|
-
await rulesCommand(args);
|
|
141
|
+
await (await import("./cli/audit.js")).rulesCommand(args);
|
|
150
142
|
return;
|
|
151
143
|
}
|
|
152
144
|
if (command === "doctor") {
|
|
153
|
-
await doctorCommand(args);
|
|
145
|
+
await (await import("./cli/auth.js")).doctorCommand(args);
|
|
154
146
|
return;
|
|
155
147
|
}
|
|
156
148
|
if (command === "health") {
|
|
157
|
-
healthCommand(args);
|
|
149
|
+
(await import("./cli/audit.js")).healthCommand(args);
|
|
158
150
|
return;
|
|
159
151
|
}
|
|
160
152
|
if (command === "suggest") {
|
|
161
|
-
await suggest(args);
|
|
153
|
+
await (await import("./cli/fix.js")).suggest(args);
|
|
162
154
|
return;
|
|
163
155
|
}
|
|
164
156
|
if (command === "call") {
|
|
165
|
-
await callCommand(args);
|
|
157
|
+
await (await import("./cli/call.js")).callCommand(args);
|
|
166
158
|
return;
|
|
167
159
|
}
|
|
168
160
|
if (command === "resolve") {
|
|
169
|
-
await resolveCommand(args);
|
|
161
|
+
await (await import("./cli/fix.js")).resolveCommand(args);
|
|
170
162
|
return;
|
|
171
163
|
}
|
|
172
164
|
if (command === "bulk-update") {
|
|
173
|
-
await bulkUpdateCommand(args);
|
|
165
|
+
await (await import("./cli/fix.js")).bulkUpdateCommand(args);
|
|
174
166
|
return;
|
|
175
167
|
}
|
|
176
168
|
if (command === "dedupe") {
|
|
177
|
-
await dedupeCommand(args);
|
|
169
|
+
await (await import("./cli/fix.js")).dedupeCommand(args);
|
|
178
170
|
return;
|
|
179
171
|
}
|
|
180
172
|
if (command === "reassign") {
|
|
181
|
-
await reassignCommand(args);
|
|
173
|
+
await (await import("./cli/fix.js")).reassignCommand(args);
|
|
182
174
|
return;
|
|
183
175
|
}
|
|
184
176
|
if (command === "fix") {
|
|
185
|
-
await fixCommand(args);
|
|
177
|
+
await (await import("./cli/fix.js")).fixCommand(args);
|
|
186
178
|
return;
|
|
187
179
|
}
|
|
188
180
|
if (command === "market") {
|
|
189
|
-
await marketCommand(args);
|
|
181
|
+
await (await import("./cli/market.js")).marketCommand(args);
|
|
190
182
|
return;
|
|
191
183
|
}
|
|
192
184
|
if (command === "enrich") {
|
|
193
|
-
await enrichCommand(args);
|
|
185
|
+
await (await import("./cli/enrich.js")).enrichCommand(args);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (command === "backfill") {
|
|
189
|
+
await (await import("./cli/backfill.js")).backfillCommand(args);
|
|
194
190
|
return;
|
|
195
191
|
}
|
|
196
192
|
if (command === "init") {
|
|
197
|
-
initCommand(args);
|
|
193
|
+
(await import("./cli/init.js")).initCommand(args);
|
|
198
194
|
return;
|
|
199
195
|
}
|
|
200
196
|
if (command === "tam") {
|
|
201
|
-
await tamCommand(args);
|
|
197
|
+
await (await import("./cli/tam.js")).tamCommand(args);
|
|
202
198
|
return;
|
|
203
199
|
}
|
|
204
200
|
if (command === "icp") {
|
|
205
|
-
await icpCommand(args);
|
|
201
|
+
await (await import("./cli/icp.js")).icpCommand(args);
|
|
206
202
|
return;
|
|
207
203
|
}
|
|
208
204
|
if (command === "signals") {
|
|
209
|
-
await signalsCommand(args);
|
|
205
|
+
await (await import("./cli/signals.js")).signalsCommand(args);
|
|
210
206
|
return;
|
|
211
207
|
}
|
|
212
208
|
if (command === "draft") {
|
|
213
|
-
await draftCommand(args);
|
|
209
|
+
await (await import("./cli/draft.js")).draftCommand(args);
|
|
214
210
|
return;
|
|
215
211
|
}
|
|
216
212
|
if (command === "schedule") {
|
|
217
|
-
await scheduleCommand(args);
|
|
213
|
+
await (await import("./cli/schedule.js")).scheduleCommand(args);
|
|
218
214
|
return;
|
|
219
215
|
}
|
|
220
216
|
if (command === "profiles") {
|
|
@@ -222,23 +218,23 @@ export async function runCli(argv) {
|
|
|
222
218
|
return;
|
|
223
219
|
}
|
|
224
220
|
if (command === "diff") {
|
|
225
|
-
await diffCommand(args);
|
|
221
|
+
await (await import("./cli/plans.js")).diffCommand(args);
|
|
226
222
|
return;
|
|
227
223
|
}
|
|
228
224
|
if (command === "merge") {
|
|
229
|
-
await mergeCommand(args);
|
|
225
|
+
await (await import("./cli/plans.js")).mergeCommand(args);
|
|
230
226
|
return;
|
|
231
227
|
}
|
|
232
228
|
if (command === "plans") {
|
|
233
|
-
await plansCommand(args);
|
|
229
|
+
await (await import("./cli/plans.js")).plansCommand(args);
|
|
234
230
|
return;
|
|
235
231
|
}
|
|
236
232
|
if (command === "audit-log") {
|
|
237
|
-
await auditLogCommand(args);
|
|
233
|
+
await (await import("./cli/plans.js")).auditLogCommand(args);
|
|
238
234
|
return;
|
|
239
235
|
}
|
|
240
236
|
if (command === "apply") {
|
|
241
|
-
await apply(args);
|
|
237
|
+
await (await import("./cli/plans.js")).apply(args);
|
|
242
238
|
return;
|
|
243
239
|
}
|
|
244
240
|
// Machine callers get a structured envelope with a did-you-mean hint
|
package/dist/connector.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ProgressEmitter } from "./progress.ts";
|
|
1
2
|
import type { GtmConnector, PatchPlan, PatchPlanRun } from "./types.ts";
|
|
2
3
|
export type ApplyPatchPlanOptions = {
|
|
3
4
|
/**
|
|
@@ -31,6 +32,13 @@ export type ApplyPatchPlanOptions = {
|
|
|
31
32
|
* full operation count.
|
|
32
33
|
*/
|
|
33
34
|
onOperation?: (progress: ApplyProgress) => void;
|
|
35
|
+
/**
|
|
36
|
+
* Shared progress vocabulary (src/progress.ts): the run emits the
|
|
37
|
+
* APPLY_STAGES (preflight → operations → results), one `opResult` per
|
|
38
|
+
* resolved operation (id + status only — no values), and `items` over the
|
|
39
|
+
* plan's operations. Additive alongside `onOperation`; presentation only.
|
|
40
|
+
*/
|
|
41
|
+
progress?: ProgressEmitter;
|
|
34
42
|
};
|
|
35
43
|
export type ApplyProgress = {
|
|
36
44
|
completed: number;
|
package/dist/connector.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { dedupeKey } from "./dedupe.js";
|
|
2
|
+
import { APPLY_STAGES } from "./progress.js";
|
|
2
3
|
import { requiresHumanInput } from "./rules.js";
|
|
3
4
|
const IRREVERSIBLE_OPERATIONS = new Set(["merge_records", "archive_record"]);
|
|
4
5
|
const IDENTITY_KEY_BY_TYPE = {
|
|
@@ -83,6 +84,8 @@ export async function applyPatchPlan(connector, plan, options) {
|
|
|
83
84
|
throw new Error(`The ${connector.provider} connector is read-only.`);
|
|
84
85
|
}
|
|
85
86
|
const startedAt = new Date().toISOString();
|
|
87
|
+
const emitStage = (stage) => options.progress?.stage(stage, APPLY_STAGES.indexOf(stage), APPLY_STAGES.length);
|
|
88
|
+
emitStage("preflight");
|
|
86
89
|
const approved = new Set(options.approvedOperationIds);
|
|
87
90
|
const checkConflicts = options.checkConflicts ?? typeof connector.readField === "function";
|
|
88
91
|
const results = [];
|
|
@@ -157,6 +160,7 @@ export async function applyPatchPlan(connector, plan, options) {
|
|
|
157
160
|
: "Operation was not approved.",
|
|
158
161
|
});
|
|
159
162
|
}
|
|
163
|
+
emitStage("results");
|
|
160
164
|
return {
|
|
161
165
|
planId: plan.id,
|
|
162
166
|
provider: connector.provider,
|
|
@@ -260,7 +264,7 @@ export async function applyPatchPlan(connector, plan, options) {
|
|
|
260
264
|
const resultsBefore = results.length;
|
|
261
265
|
let lastNotified = results.length;
|
|
262
266
|
const notifyProgress = () => {
|
|
263
|
-
if (!options.onOperation || results.length === lastNotified)
|
|
267
|
+
if ((!options.onOperation && !options.progress) || results.length === lastNotified)
|
|
264
268
|
return;
|
|
265
269
|
lastNotified = results.length;
|
|
266
270
|
const last = results[results.length - 1];
|
|
@@ -272,17 +276,24 @@ export async function applyPatchPlan(connector, plan, options) {
|
|
|
272
276
|
progressCounts.conflicts += 1;
|
|
273
277
|
else
|
|
274
278
|
progressCounts.skipped += 1;
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
279
|
+
// Shared vocabulary: operation id + status only — a conflict/failure
|
|
280
|
+
// detail can echo field values, which never leave the machine.
|
|
281
|
+
options.progress?.opResult(last.operationId, last.status);
|
|
282
|
+
options.progress?.items(results.length - resultsBefore, plan.operations.length);
|
|
283
|
+
if (options.onOperation) {
|
|
284
|
+
try {
|
|
285
|
+
options.onOperation({
|
|
286
|
+
completed: results.length - resultsBefore,
|
|
287
|
+
total: plan.operations.length,
|
|
288
|
+
...progressCounts,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
// progress is presentation-only
|
|
293
|
+
}
|
|
284
294
|
}
|
|
285
295
|
};
|
|
296
|
+
emitStage("operations");
|
|
286
297
|
for (const operation of plan.operations) {
|
|
287
298
|
// Report the previous iteration's result (guarded: no-op if it pushed
|
|
288
299
|
// nothing). One-iteration lag keeps this a two-line hook instead of a
|
|
@@ -377,6 +388,7 @@ export async function applyPatchPlan(connector, plan, options) {
|
|
|
377
388
|
}
|
|
378
389
|
}
|
|
379
390
|
notifyProgress();
|
|
391
|
+
emitStage("results");
|
|
380
392
|
return {
|
|
381
393
|
planId: plan.id,
|
|
382
394
|
provider: connector.provider,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type FieldMappings } from "../mappings.ts";
|
|
2
2
|
import type { GtmConnector, SnapshotProgress } from "../types.ts";
|
|
3
|
+
import { type ProgressEmitter } from "../progress.ts";
|
|
3
4
|
export type HubspotConnectorOptions = {
|
|
4
5
|
/** Returns a HubSpot access token (private app token or OAuth access token). */
|
|
5
6
|
getAccessToken: () => string | Promise<string>;
|
|
@@ -10,6 +11,12 @@ export type HubspotConnectorOptions = {
|
|
|
10
11
|
fetchImpl?: typeof fetch;
|
|
11
12
|
/** Per-page snapshot-pull progress (presentation only — errors are swallowed). */
|
|
12
13
|
onProgress?: (progress: SnapshotProgress) => void;
|
|
14
|
+
/**
|
|
15
|
+
* Shared progress vocabulary (src/progress.ts): the snapshot pull emits a
|
|
16
|
+
* `stage` per object type and an `items` heartbeat per page. Additive
|
|
17
|
+
* alongside the legacy `onProgress` callback; both are presentation-only.
|
|
18
|
+
*/
|
|
19
|
+
progress?: ProgressEmitter;
|
|
13
20
|
};
|
|
14
21
|
/**
|
|
15
22
|
* Reference connector for HubSpot.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { HUBSPOT_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, readMappedValue, } from "../mappings.js";
|
|
2
|
+
import { SNAPSHOT_PULL_STAGES } from "../progress.js";
|
|
2
3
|
const DEFAULT_API_BASE_URL = "https://api.hubapi.com";
|
|
3
4
|
const OBJECT_PATHS = {
|
|
4
5
|
account: "companies",
|
|
@@ -10,6 +11,14 @@ const MAPPING_OBJECT_TYPES = {
|
|
|
10
11
|
contact: "contacts",
|
|
11
12
|
deal: "deals",
|
|
12
13
|
};
|
|
14
|
+
// SnapshotProgress object types → the shared SNAPSHOT_PULL_STAGES names, so
|
|
15
|
+
// the CLI checklist and the app StageTimeline read the same stage labels.
|
|
16
|
+
const PULL_STAGE_BY_TYPE = {
|
|
17
|
+
user: "owners",
|
|
18
|
+
account: "accounts",
|
|
19
|
+
contact: "contacts",
|
|
20
|
+
deal: "deals",
|
|
21
|
+
};
|
|
13
22
|
/**
|
|
14
23
|
* Reference connector for HubSpot.
|
|
15
24
|
*
|
|
@@ -30,6 +39,32 @@ export function createHubspotConnector(options) {
|
|
|
30
39
|
// match value (email): HubSpot search is eventually consistent, so a contact
|
|
31
40
|
// created earlier in this apply run is invisible to a later search.
|
|
32
41
|
const createdContactsByMatch = new Map();
|
|
42
|
+
// Same-run dedup for `create_record` deal creates, keyed by
|
|
43
|
+
// matchKey:matchValue (e.g. stripe_invoice_id:in_123) — same
|
|
44
|
+
// eventual-consistency rationale as the contact cache.
|
|
45
|
+
const createdDealsByMatch = new Map();
|
|
46
|
+
// One /crm/v3/pipelines/deals read per connector lifetime (one apply run):
|
|
47
|
+
// pipeline/stage metadata doesn't change mid-run, and deal creates resolve
|
|
48
|
+
// the closed-won stage from it per operation.
|
|
49
|
+
let dealPipelinesCache;
|
|
50
|
+
// Custom deal dedupe properties ensured (or confirmed missing) this run.
|
|
51
|
+
const ensuredDealProperties = new Map();
|
|
52
|
+
// Per-page snapshot-pull progress: the legacy onProgress callback plus the
|
|
53
|
+
// shared emitter (stage on the first page of each object type, items per
|
|
54
|
+
// page). Both are presentation-only — callers already swallow errors.
|
|
55
|
+
let lastPullStage;
|
|
56
|
+
const pullProgress = (objectType, fetched) => {
|
|
57
|
+
options.onProgress?.({ objectType, fetched });
|
|
58
|
+
const emitter = options.progress;
|
|
59
|
+
if (!emitter)
|
|
60
|
+
return;
|
|
61
|
+
const stage = PULL_STAGE_BY_TYPE[objectType];
|
|
62
|
+
if (stage !== lastPullStage) {
|
|
63
|
+
lastPullStage = stage;
|
|
64
|
+
emitter.stage(stage, SNAPSHOT_PULL_STAGES.indexOf(stage), SNAPSHOT_PULL_STAGES.length);
|
|
65
|
+
}
|
|
66
|
+
emitter.items(fetched);
|
|
67
|
+
};
|
|
33
68
|
async function request(path, init = {}) {
|
|
34
69
|
const token = await options.getAccessToken();
|
|
35
70
|
let response;
|
|
@@ -115,7 +150,7 @@ export function createHubspotConnector(options) {
|
|
|
115
150
|
return results;
|
|
116
151
|
}
|
|
117
152
|
async function assembleSnapshot(fetchObjects) {
|
|
118
|
-
const owners = await list("/crm/v3/owners?limit=100", (fetched) =>
|
|
153
|
+
const owners = await list("/crm/v3/owners?limit=100", (fetched) => pullProgress("user", fetched));
|
|
119
154
|
const users = owners
|
|
120
155
|
.filter((owner) => owner.id)
|
|
121
156
|
.map((owner) => ({
|
|
@@ -228,6 +263,8 @@ export function createHubspotConnector(options) {
|
|
|
228
263
|
raw: deal,
|
|
229
264
|
};
|
|
230
265
|
});
|
|
266
|
+
// Deliver any throttled trailing items heartbeat before the pull returns.
|
|
267
|
+
options.progress?.flush();
|
|
231
268
|
return {
|
|
232
269
|
generatedAt: new Date().toISOString(),
|
|
233
270
|
provider: "hubspot",
|
|
@@ -242,7 +279,7 @@ export function createHubspotConnector(options) {
|
|
|
242
279
|
}
|
|
243
280
|
async function fetchSnapshot() {
|
|
244
281
|
const canonicalType = { companies: "account", contacts: "contact", deals: "deal" };
|
|
245
|
-
return assembleSnapshot((objectType, properties, withAssociations) => list(`/crm/v3/objects/${objectType}?limit=100&properties=${properties}${withAssociations ? "&associations=companies" : ""}`, (fetched) =>
|
|
282
|
+
return assembleSnapshot((objectType, properties, withAssociations) => list(`/crm/v3/objects/${objectType}?limit=100&properties=${properties}${withAssociations ? "&associations=companies" : ""}`, (fetched) => pullProgress(canonicalType[objectType], fetched)));
|
|
246
283
|
}
|
|
247
284
|
const MODIFIED_DATE_PROPERTIES = {
|
|
248
285
|
companies: "hs_lastmodifieddate",
|
|
@@ -506,12 +543,199 @@ export function createHubspotConnector(options) {
|
|
|
506
543
|
createdCompaniesByName.set(cacheKey, id);
|
|
507
544
|
return id;
|
|
508
545
|
}
|
|
546
|
+
/** Exact-value deal lookup (by any searchable property) for resolve-first creates. */
|
|
547
|
+
async function searchDealsBy(property, value) {
|
|
548
|
+
const data = await request(`/crm/v3/objects/deals/search`, {
|
|
549
|
+
method: "POST",
|
|
550
|
+
body: JSON.stringify({
|
|
551
|
+
filterGroups: [{ filters: [{ propertyName: property, operator: "EQ", value }] }],
|
|
552
|
+
properties: [property],
|
|
553
|
+
limit: 3,
|
|
554
|
+
}),
|
|
555
|
+
});
|
|
556
|
+
return (data?.results ?? []).map((row) => String(row.id));
|
|
557
|
+
}
|
|
509
558
|
/**
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
559
|
+
* Make sure the deal dedupe property (e.g. "stripe_invoice_id") exists so
|
|
560
|
+
* both the resolve-first search and the stamped value are real. Best-effort
|
|
561
|
+
* create on a confirmed miss; returns false when the property can neither
|
|
562
|
+
* be read nor created — the caller must SKIP then (creating a deal without
|
|
563
|
+
* its dedupe key would make every replay a duplicate).
|
|
564
|
+
*/
|
|
565
|
+
async function ensureDealProperty(name) {
|
|
566
|
+
const cached = ensuredDealProperties.get(name);
|
|
567
|
+
if (cached !== undefined)
|
|
568
|
+
return cached;
|
|
569
|
+
let ok;
|
|
570
|
+
try {
|
|
571
|
+
await request(`/crm/v3/properties/deals/${encodeURIComponent(name)}`);
|
|
572
|
+
ok = true;
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
// Missing (404) or unreadable — try to create it; a failure here means
|
|
576
|
+
// we cannot guarantee the dedupe key, so report false.
|
|
577
|
+
try {
|
|
578
|
+
await request(`/crm/v3/properties/deals`, {
|
|
579
|
+
method: "POST",
|
|
580
|
+
body: JSON.stringify({
|
|
581
|
+
name,
|
|
582
|
+
label: name,
|
|
583
|
+
type: "string",
|
|
584
|
+
fieldType: "text",
|
|
585
|
+
groupName: "dealinformation",
|
|
586
|
+
hasUniqueValue: false,
|
|
587
|
+
}),
|
|
588
|
+
});
|
|
589
|
+
ok = true;
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
ok = false;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
ensuredDealProperties.set(name, ok);
|
|
596
|
+
return ok;
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Resolve the target pipeline and its closed-won stage from the portal's
|
|
600
|
+
* own pipeline metadata (one read per run). The pipeline is picked by id or
|
|
601
|
+
* case-insensitive label (`hint`), else the default (lowest displayOrder);
|
|
602
|
+
* the closed-won stage is the one whose metadata says isClosed with
|
|
603
|
+
* probability 1 — NEVER a substring guess against stage names. Returns null
|
|
604
|
+
* when either is unresolvable so the caller skips instead of guessing.
|
|
605
|
+
*/
|
|
606
|
+
async function resolveClosedWonStage(hint) {
|
|
607
|
+
if (!dealPipelinesCache) {
|
|
608
|
+
dealPipelinesCache = request("/crm/v3/pipelines/deals").then((data) => (data?.results ?? []));
|
|
609
|
+
// A failed read must not poison the cache for the rest of the run.
|
|
610
|
+
dealPipelinesCache = dealPipelinesCache.catch(() => []);
|
|
611
|
+
}
|
|
612
|
+
const pipelines = await dealPipelinesCache;
|
|
613
|
+
if (pipelines.length === 0)
|
|
614
|
+
return null;
|
|
615
|
+
let pipeline;
|
|
616
|
+
if (hint) {
|
|
617
|
+
const wanted = hint.trim().toLowerCase();
|
|
618
|
+
pipeline = pipelines.find((candidate) => String(candidate?.id ?? "").toLowerCase() === wanted ||
|
|
619
|
+
String(candidate?.label ?? "").trim().toLowerCase() === wanted);
|
|
620
|
+
if (!pipeline)
|
|
621
|
+
return null; // an explicit hint that matches nothing is an error, not "use default"
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
pipeline = [...pipelines].sort((a, b) => Number(a?.displayOrder ?? 0) - Number(b?.displayOrder ?? 0))[0];
|
|
625
|
+
}
|
|
626
|
+
if (!pipeline?.id)
|
|
627
|
+
return null;
|
|
628
|
+
const stage = (pipeline.stages ?? []).find((candidate) => {
|
|
629
|
+
const meta = candidate?.metadata ?? {};
|
|
630
|
+
return (candidate?.id &&
|
|
631
|
+
String(meta.isClosed).toLowerCase() === "true" &&
|
|
632
|
+
Number(meta.probability) === 1);
|
|
633
|
+
});
|
|
634
|
+
if (!stage)
|
|
635
|
+
return null;
|
|
636
|
+
return { pipelineId: String(pipeline.id), stageId: String(stage.id) };
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Deal branch of create_record (backfill: one closed-won deal per paid
|
|
640
|
+
* invoice). Resolve-first on the custom dedupe property (ensured to exist
|
|
641
|
+
* first), stage resolved from pipeline metadata, company association
|
|
642
|
+
* best-effort — same contract as the contact branch.
|
|
643
|
+
*/
|
|
644
|
+
async function createDealRecord(operation, payload, matchValue) {
|
|
645
|
+
const matchKey = String(payload.matchKey ?? "").trim();
|
|
646
|
+
if (!matchKey) {
|
|
647
|
+
return { operationId: operation.id, status: "skipped", detail: "create_record for deals needs a matchKey (the dedupe property, e.g. stripe_invoice_id)." };
|
|
648
|
+
}
|
|
649
|
+
const dedupeKey = `${matchKey}:${matchValue.toLowerCase()}`;
|
|
650
|
+
const alreadyCreated = createdDealsByMatch.get(dedupeKey);
|
|
651
|
+
if (alreadyCreated) {
|
|
652
|
+
return { operationId: operation.id, status: "skipped", detail: `Deal ${matchKey}=${matchValue} already created earlier in this run; not duplicating.`, providerData: { id: alreadyCreated, existing: true } };
|
|
653
|
+
}
|
|
654
|
+
// The dedupe property is usually CUSTOM — ensure it exists before the
|
|
655
|
+
// search; searching a property HubSpot doesn't have 400s, and creating a
|
|
656
|
+
// deal without the key stamped would defeat every future resolve-first.
|
|
657
|
+
const propertyOk = await ensureDealProperty(matchKey);
|
|
658
|
+
if (!propertyOk) {
|
|
659
|
+
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.` };
|
|
660
|
+
}
|
|
661
|
+
const existing = await searchDealsBy(matchKey, matchValue);
|
|
662
|
+
if (existing.length > 0) {
|
|
663
|
+
createdDealsByMatch.set(dedupeKey, existing[0]);
|
|
664
|
+
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 } };
|
|
665
|
+
}
|
|
666
|
+
// Stage: only the provider-neutral "closed_won" sentinel is understood,
|
|
667
|
+
// and it must resolve to a real stage id from pipeline metadata.
|
|
668
|
+
if (payload.dealStage !== "closed_won") {
|
|
669
|
+
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).' };
|
|
670
|
+
}
|
|
671
|
+
const resolved = await resolveClosedWonStage(payload.dealPipeline);
|
|
672
|
+
if (!resolved) {
|
|
673
|
+
return {
|
|
674
|
+
operationId: operation.id,
|
|
675
|
+
status: "skipped",
|
|
676
|
+
detail: payload.dealPipeline
|
|
677
|
+
? `Could not resolve pipeline "${payload.dealPipeline}" (by id or label) to a closed-won stage; not guessing a stage.`
|
|
678
|
+
: "Could not resolve the default deal pipeline's closed-won stage (isClosed + probability 1) from pipeline metadata; not guessing a stage.",
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
const properties = {
|
|
682
|
+
...payload.properties,
|
|
683
|
+
[matchKey]: matchValue,
|
|
684
|
+
pipeline: resolved.pipelineId,
|
|
685
|
+
dealstage: resolved.stageId,
|
|
686
|
+
};
|
|
687
|
+
if (payload.ownerId && !properties.hubspot_owner_id) {
|
|
688
|
+
properties.hubspot_owner_id = String(payload.ownerId);
|
|
689
|
+
}
|
|
690
|
+
let created;
|
|
691
|
+
try {
|
|
692
|
+
created = await request(`/crm/v3/objects/deals`, {
|
|
693
|
+
method: "POST",
|
|
694
|
+
body: JSON.stringify({ properties: { ...properties, hs_object_source_detail_2: `fullstackgtm backfill (${operation.id})` } }),
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
catch {
|
|
698
|
+
// Some portals reject writes to source-detail properties — the provenance
|
|
699
|
+
// stamp is best-effort, the create is not.
|
|
700
|
+
created = await request(`/crm/v3/objects/deals`, {
|
|
701
|
+
method: "POST",
|
|
702
|
+
body: JSON.stringify({ properties }),
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
const dealId = String(created.id);
|
|
706
|
+
createdDealsByMatch.set(dedupeKey, dealId);
|
|
707
|
+
let companyNote = "";
|
|
708
|
+
if (payload.associateCompanyName) {
|
|
709
|
+
try {
|
|
710
|
+
const companyId = await resolveOrCreateCompany(payload.associateCompanyName, payload.associateCompanyDomain, operation.id);
|
|
711
|
+
if (companyId) {
|
|
712
|
+
await request(`/crm/v4/objects/deals/${encodeURIComponent(dealId)}/associations/default/companies/${encodeURIComponent(companyId)}`, { method: "PUT" });
|
|
713
|
+
const domainNote = payload.associateCompanyDomain ? ` (${payload.associateCompanyDomain})` : "";
|
|
714
|
+
companyNote = ` Linked to company "${payload.associateCompanyName}"${domainNote} (${companyId}).`;
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
companyNote = ` Company "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
catch (error) {
|
|
721
|
+
// Association is best-effort — the deal create already succeeded.
|
|
722
|
+
companyNote = ` (company link failed: ${error instanceof Error ? error.message : String(error)})`;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return {
|
|
726
|
+
operationId: operation.id,
|
|
727
|
+
status: "applied",
|
|
728
|
+
detail: `Created deal ${matchKey}=${matchValue} (${dealId}) in stage ${resolved.stageId}.${companyNote}`,
|
|
729
|
+
providerData: { id: dealId, created: true, pipelineId: resolved.pipelineId, stageId: resolved.stageId },
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Create a NET-NEW record (a sourced lead, or a backfilled deal).
|
|
734
|
+
* Resolve-first: re-checks the dedupe key against the live CRM (the
|
|
735
|
+
* plan-time snapshot can be stale) and creates ONLY on a confirmed miss — a
|
|
736
|
+
* record a concurrent writer already added is returned as `skipped`, never
|
|
737
|
+
* duplicated. Contacts and deals can be linked to a resolved-or-created
|
|
738
|
+
* company in the same step.
|
|
515
739
|
*/
|
|
516
740
|
async function createRecord(operation) {
|
|
517
741
|
const payload = operation.afterValue;
|
|
@@ -519,13 +743,16 @@ export function createHubspotConnector(options) {
|
|
|
519
743
|
return { operationId: operation.id, status: "skipped", detail: "create_record needs a CreateRecordPayload afterValue." };
|
|
520
744
|
}
|
|
521
745
|
const objectPath = OBJECT_PATHS[operation.objectType];
|
|
522
|
-
if (operation.objectType !== "contact" && operation.objectType !== "account") {
|
|
523
|
-
return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts and
|
|
746
|
+
if (operation.objectType !== "contact" && operation.objectType !== "account" && operation.objectType !== "deal") {
|
|
747
|
+
return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts, accounts, and deals." };
|
|
524
748
|
}
|
|
525
749
|
const matchValue = String(payload.matchValue ?? "").trim();
|
|
526
750
|
if (!matchValue) {
|
|
527
751
|
return { operationId: operation.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." };
|
|
528
752
|
}
|
|
753
|
+
if (operation.objectType === "deal") {
|
|
754
|
+
return createDealRecord(operation, payload, matchValue);
|
|
755
|
+
}
|
|
529
756
|
if (operation.objectType === "account") {
|
|
530
757
|
const id = await resolveOrCreateCompany(matchValue, stringOrUndefined(payload.properties?.domain), operation.id);
|
|
531
758
|
if (id === null) {
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createServer } from "node:http";
|
|
2
1
|
import { randomBytes } from "node:crypto";
|
|
3
2
|
/**
|
|
4
3
|
* HubSpot CLI authentication.
|
|
@@ -101,6 +100,11 @@ export async function runHubspotLoopbackLogin(options) {
|
|
|
101
100
|
const log = options.log ?? ((message) => console.error(message));
|
|
102
101
|
const redirectUri = `http://localhost:${port}/callback`;
|
|
103
102
|
const state = randomBytes(16).toString("hex");
|
|
103
|
+
// Deferred: `node:http` transitively compiles node's bundled undici on
|
|
104
|
+
// import (~10ms+, 12% of a demo-audit run's CPU profile). This module is in
|
|
105
|
+
// the eager graph of every CLI invocation via help.ts/auth.ts, but the
|
|
106
|
+
// server is only needed during the interactive OAuth loopback login.
|
|
107
|
+
const { createServer } = await import("node:http");
|
|
104
108
|
const code = await new Promise((resolve, reject) => {
|
|
105
109
|
const server = createServer((request, response) => {
|
|
106
110
|
const url = new URL(request.url ?? "/", `http://localhost:${port}`);
|