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.
- package/CHANGELOG.md +45 -0
- package/README.md +7 -0
- package/dist/cli/audit.js +6 -1
- package/dist/cli/auth.js +49 -4
- package/dist/cli/backfill.js +4 -0
- package/dist/cli/call.js +28 -6
- package/dist/cli/draft.js +11 -1
- package/dist/cli/enrich.js +124 -57
- package/dist/cli/fix.js +6 -3
- package/dist/cli/help.js +31 -28
- package/dist/cli/icp.js +15 -1
- package/dist/cli/init.js +3 -3
- package/dist/cli/market.js +14 -1
- package/dist/cli/planOutput.d.ts +5 -0
- package/dist/cli/planOutput.js +27 -0
- package/dist/cli/plans.js +184 -33
- package/dist/cli/schedule.js +22 -11
- package/dist/cli/signals.js +22 -3
- package/dist/cli/tam.d.ts +1 -1
- package/dist/cli/tam.js +14 -2
- package/dist/cli/ui.js +14 -6
- package/dist/connectors/clay.d.ts +33 -0
- package/dist/connectors/clay.js +123 -0
- package/dist/connectors/hubspot.d.ts +4 -0
- package/dist/connectors/hubspot.js +36 -18
- package/dist/connectors/prospectSources.d.ts +12 -0
- package/dist/connectors/prospectSources.js +1 -1
- package/dist/contactProviders.d.ts +44 -0
- package/dist/contactProviders.js +100 -0
- package/dist/enrich.d.ts +7 -1
- package/dist/enrich.js +46 -1
- package/dist/hostedPatchPlan.js +6 -1
- package/dist/icp.d.ts +2 -0
- package/dist/icp.js +49 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/init.d.ts +1 -1
- package/dist/init.js +1 -1
- package/docs/api.md +18 -1
- package/package.json +1 -1
- package/src/cli/audit.ts +5 -1
- package/src/cli/auth.ts +46 -4
- package/src/cli/backfill.ts +3 -0
- package/src/cli/call.ts +25 -6
- package/src/cli/draft.ts +9 -1
- package/src/cli/enrich.ts +132 -56
- package/src/cli/fix.ts +5 -3
- package/src/cli/help.ts +31 -28
- package/src/cli/icp.ts +13 -1
- package/src/cli/init.ts +3 -3
- package/src/cli/market.ts +12 -1
- package/src/cli/planOutput.ts +30 -0
- package/src/cli/plans.ts +174 -29
- package/src/cli/schedule.ts +21 -15
- package/src/cli/signals.ts +22 -3
- package/src/cli/tam.ts +12 -3
- package/src/cli/ui.ts +15 -6
- package/src/connectors/clay.ts +155 -0
- package/src/connectors/hubspot.ts +41 -17
- package/src/connectors/prospectSources.ts +7 -1
- package/src/contactProviders.ts +141 -0
- package/src/enrich.ts +51 -2
- package/src/hostedPatchPlan.ts +6 -1
- package/src/icp.ts +51 -0
- package/src/index.ts +25 -0
- package/src/init.ts +2 -2
package/dist/cli/schedule.js
CHANGED
|
@@ -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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
|
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
|
|
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 ` +
|
package/dist/cli/signals.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
//
|
|
296
|
-
|
|
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
|
|
302
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
90
|
-
|
|
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-
|
|
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
|
+
}
|