fullstackgtm 0.50.0 → 0.51.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 (66) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +7 -0
  3. package/dist/cli/audit.js +6 -1
  4. package/dist/cli/auth.js +49 -4
  5. package/dist/cli/backfill.js +4 -0
  6. package/dist/cli/call.js +28 -6
  7. package/dist/cli/draft.js +11 -1
  8. package/dist/cli/enrich.js +124 -57
  9. package/dist/cli/fix.js +6 -3
  10. package/dist/cli/help.js +31 -28
  11. package/dist/cli/icp.js +15 -1
  12. package/dist/cli/init.js +3 -3
  13. package/dist/cli/market.js +14 -1
  14. package/dist/cli/planOutput.d.ts +5 -0
  15. package/dist/cli/planOutput.js +27 -0
  16. package/dist/cli/plans.js +184 -33
  17. package/dist/cli/schedule.js +22 -11
  18. package/dist/cli/signals.js +22 -3
  19. package/dist/cli/tam.d.ts +1 -1
  20. package/dist/cli/tam.js +14 -2
  21. package/dist/cli/ui.js +14 -6
  22. package/dist/connectors/clay.d.ts +33 -0
  23. package/dist/connectors/clay.js +123 -0
  24. package/dist/connectors/hubspot.d.ts +4 -0
  25. package/dist/connectors/hubspot.js +36 -18
  26. package/dist/connectors/prospectSources.d.ts +12 -0
  27. package/dist/connectors/prospectSources.js +1 -1
  28. package/dist/contactProviders.d.ts +44 -0
  29. package/dist/contactProviders.js +100 -0
  30. package/dist/enrich.d.ts +7 -1
  31. package/dist/enrich.js +46 -1
  32. package/dist/hostedPatchPlan.js +6 -1
  33. package/dist/icp.d.ts +2 -0
  34. package/dist/icp.js +49 -0
  35. package/dist/index.d.ts +2 -0
  36. package/dist/index.js +2 -0
  37. package/dist/init.d.ts +1 -1
  38. package/dist/init.js +1 -1
  39. package/docs/api.md +18 -1
  40. package/package.json +1 -1
  41. package/src/cli/audit.ts +5 -1
  42. package/src/cli/auth.ts +46 -4
  43. package/src/cli/backfill.ts +3 -0
  44. package/src/cli/call.ts +25 -6
  45. package/src/cli/draft.ts +9 -1
  46. package/src/cli/enrich.ts +132 -56
  47. package/src/cli/fix.ts +5 -3
  48. package/src/cli/help.ts +31 -28
  49. package/src/cli/icp.ts +13 -1
  50. package/src/cli/init.ts +3 -3
  51. package/src/cli/market.ts +12 -1
  52. package/src/cli/planOutput.ts +30 -0
  53. package/src/cli/plans.ts +174 -29
  54. package/src/cli/schedule.ts +21 -15
  55. package/src/cli/signals.ts +22 -3
  56. package/src/cli/tam.ts +12 -3
  57. package/src/cli/ui.ts +15 -6
  58. package/src/connectors/clay.ts +155 -0
  59. package/src/connectors/hubspot.ts +41 -17
  60. package/src/connectors/prospectSources.ts +7 -1
  61. package/src/contactProviders.ts +141 -0
  62. package/src/enrich.ts +51 -2
  63. package/src/hostedPatchPlan.ts +6 -1
  64. package/src/icp.ts +51 -0
  65. package/src/index.ts +25 -0
  66. package/src/init.ts +2 -2
