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.
Files changed (95) hide show
  1. package/CHANGELOG.md +109 -1
  2. package/INSTALL_FOR_AGENTS.md +13 -11
  3. package/README.md +27 -18
  4. package/dist/backfill.d.ts +86 -0
  5. package/dist/backfill.js +251 -0
  6. package/dist/bin.js +4 -1
  7. package/dist/cli/auth.d.ts +2 -0
  8. package/dist/cli/auth.js +119 -12
  9. package/dist/cli/backfill.d.ts +1 -0
  10. package/dist/cli/backfill.js +125 -0
  11. package/dist/cli/backfillRuns.d.ts +1 -0
  12. package/dist/cli/backfillRuns.js +187 -0
  13. package/dist/cli/call.js +23 -9
  14. package/dist/cli/enrich.js +28 -10
  15. package/dist/cli/fix.js +9 -7
  16. package/dist/cli/help.js +60 -23
  17. package/dist/cli/plans.js +13 -11
  18. package/dist/cli/shared.d.ts +4 -3
  19. package/dist/cli/shared.js +31 -20
  20. package/dist/cli/ui.d.ts +21 -0
  21. package/dist/cli/ui.js +53 -1
  22. package/dist/cli.js +37 -41
  23. package/dist/connector.d.ts +8 -0
  24. package/dist/connector.js +22 -10
  25. package/dist/connectors/hubspot.d.ts +7 -0
  26. package/dist/connectors/hubspot.js +236 -9
  27. package/dist/connectors/hubspotAuth.js +5 -1
  28. package/dist/connectors/linkedin.js +14 -14
  29. package/dist/connectors/salesforce.d.ts +7 -0
  30. package/dist/connectors/salesforce.js +28 -2
  31. package/dist/connectors/stripe.d.ts +37 -0
  32. package/dist/connectors/stripe.js +103 -31
  33. package/dist/enrich.d.ts +7 -0
  34. package/dist/enrich.js +7 -0
  35. package/dist/health.d.ts +11 -69
  36. package/dist/health.js +4 -134
  37. package/dist/healthScore.d.ts +71 -0
  38. package/dist/healthScore.js +143 -0
  39. package/dist/index.d.ts +3 -1
  40. package/dist/index.js +3 -1
  41. package/dist/llm.d.ts +29 -0
  42. package/dist/llm.js +206 -0
  43. package/dist/market.d.ts +39 -1
  44. package/dist/market.js +116 -44
  45. package/dist/marketClassify.d.ts +9 -1
  46. package/dist/marketClassify.js +10 -1
  47. package/dist/marketTaxonomy.d.ts +6 -1
  48. package/dist/marketTaxonomy.js +4 -2
  49. package/dist/mcp-bin.js +29 -0
  50. package/dist/mcp.js +117 -4
  51. package/dist/progress.d.ts +96 -0
  52. package/dist/progress.js +142 -0
  53. package/dist/runReport.d.ts +24 -0
  54. package/dist/runReport.js +139 -4
  55. package/dist/types.d.ts +18 -1
  56. package/docs/api.md +2 -1
  57. package/docs/architecture.md +2 -0
  58. package/docs/linkedin-connector-spec.md +1 -1
  59. package/llms.txt +3 -3
  60. package/package.json +10 -3
  61. package/skills/fullstackgtm/SKILL.md +1 -0
  62. package/src/backfill.ts +340 -0
  63. package/src/bin.ts +5 -1
  64. package/src/cli/auth.ts +135 -15
  65. package/src/cli/backfill.ts +156 -0
  66. package/src/cli/backfillRuns.ts +198 -0
  67. package/src/cli/call.ts +26 -10
  68. package/src/cli/enrich.ts +33 -10
  69. package/src/cli/fix.ts +11 -10
  70. package/src/cli/help.ts +61 -23
  71. package/src/cli/plans.ts +15 -14
  72. package/src/cli/shared.ts +44 -27
  73. package/src/cli/ui.ts +72 -1
  74. package/src/cli.ts +38 -41
  75. package/src/connector.ts +29 -9
  76. package/src/connectors/hubspot.ts +261 -9
  77. package/src/connectors/hubspotAuth.ts +5 -1
  78. package/src/connectors/linkedin.ts +14 -14
  79. package/src/connectors/salesforce.ts +34 -2
  80. package/src/connectors/stripe.ts +154 -34
  81. package/src/enrich.ts +13 -0
  82. package/src/health.ts +14 -213
  83. package/src/healthScore.ts +223 -0
  84. package/src/index.ts +28 -1
  85. package/src/llm.ts +239 -0
  86. package/src/market.ts +157 -44
  87. package/src/marketClassify.ts +18 -1
  88. package/src/marketTaxonomy.ts +10 -2
  89. package/src/mcp-bin.ts +32 -0
  90. package/src/mcp.ts +140 -6
  91. package/src/progress.ts +197 -0
  92. package/src/runReport.ts +159 -4
  93. package/src/types.ts +20 -2
  94. package/docs/dx-punch-list.md +0 -87
  95. 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
- import { signalsCommand } from "./cli/signals.js";
19
- import { tamCommand } from "./cli/tam.js";
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
@@ -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
- try {
276
- options.onOperation({
277
- completed: results.length - resultsBefore,
278
- total: plan.operations.length,
279
- ...progressCounts,
280
- });
281
- }
282
- catch {
283
- // progress is presentation-only
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) => options.onProgress?.({ objectType: "user", 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) => options.onProgress?.({ objectType: canonicalType[objectType], 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
- * Create a NET-NEW record (a sourced lead). Resolve-first: re-checks the
511
- * dedupe key against the live CRM (the plan-time snapshot can be stale) and
512
- * creates ONLY on a confirmed miss a record a concurrent writer already
513
- * added is returned as `skipped`, never duplicated. Contacts can be linked
514
- * to a resolved-or-created company in the same step.
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 accounts." };
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}`);