@@ -8,6 +8,7 @@ import { computeMissedFirings, createFileScheduleRunStore, createFileScheduleSto
8
8
  import { runCli } from "../cli.js";
9
9
  import { isOptionValue, numericOption, option } from "./shared.js";
10
10
  import { unknownSubcommandError } from "./suggest.js";
11
+ import { colorEnabled, paint, table } from "./ui.js";
11
12
  /**
12
13
  * The schedule layer: declarative cadences for read/plan-side commands,
13
14
  * materialized through a provider (MVP: the user crontab), with an
@@ -113,10 +114,19 @@ trigger: manual. status shows next firing and surfaces missed firings
113
114
  console.log('No schedules. Add one with `fullstackgtm schedule add "<command>" --cron "<expr>"`.');
114
115
  return;
115
116
  }
116
- for (const entry of withNext) {
117
- console.log(`${entry.id} ${entry.enabled ? "on " : "off"} ${entry.cron.padEnd(14)} next ${entry.nextFiring ?? ""} ${entry.label}: ${entry.argv.join(" ")}`);
118
- }
119
- console.log("\nEntries are declarative `schedule install` materializes enabled ones into the crontab.");
117
+ const p = paint(colorEnabled(process.stdout));
118
+ const verbose = rest.includes("--verbose");
119
+ const rows = [
120
+ ["STATE", "SCHEDULE", "NEXT", ...(verbose ? ["CRON", "COMMAND"] : [])],
121
+ ...withNext.map((entry) => [
122
+ entry.enabled ? "on" : "off",
123
+ `${entry.label} · ${entry.id}`,
124
+ entry.nextFiring ?? "—",
125
+ ...(verbose ? [entry.cron, entry.argv.join(" ")] : []),
126
+ ]),
127
+ ];
128
+ console.log(table(rows, [p.dim, null, null]).join("\n"));
129
+ console.log("\nDeclarative only · run `fullstackgtm schedule install` after changes.");
120
130
  return;
121
131
  }
122
132
  if (subcommand === "remove") {
@@ -251,20 +261,21 @@ trigger: manual. status shows next firing and surfaces missed firings
251
261
  const last = entry.lastRun;
252
262
  const streak = entry.streak;
253
263
  const missed = entry.missedFirings;
254
- console.log(`${entry.id} ${entry.enabled ? "on " : "off"} ${entry.cron} ${entry.label}: ${entry.argv.join(" ")}`);
255
- console.log(` next: ${entry.nextFiring ?? " (disabled)"}`);
264
+ const verbose = rest.includes("--verbose");
265
+ const outcome = !last ? "never run" : last.exitCode === 0 ? (last.noopReason ? `no-op · ${last.noopReason}` : "healthy") : `failed · exit ${last.exitCode}`;
266
+ console.log(`${entry.enabled ? "●" : "○"} ${entry.label} · ${outcome}`);
267
+ console.log(` ${entry.id} · next ${entry.nextFiring ?? "— (disabled)"}`);
268
+ if (verbose)
269
+ console.log(` ${entry.argv.join(" ")} · cron ${entry.cron}`);
256
270
  if (last) {
257
271
  const artifacts = [
258
272
  ...last.artifacts.planIds.map((planId) => `plan ${planId}`),
259
273
  ...last.artifacts.runLabels.map((runLabel) => `run ${runLabel}`),
260
274
  ];
261
- console.log(` last: ${last.firedAt} (${last.trigger}) exit ${last.exitCode}` +
262
- (last.noopReason ? ` — no-op: ${last.noopReason}` : "") +
263
- (artifacts.length ? ` — ${artifacts.join(", ")}` : ""));
264
- console.log(` streak: ${streak.length} ${streak.outcome}(s)`);
275
+ console.log(` last ${last.firedAt} · ${last.trigger} · ${streak.length} ${streak.outcome}${streak.length === 1 ? "" : "s"}${artifacts.length ? ` · ${artifacts.join(", ")}` : ""}`);
265
276
  }
266
277
  else {
267
- console.log(" last: never fired");
278
+ console.log(" last never fired");
268
279
  }
269
280
  if (missed.length > 0) {
270
281
  console.log(` missed: ${missed.length}${entry.missedFiringsCapped ? "+" : ""} expected firing(s) with no run record ` +
@@ -22,6 +22,20 @@ function resolveSignalsConfig(args) {
22
22
  return loadSignalsConfig(local);
23
23
  return DEFAULT_SIGNALS_CONFIG;
24
24
  }
25
+ function concise(value, max = 72) {
26
+ const oneLine = value.replace(/\s+/g, " ").trim();
27
+ return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max - 1)}…`;
28
+ }
29
+ function renderSignals(signals) {
30
+ if (signals.length === 0)
31
+ return "No signals matched.";
32
+ const lines = [`Fresh signals (${signals.length})`, ""];
33
+ for (const signal of signals) {
34
+ lines.push(`${signal.weight.toFixed(2).padStart(5)} ${signal.accountDomain} · ${signal.bucket}`);
35
+ lines.push(` ${concise(signal.trigger)}`);
36
+ }
37
+ return lines.join("\n");
38
+ }
25
39
  /**
26
40
  * Resolve the watchlist of accounts to scan. Sources, in precedence order:
27
41
  * - a --watchlist file (JSON array of {domain, boards?} or bare domain strings),
@@ -211,7 +225,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
211
225
  const { fresh, deduped } = dedupeSignals(candidates, priorSignals, config.dedupWindowDays, now);
212
226
  const ranked = [...fresh].sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
213
227
  // Ranked fresh signals to stdout; guidance to stderr.
214
- console.log(JSON.stringify(ranked, null, 2));
228
+ console.log(rest.includes("--json") || rest.includes("--verbose") ? JSON.stringify(ranked, null, 2) : renderSignals(ranked));
215
229
  console.error(`Fetched ${fetched} candidate signal(s); ${fresh.length} fresh, ${deduped.length} deduped ` +
216
230
  `(window ${config.dedupWindowDays}d). NO plan emitted — signals are read-only re: CRM.`);
217
231
  const save = saveRequested(rest);
@@ -259,7 +273,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
259
273
  return true;
260
274
  });
261
275
  const ranked = filtered.sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
262
- console.log(JSON.stringify(ranked, null, 2));
276
+ console.log(rest.includes("--json") || rest.includes("--verbose") ? JSON.stringify(ranked, null, 2) : renderSignals(ranked));
263
277
  console.error(`${ranked.length} signal(s)${unjudgedOnly ? " (unjudged)" : ""}.`);
264
278
  return;
265
279
  }
@@ -286,7 +300,12 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
286
300
  const outcomes = await store.listOutcomes();
287
301
  const signalsById = new Map((await store.allSignals()).map((s) => [s.id, s]));
288
302
  const weights = computeWeights(config, outcomes, signalsById);
289
- console.log(JSON.stringify(weights, null, 2));
303
+ if (rest.includes("--json") || rest.includes("--verbose")) {
304
+ console.log(JSON.stringify(weights, null, 2));
305
+ }
306
+ else {
307
+ console.log(["Learned signal weights", "", ...SIGNAL_BUCKETS.map((bucket) => `${bucket.padEnd(10)} ${weights[bucket].toFixed(4)}`)].join("\n"));
308
+ }
290
309
  if (rest.includes("--explain")) {
291
310
  // Per-bucket config default vs learned + booked/total over credited signals.
292
311
  const booked = new Map();
package/dist/cli/tam.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Best-effort enrich-config load for apply-time acquire-budget enforcement. */
2
2
  /** Provider API key: env override first, then the credential store (`login`). */
3
- export declare function providerKey(provider: "explorium" | "pipe0" | "heyreach" | "theirstack"): string;
3
+ export declare function providerKey(provider: "explorium" | "pipe0" | "clay" | "heyreach" | "theirstack"): string;
4
4
  /**
5
5
  * `tam` — Total Addressable Market mapping. Estimate a defensible universe from
6
6
  * the ICP, then iteratively populate it via scheduled governed acquire runs and
package/dist/cli/tam.js CHANGED
@@ -5,6 +5,7 @@ import { getCredential } from "../credentials.js";
5
5
  import { appendCoverage, computeCoverage, coverageCountsFromSnapshot, classifyCoverage, coverageToText, deriveAcvFromClosedWon, deriveBuyersPerAccount, estimateTam, loadTamModel, projectEta, readCoverageTimeline, saveTamModel, tamReportToMarkdown } from "../tam.js";
6
6
  import { probeExploriumBusinessCount } from "../connectors/prospectSources.js";
7
7
  import { theirStackCountCompanies, theirStackPullCost, theirStackSearchCompanies } from "../connectors/theirstack.js";
8
+ import { clayApiKey } from "../connectors/clay.js";
8
9
  import { icpToExploriumBusinessFilters, icpToTheirStackFilters } from "../icp.js";
9
10
  import { scheduleCommand } from "./schedule.js";
10
11
  import { loadIcp, numericOption, option, readSnapshot, saveRequested } from "./shared.js";
@@ -12,6 +13,8 @@ import { unknownSubcommandError } from "./suggest.js";
12
13
  /** Best-effort enrich-config load for apply-time acquire-budget enforcement. */
13
14
  /** Provider API key: env override first, then the credential store (`login`). */
14
15
  export function providerKey(provider) {
16
+ if (provider === "clay")
17
+ return clayApiKey();
15
18
  const envName = provider === "explorium"
16
19
  ? "EXPLORIUM_API_KEY"
17
20
  : provider === "pipe0"
@@ -41,7 +44,7 @@ export async function tamCommand(args) {
41
44
  fullstackgtm tam accounts [--name <n>] [--icp <path>] --source theirstack [--max <n>] [--dry-run | --confirm] [--max-credits <n>] [--usd-per-credit <r>] [--out <file.csv> | --json]
42
45
  fullstackgtm tam status [--name <n>] <source options> [--save] [--json]
43
46
  fullstackgtm tam report [--name <n>] [--out <path>]
44
- fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--label <l>]
47
+ fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--label <l>]
45
48
 
46
49
  Estimate the reachable market FROM your ICP: a real account count × a confirmed
47
50
  ANNUAL ACV (--acv <annual-usd>, or --acv-from-crm --deal-period monthly|quarterly|
@@ -253,9 +256,18 @@ RevOps universe, with real names. --source explorium is a firmographic count onl
253
256
  writeFileSync(resolve(process.cwd(), out), md);
254
257
  console.log(`Wrote ${out}.`);
255
258
  }
256
- else {
259
+ else if (rest.includes("--verbose")) {
257
260
  console.log(md);
258
261
  }
262
+ else if (timeline.length > 0) {
263
+ console.log(coverageToText(model, timeline.at(-1), eta));
264
+ console.log("\nUse --verbose for assumptions, cross-checks, and the full coverage history.");
265
+ }
266
+ else {
267
+ console.log(`TAM "${model.name}" · ${model.universe.accounts.toLocaleString()} accounts · ${model.universe.contacts.toLocaleString()} buyers · $${Math.round(model.tamUsd).toLocaleString()}`);
268
+ console.log(`ICP ${model.icpName} · ${model.acv.basis}-basis ACV $${Math.round(model.acv.valueUsd).toLocaleString()} (${model.acv.source})`);
269
+ console.log("No coverage readings yet. Run `fullstackgtm tam status --save` to establish one; use --verbose for the full assumptions report.");
270
+ }
259
271
  return;
260
272
  }
261
273
  if (sub === "populate") {
package/dist/cli/ui.js CHANGED
@@ -262,8 +262,12 @@ export function createChecklist(items, stream = process.stderr, env = process.en
262
262
  const label = entry.state === "pending" ? p.dim(item.label.padEnd(labelWidth)) : item.label.padEnd(labelWidth);
263
263
  return ` ${glyph} ${label}${note}`;
264
264
  });
265
- const up = painted > 0 ? `\u001b[${painted}A` : "";
266
- stream.write(`${up}${lines.map((line) => `\u001b[2K${line}`).join("\n")}\n`);
265
+ // Keep the cursor on the board's last row while it is live. A trailing
266
+ // newline on every repaint can scroll the old top row into permanent
267
+ // history when the board sits at the bottom of the terminal, producing
268
+ // apparent duplicate/errored frames in terminal captures.
269
+ const up = painted > 1 ? `\u001b[${painted - 1}A` : "";
270
+ stream.write(`${up}${lines.map((line) => `\u001b[2K${line}`).join("\n")}`);
267
271
  painted = lines.length;
268
272
  };
269
273
  const start = () => {
@@ -292,14 +296,18 @@ export function createChecklist(items, stream = process.stderr, env = process.en
292
296
  timer = null;
293
297
  if (options.persist) {
294
298
  // The caller's final state update already painted the completed board.
295
- // Freeze that frame in scrollback; repainting here can leave both the
296
- // last live frame and the final frame visible in some terminals.
299
+ // Advance exactly once so subsequent output begins below it.
300
+ if (painted > 0)
301
+ stream.write("\n");
297
302
  painted = 0;
298
303
  return;
299
304
  }
300
305
  if (painted > 0) {
301
- // Erase the board: cursor up over every painted line, clear each.
302
- stream.write(`\u001b[${painted}A${"\u001b[2K\n".repeat(painted)}\u001b[${painted}A`);
306
+ // Erase in place without linefeeds, which could themselves scroll.
307
+ const up = painted > 1 ? `\u001b[${painted - 1}A` : "";
308
+ const clear = Array.from({ length: painted }, (_, index) => `\u001b[2K${index < painted - 1 ? "\u001b[1B" : ""}`).join("");
309
+ const restore = painted > 1 ? `\u001b[${painted - 1}A` : "";
310
+ stream.write(`${up}${clear}${restore}`);
303
311
  painted = 0;
304
312
  }
305
313
  },
@@ -0,0 +1,33 @@
1
+ import type { Prospect } from "./prospectSources.ts";
2
+ export declare const CLAY_PUBLIC_API_BASE = "https://api.clay.com/public/v0";
3
+ export type ClayKeyValidation = {
4
+ ok: boolean;
5
+ detail: string;
6
+ workspaceId?: string;
7
+ };
8
+ export type ClaySearchSourceType = "people" | "companies";
9
+ export type ClayPeopleSearchPage = {
10
+ prospects: Prospect[];
11
+ hasMore: boolean;
12
+ };
13
+ /** Create a forward-only Clay search iterator. This call does not fetch a page. */
14
+ export declare function createClaySearch(opts: {
15
+ apiKey: string;
16
+ sourceType: ClaySearchSourceType;
17
+ filters: Record<string, unknown>;
18
+ fetchImpl?: typeof fetch;
19
+ apiBaseUrl?: string;
20
+ }): Promise<string>;
21
+ /** Consume the next people page from a Clay search iterator. */
22
+ export declare function runClayPeopleSearchPage(opts: {
23
+ apiKey: string;
24
+ searchId: string;
25
+ limit?: number;
26
+ fetchImpl?: typeof fetch;
27
+ apiBaseUrl?: string;
28
+ }): Promise<ClayPeopleSearchPage>;
29
+ export declare function normalizeClayPerson(value: unknown): Prospect;
30
+ /** Validate a Clay Public API key without creating a search or spending enrichment credits. */
31
+ export declare function validateClayApiKey(apiKey: string, fetchImpl?: typeof fetch, apiBaseUrl?: string): Promise<ClayKeyValidation>;
32
+ /** Resolve Clay credentials using the standard env -> profile-scoped store ladder. */
33
+ export declare function clayApiKey(env?: Record<string, string | undefined>): string;
@@ -0,0 +1,123 @@
1
+ import { getCredential } from "../credentials.js";
2
+ import { ProviderHttpError } from "../providerError.js";
3
+ export const CLAY_PUBLIC_API_BASE = "https://api.clay.com/public/v0";
4
+ function clayHeaders(apiKey) {
5
+ return { "clay-api-key": apiKey, Accept: "application/json", "Content-Type": "application/json" };
6
+ }
7
+ /** Create a forward-only Clay search iterator. This call does not fetch a page. */
8
+ export async function createClaySearch(opts) {
9
+ const fetchImpl = opts.fetchImpl ?? fetch;
10
+ const base = (opts.apiBaseUrl ?? CLAY_PUBLIC_API_BASE).replace(/\/$/, "");
11
+ const response = await fetchImpl(`${base}/search/filters-mode`, {
12
+ method: "POST",
13
+ headers: clayHeaders(opts.apiKey),
14
+ body: JSON.stringify({ source_type: opts.sourceType, filters: opts.filters }),
15
+ });
16
+ if (!response.ok)
17
+ throw new ProviderHttpError("Clay", "create search", response.status);
18
+ const body = await response.json();
19
+ const searchId = body.search_id ?? body.searchId;
20
+ if (typeof searchId !== "string" || !searchId)
21
+ throw new Error("Clay create search returned no search_id.");
22
+ return searchId;
23
+ }
24
+ /** Consume the next people page from a Clay search iterator. */
25
+ export async function runClayPeopleSearchPage(opts) {
26
+ const fetchImpl = opts.fetchImpl ?? fetch;
27
+ const base = (opts.apiBaseUrl ?? CLAY_PUBLIC_API_BASE).replace(/\/$/, "");
28
+ const limit = Math.max(1, Math.min(opts.limit ?? 25, 100));
29
+ const response = await fetchImpl(`${base}/search/filters-mode/${encodeURIComponent(opts.searchId)}/run`, {
30
+ method: "POST",
31
+ headers: clayHeaders(opts.apiKey),
32
+ body: JSON.stringify({ limit }),
33
+ });
34
+ if (!response.ok)
35
+ throw new ProviderHttpError("Clay", "run people search", response.status);
36
+ const body = await response.json();
37
+ if (!Array.isArray(body.data))
38
+ throw new Error("Clay people search returned an invalid data array.");
39
+ const hasMore = body.has_more ?? body.hasMore;
40
+ if (typeof hasMore !== "boolean")
41
+ throw new Error("Clay people search returned no has_more flag.");
42
+ return { prospects: body.data.map(normalizeClayPerson), hasMore };
43
+ }
44
+ export function normalizeClayPerson(value) {
45
+ const row = value && typeof value === "object" ? value : {};
46
+ const location = row.structured_location && typeof row.structured_location === "object"
47
+ ? row.structured_location
48
+ : {};
49
+ const fullName = stringValue(row.name);
50
+ return {
51
+ firstName: stringValue(row.first_name),
52
+ lastName: stringValue(row.last_name),
53
+ fullName,
54
+ jobTitle: stringValue(row.latest_experience_title),
55
+ companyName: stringValue(row.latest_experience_company),
56
+ companyDomain: bareDomain(stringValue(row.domain)),
57
+ linkedin: normalizeLinkedin(stringValue(row.url)),
58
+ headline: undefined,
59
+ sourceId: normalizeLinkedin(stringValue(row.url)) ?? fullName,
60
+ location: {
61
+ city: stringValue(location.city),
62
+ state: stringValue(location.state),
63
+ region: stringValue(location.region),
64
+ country: stringValue(location.country),
65
+ countryCode: stringValue(location.country_iso),
66
+ },
67
+ };
68
+ }
69
+ function stringValue(value) {
70
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
71
+ }
72
+ function bareDomain(value) {
73
+ return value?.replace(/^https?:\/\//i, "").replace(/^www\./i, "").replace(/\/.*$/, "").toLowerCase() || undefined;
74
+ }
75
+ function normalizeLinkedin(value) {
76
+ if (!value)
77
+ return undefined;
78
+ const normalized = value.startsWith("http") ? value : `https://${value.replace(/^\/+/, "")}`;
79
+ return normalized.replace(/\/$/, "");
80
+ }
81
+ /** Validate a Clay Public API key without creating a search or spending enrichment credits. */
82
+ export async function validateClayApiKey(apiKey, fetchImpl = fetch, apiBaseUrl = CLAY_PUBLIC_API_BASE) {
83
+ let response;
84
+ try {
85
+ response = await fetchImpl(`${apiBaseUrl.replace(/\/$/, "")}/me`, {
86
+ headers: { "clay-api-key": apiKey, Accept: "application/json" },
87
+ });
88
+ }
89
+ catch (error) {
90
+ const detail = error instanceof Error ? error.message : String(error);
91
+ return { ok: false, detail: `Cannot reach the Clay Public API: ${detail}` };
92
+ }
93
+ if (!response.ok)
94
+ return { ok: false, detail: `Clay rejected the key: HTTP ${response.status}` };
95
+ let body;
96
+ try {
97
+ body = await response.json();
98
+ }
99
+ catch {
100
+ return { ok: false, detail: "Clay accepted the request but returned an invalid JSON response." };
101
+ }
102
+ const record = body && typeof body === "object" ? body : {};
103
+ const workspace = record.workspace && typeof record.workspace === "object"
104
+ ? record.workspace
105
+ : undefined;
106
+ const rawWorkspaceId = record.workspace_id ?? record.workspaceId ?? workspace?.id;
107
+ const workspaceId = typeof rawWorkspaceId === "string" || typeof rawWorkspaceId === "number"
108
+ ? String(rawWorkspaceId)
109
+ : undefined;
110
+ return {
111
+ ok: true,
112
+ detail: workspaceId ? `Key accepted by Clay workspace ${workspaceId}.` : "Key accepted by the Clay Public API.",
113
+ workspaceId,
114
+ };
115
+ }
116
+ /** Resolve Clay credentials using the standard env -> profile-scoped store ladder. */
117
+ export function clayApiKey(env = process.env) {
118
+ const key = env.CLAY_API_KEY ?? getCredential("clay")?.accessToken;
119
+ if (key)
120
+ return key;
121
+ throw new Error('No Clay credentials. Run `clay api-keys create --name "fullstackgtm"`, then pipe the key to ' +
122
+ '`fullstackgtm login clay`, or set CLAY_API_KEY.');
123
+ }
@@ -18,6 +18,10 @@ export type HubspotConnectorOptions = {
18
18
  * alongside the legacy `onProgress` callback; both are presentation-only.
19
19
  */
20
20
  progress?: ProgressEmitter;
21
+ /** Maximum retries for HubSpot 429/5xx responses (default 5). */
22
+ maxRetries?: number;
23
+ /** Injectable delay for deterministic retry tests. */
24
+ sleep?: (milliseconds: number) => Promise<void>;
21
25
  };
22
26
  /**
23
27
  * Reference connector for HubSpot.
@@ -30,6 +30,8 @@ const PULL_STAGE_BY_TYPE = {
30
30
  export function createHubspotConnector(options) {
31
31
  const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
32
32
  const fetchImpl = options.fetchImpl ?? fetch;
33
+ const maxRetries = options.maxRetries ?? 5;
34
+ const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
33
35
  const mappings = options.fieldMappings;
34
36
  // create:<Name> dedup within one connector lifetime (one apply run): the
35
37
  // search API is eventually consistent, so a just-created company is
@@ -66,28 +68,44 @@ export function createHubspotConnector(options) {
66
68
  emitter.items(fetched);
67
69
  };
68
70
  async function request(path, init = {}) {
69
- const token = await options.getAccessToken();
70
71
  let response;
71
- try {
72
- response = await fetchImpl(`${baseUrl}${path}`, {
73
- ...init,
74
- headers: {
75
- Authorization: `Bearer ${token}`,
76
- "Content-Type": "application/json",
77
- ...(init.headers ?? {}),
78
- },
79
- });
80
- }
81
- catch (error) {
82
- const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
83
- throw new Error(`Cannot reach HubSpot at ${baseUrl}${cause}. Check network access.`);
84
- }
85
- if (!response.ok) {
72
+ for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
73
+ const token = await options.getAccessToken();
74
+ try {
75
+ response = await fetchImpl(`${baseUrl}${path}`, {
76
+ ...init,
77
+ headers: {
78
+ Authorization: `Bearer ${token}`,
79
+ "Content-Type": "application/json",
80
+ ...(init.headers ?? {}),
81
+ },
82
+ });
83
+ }
84
+ catch (error) {
85
+ const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
86
+ throw new Error(`Cannot reach HubSpot at ${baseUrl}${cause}. Check network access.`);
87
+ }
88
+ const retryable = response.status === 429 || response.status >= 500;
89
+ if (!retryable || attempt === maxRetries)
90
+ break;
91
+ await response.text().catch(() => undefined);
92
+ const retryAfter = response.headers.get("Retry-After");
93
+ const seconds = retryAfter === null ? NaN : Number(retryAfter);
94
+ const dateDelay = retryAfter && !Number.isFinite(seconds) ? Date.parse(retryAfter) - Date.now() : NaN;
95
+ const delayMs = Math.min(30_000, Math.max(0, Number.isFinite(seconds) ? seconds * 1_000 : Number.isFinite(dateDelay) ? dateDelay : 1_000 * 2 ** attempt));
96
+ const reason = response.status === 429 ? "rate limited" : `temporarily unavailable (${response.status})`;
97
+ options.progress?.note(`HubSpot ${reason}; retrying in ${Math.ceil(delayMs / 1_000)}s (${attempt + 1}/${maxRetries})`);
98
+ await sleep(delayMs);
99
+ }
100
+ if (!response || !response.ok) {
86
101
  // Status line only — HubSpot 4xx bodies echo submitted property values
87
102
  // (contact emails, company domains) and the request payload, and these
88
103
  // errors are persisted into scheduled-run records. Never interpolate it.
89
- await response.text().catch(() => undefined);
90
- throw new Error(`HubSpot API error ${response.status}. Check the token scopes and request.`);
104
+ await response?.text().catch(() => undefined);
105
+ if (response?.status === 429) {
106
+ throw new Error(`HubSpot rate limit (429) persisted after ${maxRetries} retries. Wait for the portal limit to reset, then retry; no CRM writes were made.`);
107
+ }
108
+ throw new Error(`HubSpot API error ${response?.status ?? "unknown"}. Check the token scopes and request.`);
91
109
  }
92
110
  // DELETE and some association writes return 204 with an empty body.
93
111
  const text = await response.text();
@@ -32,6 +32,18 @@ export type Prospect = {
32
32
  linkedin?: string;
33
33
  /** real work email once resolved (pipe0); never the hashed value */
34
34
  email?: string;
35
+ /** Validated mobile number when a contact provider explicitly returns one. */
36
+ mobile?: string;
37
+ /** Validated business direct dial when distinct from mobile. */
38
+ directDial?: string;
39
+ /** Person geography returned by identity-search providers. */
40
+ location?: {
41
+ city?: string;
42
+ state?: string;
43
+ region?: string;
44
+ country?: string;
45
+ countryCode?: string;
46
+ };
35
47
  /** ICP fit score 0..1, set by the acquire scorer */
36
48
  fitScore?: number;
37
49
  /** provider-native id for traceability */
@@ -313,7 +313,7 @@ function fieldValue(field) {
313
313
  return typeof v === "string" && v.trim() ? v : undefined;
314
314
  }
315
315
  // ---------------------------------------------------------------------------
316
- // Pre-email dedup: identity keys shared between prospects and CRM contacts, so
316
+ // Pre-enrichment dedup: identity keys shared between prospects and CRM contacts, so
317
317
  // we can drop already-in-CRM / already-seen people BEFORE paying for emails.
318
318
  function normName(value) {
319
319
  return (value ?? "").trim().toLowerCase().replace(/\s+/g, " ");
@@ -0,0 +1,44 @@
1
+ import type { Prospect } from "./connectors/prospectSources.ts";
2
+ export type ContactField = "work_email" | "mobile" | "direct_dial";
3
+ export type ContactInputShape = "linkedin" | "name_domain" | "email" | "phone" | "company_domain";
4
+ export type ContactProviderExecution = "sync" | "poll" | "webhook" | "batch";
5
+ export type ContactProviderBilling = "per_attempt" | "per_match" | "per_field" | "subscription" | "unknown";
6
+ export type ContactProviderCapability = {
7
+ provider: string;
8
+ operation: string;
9
+ inputShapes: ContactInputShape[];
10
+ outputFields: ContactField[];
11
+ execution: ContactProviderExecution;
12
+ billing: ContactProviderBilling;
13
+ chargesOn: Array<"request" | "match" | "valid" | "accept_all" | "unknown">;
14
+ supportsBalance: boolean;
15
+ supportsIdempotency: boolean;
16
+ maxBatchSize?: number;
17
+ };
18
+ /** Only implemented adapters belong here; planned providers stay in the strategy document. */
19
+ export declare const CONTACT_PROVIDER_CAPABILITIES: Readonly<Record<string, readonly ContactProviderCapability[]>>;
20
+ export type ContactWaterfallStep = {
21
+ provider: string;
22
+ fields: ContactField[];
23
+ };
24
+ export type ContactProviderAdapter = {
25
+ provider: string;
26
+ resolve(prospects: Prospect[], fields: ContactField[]): Promise<Prospect[]>;
27
+ };
28
+ export type ContactWaterfallAttempt = {
29
+ provider: string;
30
+ fields: ContactField[];
31
+ attempted: number;
32
+ added: Partial<Record<ContactField, number>>;
33
+ };
34
+ export type ContactWaterfallResult = {
35
+ prospects: Prospect[];
36
+ attempts: ContactWaterfallAttempt[];
37
+ };
38
+ export declare function validateContactWaterfall(steps: ContactWaterfallStep[]): ContactWaterfallStep[];
39
+ /** Run providers in order. Later steps receive only records still missing a requested field. */
40
+ export declare function runContactWaterfall(opts: {
41
+ prospects: Prospect[];
42
+ steps: ContactWaterfallStep[];
43
+ adapters: Readonly<Record<string, ContactProviderAdapter>>;
44
+ }): Promise<ContactWaterfallResult>;
@@ -0,0 +1,100 @@
1
+ /** Only implemented adapters belong here; planned providers stay in the strategy document. */
2
+ export const CONTACT_PROVIDER_CAPABILITIES = {
3
+ pipe0: [{
4
+ provider: "pipe0",
5
+ operation: "person:workemail:waterfall@1",
6
+ inputShapes: ["name_domain"],
7
+ outputFields: ["work_email"],
8
+ execution: "batch",
9
+ billing: "per_attempt",
10
+ chargesOn: ["request"],
11
+ supportsBalance: false,
12
+ supportsIdempotency: false,
13
+ maxBatchSize: 100,
14
+ }],
15
+ };
16
+ export function validateContactWaterfall(steps) {
17
+ if (!Array.isArray(steps) || steps.length === 0) {
18
+ throw new Error("contact waterfall must contain at least one provider step");
19
+ }
20
+ return steps.map((step, index) => {
21
+ if (!step || typeof step.provider !== "string" || !step.provider.trim()) {
22
+ throw new Error(`contact waterfall step ${index + 1}: provider must be a non-empty string`);
23
+ }
24
+ const capabilities = CONTACT_PROVIDER_CAPABILITIES[step.provider];
25
+ if (!capabilities) {
26
+ throw new Error(`contact waterfall step ${index + 1}: unsupported provider "${step.provider}" ` +
27
+ `(implemented: ${Object.keys(CONTACT_PROVIDER_CAPABILITIES).join(", ")})`);
28
+ }
29
+ if (!Array.isArray(step.fields) || step.fields.length === 0) {
30
+ throw new Error(`contact waterfall step ${index + 1}: fields must be a non-empty array`);
31
+ }
32
+ const fields = [...new Set(step.fields)];
33
+ for (const field of fields) {
34
+ if (field !== "work_email" && field !== "mobile" && field !== "direct_dial") {
35
+ throw new Error(`contact waterfall step ${index + 1}: unsupported field "${String(field)}"`);
36
+ }
37
+ if (!capabilities.some((capability) => capability.outputFields.includes(field))) {
38
+ const available = [...new Set(capabilities.flatMap((capability) => capability.outputFields))];
39
+ throw new Error(`contact waterfall step ${index + 1}: ${step.provider} does not implement "${field}" ` +
40
+ `(available: ${available.join(", ")})`);
41
+ }
42
+ }
43
+ return { provider: step.provider, fields };
44
+ });
45
+ }
46
+ /** Run providers in order. Later steps receive only records still missing a requested field. */
47
+ export async function runContactWaterfall(opts) {
48
+ const steps = validateContactWaterfall(opts.steps);
49
+ const prospects = opts.prospects.map((prospect) => ({ ...prospect }));
50
+ const attempts = [];
51
+ for (const step of steps) {
52
+ const adapter = opts.adapters[step.provider];
53
+ if (!adapter)
54
+ throw new Error(`contact waterfall: adapter "${step.provider}" is not configured`);
55
+ const indexes = prospects
56
+ .map((prospect, index) => step.fields.some((field) => !fieldValue(prospect, field)) ? index : -1)
57
+ .filter((index) => index >= 0);
58
+ if (indexes.length === 0) {
59
+ attempts.push({ provider: step.provider, fields: step.fields, attempted: 0, added: {} });
60
+ continue;
61
+ }
62
+ const inputs = indexes.map((index) => prospects[index]);
63
+ const outputs = await adapter.resolve(inputs, step.fields);
64
+ if (outputs.length !== inputs.length) {
65
+ throw new Error(`contact waterfall: ${step.provider} returned ${outputs.length} result(s) for ${inputs.length} input(s)`);
66
+ }
67
+ const added = {};
68
+ outputs.forEach((output, outputIndex) => {
69
+ const prospectIndex = indexes[outputIndex];
70
+ const current = prospects[prospectIndex];
71
+ let merged = current;
72
+ for (const field of step.fields) {
73
+ if (fieldValue(current, field))
74
+ continue;
75
+ const value = fieldValue(output, field);
76
+ if (!value)
77
+ continue;
78
+ merged = setFieldValue(merged, field, value);
79
+ added[field] = (added[field] ?? 0) + 1;
80
+ }
81
+ prospects[prospectIndex] = merged;
82
+ });
83
+ attempts.push({ provider: step.provider, fields: step.fields, attempted: inputs.length, added });
84
+ }
85
+ return { prospects, attempts };
86
+ }
87
+ function fieldValue(prospect, field) {
88
+ if (field === "work_email")
89
+ return prospect.email;
90
+ if (field === "mobile")
91
+ return prospect.mobile;
92
+ return prospect.directDial;
93
+ }
94
+ function setFieldValue(prospect, field, value) {
95
+ if (field === "work_email")
96
+ return { ...prospect, email: value };
97
+ if (field === "mobile")
98
+ return { ...prospect, mobile: value };
99
+ return { ...prospect, directDial: value };
100
+ }