fullstackgtm 0.37.0 → 0.38.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 +50 -0
- package/INSTALL_FOR_AGENTS.md +8 -0
- package/README.md +14 -0
- package/dist/acquireLinkedIn.d.ts +41 -0
- package/dist/acquireLinkedIn.js +57 -0
- package/dist/assign.d.ts +83 -0
- package/dist/assign.js +146 -0
- package/dist/bin.js +14 -2
- package/dist/cli.js +91 -11
- package/dist/connectors/hubspot.js +5 -0
- package/dist/connectors/linkedin.d.ts +78 -0
- package/dist/connectors/linkedin.js +199 -0
- package/dist/enrich.d.ts +21 -1
- package/dist/enrich.js +127 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/reassign.d.ts +11 -2
- package/dist/reassign.js +13 -6
- package/dist/runReport.d.ts +9 -0
- package/dist/runReport.js +66 -0
- package/dist/types.d.ts +8 -0
- package/docs/api.md +24 -1
- package/docs/architecture.md +3 -0
- package/docs/linkedin-connector-spec.md +87 -0
- package/llms.txt +22 -1
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +3 -2
- package/src/acquireLinkedIn.ts +83 -0
- package/src/assign.ts +193 -0
- package/src/bin.ts +17 -4
- package/src/cli.ts +102 -12
- package/src/connectors/hubspot.ts +5 -0
- package/src/connectors/linkedin.ts +272 -0
- package/src/enrich.ts +137 -3
- package/src/index.ts +10 -0
- package/src/reassign.ts +24 -8
- package/src/runReport.ts +76 -0
- package/src/types.ts +8 -0
package/dist/cli.js
CHANGED
|
@@ -34,6 +34,8 @@ import { buildAcquirePlan, buildEnrichPlan, createFileEnrichRunStore, DEFAULT_ST
|
|
|
34
34
|
import { loadMeter, recordConsumption, remaining, } from "./acquireMeter.js";
|
|
35
35
|
import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspects, partitionFreshProspects, pipe0ResolveWorkEmails, prospectIdentityKeys, } from "./connectors/prospectSources.js";
|
|
36
36
|
import { loadSeen, recordSeen } from "./acquireSeen.js";
|
|
37
|
+
import { reportCounts, reportEvent } from "./runReport.js";
|
|
38
|
+
import { createLinkedInProvider, discoverLinkedInProspects } from "./acquireLinkedIn.js";
|
|
37
39
|
import { fitThreshold, icpFromAnswers, icpToCrustdataFilters, icpToExploriumFilters, parseIcp, scoreProspectAgainstIcp, INTERVIEW_SPEC, } from "./icp.js";
|
|
38
40
|
import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, } from "./enrichApollo.js";
|
|
39
41
|
import { computeMissedFirings, createFileScheduleRunStore, createFileScheduleStore, nextCronFiring, parseCron, renderManagedBlock, replaceManagedBlock, assertSingleLineLabel, hasControlChar, scheduleId, systemCrontabIo, tokenizeCommand, validateSchedulableArgv, } from "./schedule.js";
|
|
@@ -54,7 +56,7 @@ Usage:
|
|
|
54
56
|
fullstackgtm login salesforce --instance-url <url> [--no-validate]
|
|
55
57
|
fullstackgtm login stripe [--no-validate]
|
|
56
58
|
fullstackgtm login anthropic | openai store an LLM API key for call parse/score
|
|
57
|
-
fullstackgtm login apollo store an Apollo API key for enrich pulls\n fullstackgtm login pipe0 | explorium store a discovery-provider key for enrich acquire\n fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|pipe0|explorium|broker>
|
|
59
|
+
fullstackgtm login apollo store an Apollo API key for enrich pulls\n fullstackgtm login pipe0 | explorium store a discovery-provider key for enrich acquire\n fullstackgtm login heyreach store a HeyReach key for enrich acquire --source linkedin\n fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|pipe0|explorium|heyreach|broker>
|
|
58
60
|
|
|
59
61
|
Secrets (tokens, client secrets) are NEVER passed as flags — they leak via
|
|
60
62
|
the process list and shell history. Pipe them on stdin or enter them at the
|
|
@@ -129,9 +131,10 @@ Usage:
|
|
|
129
131
|
deterministic survivor (richest = most populated data
|
|
130
132
|
fields, ties to lowest id; oldest = lowest id). Approve and
|
|
131
133
|
apply like any plan; merges are IRREVERSIBLE on apply.
|
|
132
|
-
fullstackgtm reassign --from <ownerId> --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--except-deal-stage <stage>] [--include-closed-deals] [source options] [--save] [--json] [--out <path>]
|
|
134
|
+
fullstackgtm reassign (--from <ownerId> | --assign-unowned) --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--except-deal-stage <stage>] [--include-closed-deals] [source options] [--save] [--json] [--out <path>]
|
|
133
135
|
ownership handoff playbook: one bulk-update-style plan per
|
|
134
|
-
object type (ownerId=<from> → <to>).
|
|
136
|
+
object type (ownerId=<from> → <to>). --assign-unowned instead
|
|
137
|
+
claims every ownerless record (ownerId:empty) for --to. Extra --where scoping
|
|
135
138
|
is account-lifted for deals/contacts (domain~.de becomes
|
|
136
139
|
account.domain~.de); --except-deal-stage <stage> excludes
|
|
137
140
|
deals in that stage AND every record whose account has an
|
|
@@ -358,7 +361,7 @@ const HELP = {
|
|
|
358
361
|
reassign: {
|
|
359
362
|
summary: "ownership handoff: one plan per object type",
|
|
360
363
|
phase: "Remediate",
|
|
361
|
-
synopsis: ["fullstackgtm reassign --from <ownerId> --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--save] [--json]"],
|
|
364
|
+
synopsis: ["fullstackgtm reassign (--from <ownerId> | --assign-unowned) --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--save] [--json]"],
|
|
362
365
|
detail: "Extra `--where` scoping is account-lifted for deals/contacts. `--except-deal-stage <stage>` excludes that stage and any record whose account has an open deal in it, re-verified per record at apply.",
|
|
363
366
|
seeAlso: ["bulk-update", "plans", "apply"],
|
|
364
367
|
},
|
|
@@ -723,6 +726,11 @@ async function audit(args) {
|
|
|
723
726
|
if (staleDealDays !== undefined)
|
|
724
727
|
policy.staleDealDays = staleDealDays;
|
|
725
728
|
const plan = auditSnapshot(snapshot, policy, rules);
|
|
729
|
+
reportCounts({
|
|
730
|
+
findings: plan.findings.length,
|
|
731
|
+
critical: plan.findings.filter((f) => f.severity === "critical").length,
|
|
732
|
+
warning: plan.findings.filter((f) => f.severity === "warning").length,
|
|
733
|
+
});
|
|
726
734
|
const out = option(args, "--out");
|
|
727
735
|
if (out) {
|
|
728
736
|
writeFileSync(resolve(process.cwd(), out), `${JSON.stringify(plan, null, 2)}\n`);
|
|
@@ -1599,7 +1607,7 @@ enrich append [--source apollo] [--objects companies,contacts] [--save] [--conf
|
|
|
1599
1607
|
enrich refresh [--source apollo] [--stale-days <n>] [--save] [--config <path>]
|
|
1600
1608
|
[source options] [--run-label <label>] [--json]
|
|
1601
1609
|
enrich ingest <file.csv|payload.json> --source clay [--run-label <label>] [--objects companies|contacts] [--config <path>]
|
|
1602
|
-
enrich acquire [--source <id>] [--max <n>] [--save] [--config <path>] [--json]
|
|
1610
|
+
enrich acquire [--source <id>] [--max <n>] [--list <id>] [--assign-owner <id>] [--save] [--config <path>] [--json]
|
|
1603
1611
|
enrich status [--runs] [--source <id>] [--config <path>] [--json]
|
|
1604
1612
|
|
|
1605
1613
|
acquire creates NET-NEW leads from a staged prospect list (ingest first):
|
|
@@ -1609,6 +1617,16 @@ it dedupes each sourced row against the CRM, skips matches and ambiguities
|
|
|
1609
1617
|
remaining budget (records + spend, per day and per month; whichever is hit
|
|
1610
1618
|
first). Approval-gated like every write: \`--save\` → plans approve → apply.
|
|
1611
1619
|
The meter is charged only when a create actually lands at apply.
|
|
1620
|
+
Discovery sources (zero-config presets): explorium | pipe0 (ICP-driven search),
|
|
1621
|
+
and linkedin (reads a HeyReach lead list — pass --list <id>, key on the LinkedIn
|
|
1622
|
+
URL, $0/record; \`login heyreach\` or HEYREACH_API_KEY).
|
|
1623
|
+
|
|
1624
|
+
Leads are never born ownerless: set an \`acquire.assign\` policy (fixed /
|
|
1625
|
+
round-robin / territory / account-owner) to stamp an owner at create time, or
|
|
1626
|
+
pass \`--assign-owner <id>\`. With a single-owner portal and no policy, acquire
|
|
1627
|
+
defaults every lead to that owner; with several owners and no policy it warns
|
|
1628
|
+
and leaves them unassigned. Backfill existing ownerless records with
|
|
1629
|
+
\`reassign --assign-unowned --to <ownerId>\`.
|
|
1612
1630
|
|
|
1613
1631
|
append pulls from an api source (Apollo — BYO key via \`login apollo\` or
|
|
1614
1632
|
APOLLO_API_KEY) or reads data staged by \`enrich ingest\` (Clay CSV exports,
|
|
@@ -1725,6 +1743,31 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
|
|
|
1725
1743
|
let cap = headroom.maxRecords;
|
|
1726
1744
|
if (explicitMax !== undefined)
|
|
1727
1745
|
cap = cap === null ? explicitMax : Math.min(cap, explicitMax);
|
|
1746
|
+
// Assignment: never create an ownerless lead. An explicit `acquire.assign`
|
|
1747
|
+
// policy wins; a `--assign-owner <id>` flag is a quick fixed override;
|
|
1748
|
+
// otherwise, when the portal has exactly one active owner, default every
|
|
1749
|
+
// lead to them. With multiple owners and no policy we refuse to guess —
|
|
1750
|
+
// leads are left unassigned and the operator is told to configure a rule.
|
|
1751
|
+
const assignOwnerFlag = option(rest, "--assign-owner");
|
|
1752
|
+
if (!config.acquire.assign) {
|
|
1753
|
+
if (assignOwnerFlag) {
|
|
1754
|
+
config.acquire.assign = { strategy: "fixed", ownerId: assignOwnerFlag };
|
|
1755
|
+
}
|
|
1756
|
+
else {
|
|
1757
|
+
const activeOwners = (snapshot.users ?? []).filter((u) => u.active !== false);
|
|
1758
|
+
if (activeOwners.length === 1) {
|
|
1759
|
+
const sole = activeOwners[0].crmId ?? activeOwners[0].id;
|
|
1760
|
+
config.acquire.assign = { strategy: "fixed", ownerId: sole };
|
|
1761
|
+
console.error(`Assignment: no policy set — defaulting every lead to the portal's sole owner ` +
|
|
1762
|
+
`${activeOwners[0].name} (${sole}). Configure "acquire.assign" to route across reps.`);
|
|
1763
|
+
}
|
|
1764
|
+
else if (activeOwners.length > 1) {
|
|
1765
|
+
console.error(`⚠ Assignment: ${activeOwners.length} active owners and no "acquire.assign" policy — ` +
|
|
1766
|
+
`leads will be created OWNERLESS. Add an acquire.assign rule (fixed / round-robin / territory) ` +
|
|
1767
|
+
`or pass --assign-owner <id> so every lead lands with an owner.`);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1728
1771
|
const result = buildAcquirePlan({
|
|
1729
1772
|
config,
|
|
1730
1773
|
source,
|
|
@@ -1733,6 +1776,19 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
|
|
|
1733
1776
|
runLabel: option(rest, "--run-label") ?? `acquire-${source}-${today}`,
|
|
1734
1777
|
maxRecords: cap,
|
|
1735
1778
|
});
|
|
1779
|
+
if (result.counts.unassigned > 0 && result.counts.created > 0) {
|
|
1780
|
+
console.error(`⚠ ${result.counts.unassigned}/${result.counts.created} proposed lead(s) have no owner ` +
|
|
1781
|
+
`(policy could not place them). They will be created ownerless.`);
|
|
1782
|
+
}
|
|
1783
|
+
// Observability: headline metrics for the web run timeline (paired users).
|
|
1784
|
+
reportCounts({
|
|
1785
|
+
sourced: result.counts.fetched,
|
|
1786
|
+
created: result.counts.created,
|
|
1787
|
+
withheldByMeter: result.counts.withheldByMeter,
|
|
1788
|
+
skippedInCrm: apiSkippedCrm,
|
|
1789
|
+
skippedSeen: apiSkippedSeen,
|
|
1790
|
+
estCostUsd: result.estCostUsd,
|
|
1791
|
+
});
|
|
1736
1792
|
const meterLine = formatAcquireMeter(headroom, costPerRecord);
|
|
1737
1793
|
if (!save) {
|
|
1738
1794
|
if (rest.includes("--json")) {
|
|
@@ -1750,6 +1806,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
|
|
|
1750
1806
|
if (result.plan.operations.length > 0) {
|
|
1751
1807
|
await createFilePlanStore().save(result.plan);
|
|
1752
1808
|
planIds.push(result.plan.id);
|
|
1809
|
+
reportEvent("plan_saved", result.plan.id);
|
|
1753
1810
|
}
|
|
1754
1811
|
await store.update({
|
|
1755
1812
|
...run,
|
|
@@ -1861,6 +1918,12 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
|
|
|
1861
1918
|
// Pull keys the source had no data for count as fetched-but-unmatched.
|
|
1862
1919
|
result.counts.fetched += missCount;
|
|
1863
1920
|
result.counts.unmatched += missCount;
|
|
1921
|
+
reportCounts({
|
|
1922
|
+
fetched: result.counts.fetched,
|
|
1923
|
+
matched: result.counts.matched,
|
|
1924
|
+
unmatched: result.counts.unmatched,
|
|
1925
|
+
opsEmitted: result.counts.opsEmitted,
|
|
1926
|
+
});
|
|
1864
1927
|
if (!save) {
|
|
1865
1928
|
if (rest.includes("--json")) {
|
|
1866
1929
|
console.log(JSON.stringify(result.plan, null, 2));
|
|
@@ -1906,7 +1969,7 @@ function formatEnrichCounts(counts, ambiguities) {
|
|
|
1906
1969
|
/** Best-effort enrich-config load for apply-time acquire-budget enforcement. */
|
|
1907
1970
|
/** Provider API key: env override first, then the credential store (`login`). */
|
|
1908
1971
|
function providerKey(provider) {
|
|
1909
|
-
const envName = provider === "explorium" ? "EXPLORIUM_API_KEY" : "PIPE0_API_KEY";
|
|
1972
|
+
const envName = provider === "explorium" ? "EXPLORIUM_API_KEY" : provider === "pipe0" ? "PIPE0_API_KEY" : "HEYREACH_API_KEY";
|
|
1910
1973
|
if (process.env[envName])
|
|
1911
1974
|
return process.env[envName];
|
|
1912
1975
|
const stored = getCredential(provider);
|
|
@@ -2006,8 +2069,18 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen) {
|
|
|
2006
2069
|
const filters = icp ? icpToCrustdataFilters(icp) : (disc.filters ?? {});
|
|
2007
2070
|
prospects = await fetchPipe0CrustdataProspects({ apiKey: providerKey("pipe0"), filters, limit: size });
|
|
2008
2071
|
}
|
|
2072
|
+
else if (disc.provider === "linkedin" || disc.provider === "heyreach") {
|
|
2073
|
+
// LinkedIn reads a pre-populated lead list (not an ICP-driven query); the ICP
|
|
2074
|
+
// scores the pulled list below. List id: disc.listId or --list <id>.
|
|
2075
|
+
const listId = disc.listId ?? option(rest, "--list") ?? undefined;
|
|
2076
|
+
if (!listId) {
|
|
2077
|
+
throw new Error("enrich acquire --source linkedin needs a HeyReach lead-list id: pass --list <id> or set acquire.discovery.linkedin.listId.");
|
|
2078
|
+
}
|
|
2079
|
+
const provider = createLinkedInProvider(disc.provider, providerKey("heyreach"));
|
|
2080
|
+
prospects = await discoverLinkedInProspects(provider, { sourceId: listId, max: size });
|
|
2081
|
+
}
|
|
2009
2082
|
else {
|
|
2010
|
-
throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (explorium | pipe0).`);
|
|
2083
|
+
throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (explorium | pipe0 | linkedin).`);
|
|
2011
2084
|
}
|
|
2012
2085
|
// 2. ICP fit scoring BEFORE paying for emails — only above-threshold prospects
|
|
2013
2086
|
// proceed to (credit-spending) email resolution.
|
|
@@ -2807,8 +2880,9 @@ async function dedupeCommand(args) {
|
|
|
2807
2880
|
async function reassignCommand(args) {
|
|
2808
2881
|
const from = option(args, "--from");
|
|
2809
2882
|
const to = option(args, "--to");
|
|
2810
|
-
|
|
2811
|
-
|
|
2883
|
+
const assignUnowned = args.includes("--assign-unowned");
|
|
2884
|
+
if (!to || (!from && !assignUnowned)) {
|
|
2885
|
+
throw new Error("Usage: fullstackgtm reassign (--from <ownerId> | --assign-unowned) --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--except-deal-stage <stage>] [--include-closed-deals] [source options] [--reason <text>] [--max-operations <n>] [--save] [--out <path>] [--json]\n\n--assign-unowned claims every OWNERLESS record (ownerId:empty) for --to — the backfill twin of `enrich acquire`'s create-time assignment.");
|
|
2812
2886
|
}
|
|
2813
2887
|
const objects = option(args, "--objects")
|
|
2814
2888
|
?.split(",")
|
|
@@ -2816,8 +2890,9 @@ async function reassignCommand(args) {
|
|
|
2816
2890
|
.filter(Boolean);
|
|
2817
2891
|
const snapshot = await readSnapshot(args);
|
|
2818
2892
|
const plans = buildReassignPlans(snapshot, {
|
|
2819
|
-
fromOwnerId: from,
|
|
2893
|
+
fromOwnerId: from ?? undefined,
|
|
2820
2894
|
toOwnerId: to,
|
|
2895
|
+
assignUnowned,
|
|
2821
2896
|
objects,
|
|
2822
2897
|
where: repeatedOption(args, "--where"),
|
|
2823
2898
|
exceptDealStage: option(args, "--except-deal-stage") ?? undefined,
|
|
@@ -3159,6 +3234,11 @@ async function apply(args) {
|
|
|
3159
3234
|
recordConsumption(new Date(), landed.length, spend);
|
|
3160
3235
|
}
|
|
3161
3236
|
}
|
|
3237
|
+
// Observability: apply-outcome tallies for the web run timeline.
|
|
3238
|
+
const applyTally = { applied: 0, conflict: 0, skipped: 0, failed: 0 };
|
|
3239
|
+
for (const result of run.results)
|
|
3240
|
+
applyTally[result.status] = (applyTally[result.status] ?? 0) + 1;
|
|
3241
|
+
reportCounts(applyTally);
|
|
3162
3242
|
if (args.includes("--json")) {
|
|
3163
3243
|
console.log(JSON.stringify(run, null, 2));
|
|
3164
3244
|
}
|
|
@@ -3603,7 +3683,7 @@ async function login(args) {
|
|
|
3603
3683
|
console.log(`Stored Apollo API key in ${credentialsPath()}. \`fullstackgtm enrich append|refresh\` use it automatically.`);
|
|
3604
3684
|
return;
|
|
3605
3685
|
}
|
|
3606
|
-
if (provider === "pipe0" || provider === "explorium") {
|
|
3686
|
+
if (provider === "pipe0" || provider === "explorium" || provider === "heyreach") {
|
|
3607
3687
|
rejectArgvSecret(args, "--token", "--key", "--api-key");
|
|
3608
3688
|
const key = await readSecret(`${provider} API key`);
|
|
3609
3689
|
if (!key)
|
|
@@ -505,6 +505,11 @@ export function createHubspotConnector(options) {
|
|
|
505
505
|
return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already exists (${existing.join(", ")}); resolve-first declined to create.`, providerData: { id: existing[0], existing: true } };
|
|
506
506
|
}
|
|
507
507
|
const properties = { ...payload.properties };
|
|
508
|
+
// Stamp ownership at create time so the lead is never born ownerless. The
|
|
509
|
+
// canonical ownerId maps to HubSpot's standard owner property.
|
|
510
|
+
if (payload.ownerId && !properties.hubspot_owner_id) {
|
|
511
|
+
properties.hubspot_owner_id = String(payload.ownerId);
|
|
512
|
+
}
|
|
508
513
|
let created;
|
|
509
514
|
try {
|
|
510
515
|
created = await request(`/crm/v3/objects/${objectPath}`, {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkedIn connector — Phase 1 discovery (read-only).
|
|
3
|
+
*
|
|
4
|
+
* A provider-agnostic surface for pulling prospects out of LinkedIn via a
|
|
5
|
+
* rented execution layer (default: HeyReach; Unipile is a drop-in alternative).
|
|
6
|
+
* Deliberately decoupled from the acquire spine — the `Icp`→filter and
|
|
7
|
+
* `LinkedInProspect`→`Prospect` bridges live in the `enrich acquire --source
|
|
8
|
+
* linkedin` wiring, not here — so this layer can land and be tested on its own.
|
|
9
|
+
*
|
|
10
|
+
* Phase 2 (future) extends `LinkedInProvider` with send actions
|
|
11
|
+
* (connect/message) routed through the plan/apply gate. Spec:
|
|
12
|
+
* docs/linkedin-connector-spec.md. Nothing here ever writes or sends.
|
|
13
|
+
*/
|
|
14
|
+
export type LinkedInProspect = {
|
|
15
|
+
firstName: string;
|
|
16
|
+
lastName: string;
|
|
17
|
+
fullName?: string;
|
|
18
|
+
headline?: string;
|
|
19
|
+
jobTitle?: string;
|
|
20
|
+
company?: string;
|
|
21
|
+
/** Canonical LinkedIn profile URL — the identity key dedup uses downstream. */
|
|
22
|
+
profileUrl: string;
|
|
23
|
+
location?: string;
|
|
24
|
+
email?: string;
|
|
25
|
+
emailStatus?: string;
|
|
26
|
+
/** The raw provider payload, preserved for debugging and later mapping. */
|
|
27
|
+
raw?: unknown;
|
|
28
|
+
};
|
|
29
|
+
export type LinkedInSource = {
|
|
30
|
+
/** Provider-native id of a prospect source (HeyReach: a lead-list id). */
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
/** Approximate prospect count, when the provider reports it. */
|
|
34
|
+
count?: number;
|
|
35
|
+
};
|
|
36
|
+
export type FetchProspectsOptions = {
|
|
37
|
+
/** Which source to read (HeyReach lead-list id); provider may have a default. */
|
|
38
|
+
sourceId?: string;
|
|
39
|
+
/** Hard cap on prospects returned; also bounds pagination. */
|
|
40
|
+
max?: number;
|
|
41
|
+
};
|
|
42
|
+
export type ConnectionStatus = {
|
|
43
|
+
ok: boolean;
|
|
44
|
+
detail: string;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Provider-agnostic LinkedIn discovery surface (Phase 1). Read-only: it
|
|
48
|
+
* enumerates prospect sources and pulls normalized prospects, never sends.
|
|
49
|
+
*/
|
|
50
|
+
export interface LinkedInProvider {
|
|
51
|
+
readonly name: string;
|
|
52
|
+
/** Cheap auth/connectivity check; never throws — returns ok:false instead. */
|
|
53
|
+
checkConnection(): Promise<ConnectionStatus>;
|
|
54
|
+
/** Enumerate prospect sources (HeyReach lead lists). */
|
|
55
|
+
listSources(): Promise<LinkedInSource[]>;
|
|
56
|
+
/** Pull prospects from a source, normalized + capped. */
|
|
57
|
+
fetchProspects(options?: FetchProspectsOptions): Promise<LinkedInProspect[]>;
|
|
58
|
+
}
|
|
59
|
+
export declare const HEYREACH_BASE = "https://api.heyreach.io/api/public";
|
|
60
|
+
export type HeyReachProviderOptions = {
|
|
61
|
+
apiKey: string;
|
|
62
|
+
fetchImpl?: typeof fetch;
|
|
63
|
+
baseUrl?: string;
|
|
64
|
+
/** Lead-list id used when fetchProspects is called without an explicit sourceId. */
|
|
65
|
+
defaultListId?: string;
|
|
66
|
+
/** Page size for paginated reads (≤ server max). Lower in tests. */
|
|
67
|
+
pageSize?: number;
|
|
68
|
+
};
|
|
69
|
+
export declare function createHeyReachProvider(options: HeyReachProviderOptions): LinkedInProvider;
|
|
70
|
+
/** Map a HeyReach lead (nested `profile` or flat) into the normalized shape. */
|
|
71
|
+
export declare function normalizeHeyReachLead(raw: any): LinkedInProspect;
|
|
72
|
+
export declare const FAKE_LINKEDIN_PROSPECTS: LinkedInProspect[];
|
|
73
|
+
export type FakeLinkedInOptions = {
|
|
74
|
+
prospects?: LinkedInProspect[];
|
|
75
|
+
sources?: LinkedInSource[];
|
|
76
|
+
connection?: ConnectionStatus;
|
|
77
|
+
};
|
|
78
|
+
export declare function createFakeLinkedInProvider(opts?: FakeLinkedInOptions): LinkedInProvider;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkedIn connector — Phase 1 discovery (read-only).
|
|
3
|
+
*
|
|
4
|
+
* A provider-agnostic surface for pulling prospects out of LinkedIn via a
|
|
5
|
+
* rented execution layer (default: HeyReach; Unipile is a drop-in alternative).
|
|
6
|
+
* Deliberately decoupled from the acquire spine — the `Icp`→filter and
|
|
7
|
+
* `LinkedInProspect`→`Prospect` bridges live in the `enrich acquire --source
|
|
8
|
+
* linkedin` wiring, not here — so this layer can land and be tested on its own.
|
|
9
|
+
*
|
|
10
|
+
* Phase 2 (future) extends `LinkedInProvider` with send actions
|
|
11
|
+
* (connect/message) routed through the plan/apply gate. Spec:
|
|
12
|
+
* docs/linkedin-connector-spec.md. Nothing here ever writes or sends.
|
|
13
|
+
*/
|
|
14
|
+
// ── HeyReach adapter ────────────────────────────────────────────────────────
|
|
15
|
+
// Base + auth verified 2026-06-20: POST endpoints, `X-API-KEY` header, 300
|
|
16
|
+
// req/min (429). Confirmed live: /auth/CheckApiKey, /list/GetAll. The
|
|
17
|
+
// leads-from-list path has a documented gap in HeyReach's public docs and is
|
|
18
|
+
// marked to reconcile at live validation (step 3, needs a real key).
|
|
19
|
+
export const HEYREACH_BASE = "https://api.heyreach.io/api/public";
|
|
20
|
+
const HEYREACH_MAX_PAGE = 100;
|
|
21
|
+
export function createHeyReachProvider(options) {
|
|
22
|
+
const apiKey = options.apiKey;
|
|
23
|
+
if (!apiKey) {
|
|
24
|
+
throw new Error("HeyReach API key is required (`login heyreach` or HEYREACH_API_KEY).");
|
|
25
|
+
}
|
|
26
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
27
|
+
const baseUrl = (options.baseUrl ?? HEYREACH_BASE).replace(/\/$/, "");
|
|
28
|
+
const pageSize = Math.max(1, Math.min(options.pageSize ?? HEYREACH_MAX_PAGE, HEYREACH_MAX_PAGE));
|
|
29
|
+
async function call(path, method, body) {
|
|
30
|
+
let response;
|
|
31
|
+
try {
|
|
32
|
+
response = await fetchImpl(`${baseUrl}${path}`, {
|
|
33
|
+
method,
|
|
34
|
+
// Secret travels in the header, never the URL (no leak via logs/history).
|
|
35
|
+
headers: {
|
|
36
|
+
"X-API-KEY": apiKey,
|
|
37
|
+
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
38
|
+
},
|
|
39
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new Error(`Cannot reach HeyReach at ${baseUrl}${path}. Check network access.`);
|
|
44
|
+
}
|
|
45
|
+
if (response.status === 429) {
|
|
46
|
+
const retry = response.headers.get("Retry-After");
|
|
47
|
+
throw new Error(`HeyReach rate limit (429)${retry ? `; retry after ${retry}s` : ""}. The cap is 300 requests/min.`);
|
|
48
|
+
}
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
await response.text().catch(() => undefined);
|
|
51
|
+
throw new Error(`HeyReach API error ${response.status} on ${path}. Check the API key and request.`);
|
|
52
|
+
}
|
|
53
|
+
if (response.status === 204)
|
|
54
|
+
return undefined;
|
|
55
|
+
return response.json().catch(() => undefined);
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
name: "heyreach",
|
|
59
|
+
async checkConnection() {
|
|
60
|
+
try {
|
|
61
|
+
await call("/auth/CheckApiKey", "GET");
|
|
62
|
+
return { ok: true, detail: "HeyReach API key valid." };
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
async listSources() {
|
|
69
|
+
const data = await call("/list/GetAll", "POST", { offset: 0, limit: HEYREACH_MAX_PAGE });
|
|
70
|
+
return toArray(data).map((item) => ({
|
|
71
|
+
id: String(item?.id ?? item?.listId ?? ""),
|
|
72
|
+
name: String(item?.name ?? item?.title ?? "(unnamed list)"),
|
|
73
|
+
count: typeof item?.totalItemsCount === "number"
|
|
74
|
+
? item.totalItemsCount
|
|
75
|
+
: typeof item?.count === "number"
|
|
76
|
+
? item.count
|
|
77
|
+
: undefined,
|
|
78
|
+
}));
|
|
79
|
+
},
|
|
80
|
+
async fetchProspects(opts = {}) {
|
|
81
|
+
const listId = opts.sourceId ?? options.defaultListId;
|
|
82
|
+
if (!listId) {
|
|
83
|
+
throw new Error("HeyReach fetchProspects needs a sourceId (lead-list id) or a configured defaultListId — list them with listSources().");
|
|
84
|
+
}
|
|
85
|
+
const max = opts.max ?? Number.POSITIVE_INFINITY;
|
|
86
|
+
const out = [];
|
|
87
|
+
let offset = 0;
|
|
88
|
+
while (out.length < max) {
|
|
89
|
+
const data = await call("/list/GetLeadsFromList", "POST", { listId, offset, limit: pageSize });
|
|
90
|
+
const items = toArray(data);
|
|
91
|
+
if (items.length === 0)
|
|
92
|
+
break;
|
|
93
|
+
for (const item of items) {
|
|
94
|
+
out.push(normalizeHeyReachLead(item));
|
|
95
|
+
if (out.length >= max)
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
if (items.length < pageSize)
|
|
99
|
+
break;
|
|
100
|
+
offset += items.length;
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** First value that is a non-empty string, else undefined (treats "" as absent). */
|
|
107
|
+
function firstNonEmpty(...values) {
|
|
108
|
+
for (const value of values) {
|
|
109
|
+
if (typeof value === "string" && value.trim() !== "")
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
/** HeyReach wraps list payloads as `{ items: [...] }` on some routes, bare arrays on others. */
|
|
115
|
+
function toArray(data) {
|
|
116
|
+
if (Array.isArray(data))
|
|
117
|
+
return data;
|
|
118
|
+
if (Array.isArray(data?.items))
|
|
119
|
+
return data.items;
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
/** Map a HeyReach lead (nested `profile` or flat) into the normalized shape. */
|
|
123
|
+
export function normalizeHeyReachLead(raw) {
|
|
124
|
+
const p = (raw && typeof raw === "object" && raw.profile) || raw || {};
|
|
125
|
+
const first = p.firstName ?? raw?.firstName ?? "";
|
|
126
|
+
const last = p.lastName ?? raw?.lastName ?? "";
|
|
127
|
+
const full = (p.fullName ?? raw?.fullName ?? `${first} ${last}`.trim()) || undefined;
|
|
128
|
+
return {
|
|
129
|
+
firstName: String(first ?? ""),
|
|
130
|
+
lastName: String(last ?? ""),
|
|
131
|
+
fullName: full,
|
|
132
|
+
headline: p.headline ?? raw?.headline ?? undefined,
|
|
133
|
+
jobTitle: p.position ?? p.jobTitle ?? raw?.position ?? undefined,
|
|
134
|
+
company: p.companyName ?? p.company ?? raw?.companyName ?? undefined,
|
|
135
|
+
profileUrl: String(p.profileUrl ?? p.linkedInUrl ?? raw?.profileUrl ?? raw?.linkedin_url ?? ""),
|
|
136
|
+
location: p.location ?? raw?.location ?? undefined,
|
|
137
|
+
// HeyReach exposes the raw `emailAddress` plus its own enrichment outputs
|
|
138
|
+
// (`enrichedEmailAddress`, `customEmailAddress`) — verified against the live
|
|
139
|
+
// /list/GetLeadsFromList schema 2026-06-20. Unresolved emails come back as
|
|
140
|
+
// "", so pick the first NON-EMPTY value (?? would keep the empty string).
|
|
141
|
+
email: firstNonEmpty(p.emailAddress, p.enrichedEmailAddress, p.customEmailAddress, p.email, raw?.emailAddress),
|
|
142
|
+
emailStatus: p.emailStatus ?? raw?.emailStatus ?? undefined,
|
|
143
|
+
raw,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// ── Fake provider (tests / dry-run with no key, no network) ─────────────────
|
|
147
|
+
export const FAKE_LINKEDIN_PROSPECTS = [
|
|
148
|
+
{
|
|
149
|
+
firstName: "Dana",
|
|
150
|
+
lastName: "Okafor",
|
|
151
|
+
fullName: "Dana Okafor",
|
|
152
|
+
headline: "VP RevOps",
|
|
153
|
+
jobTitle: "VP Revenue Operations",
|
|
154
|
+
company: "Northwind Logistics",
|
|
155
|
+
profileUrl: "https://www.linkedin.com/in/dana-okafor",
|
|
156
|
+
location: "Chicago, IL",
|
|
157
|
+
email: "dana@northwind.example",
|
|
158
|
+
emailStatus: "verified",
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
firstName: "Priya",
|
|
162
|
+
lastName: "Raman",
|
|
163
|
+
fullName: "Priya Raman",
|
|
164
|
+
headline: "Head of Sales Ops",
|
|
165
|
+
jobTitle: "Head of Sales Operations",
|
|
166
|
+
company: "Cobalt Health",
|
|
167
|
+
profileUrl: "https://www.linkedin.com/in/priya-raman",
|
|
168
|
+
location: "Austin, TX",
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
firstName: "Marco",
|
|
172
|
+
lastName: "Bianchi",
|
|
173
|
+
fullName: "Marco Bianchi",
|
|
174
|
+
headline: "RevOps Manager",
|
|
175
|
+
jobTitle: "Revenue Operations Manager",
|
|
176
|
+
company: "Ferro Systems",
|
|
177
|
+
profileUrl: "https://www.linkedin.com/in/marco-bianchi",
|
|
178
|
+
location: "Remote",
|
|
179
|
+
email: "marco@ferro.example",
|
|
180
|
+
emailStatus: "guessed",
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
export function createFakeLinkedInProvider(opts = {}) {
|
|
184
|
+
const prospects = opts.prospects ?? FAKE_LINKEDIN_PROSPECTS;
|
|
185
|
+
const sources = opts.sources ?? [{ id: "list_demo", name: "Demo ICP list", count: prospects.length }];
|
|
186
|
+
return {
|
|
187
|
+
name: "fake",
|
|
188
|
+
async checkConnection() {
|
|
189
|
+
return opts.connection ?? { ok: true, detail: "fake LinkedIn provider" };
|
|
190
|
+
},
|
|
191
|
+
async listSources() {
|
|
192
|
+
return sources.map((s) => ({ ...s }));
|
|
193
|
+
},
|
|
194
|
+
async fetchProspects(options = {}) {
|
|
195
|
+
const capped = options.max != null ? prospects.slice(0, options.max) : prospects;
|
|
196
|
+
return capped.map((p) => ({ ...p }));
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
package/dist/enrich.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AcquireBudget } from "./acquireMeter.ts";
|
|
2
|
+
import type { AssignmentContext, AssignmentPolicy } from "./assign.ts";
|
|
2
3
|
import type { CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
|
|
3
4
|
/**
|
|
4
5
|
* The enrich layer: governed append/refresh of third-party data into the CRM.
|
|
@@ -75,10 +76,12 @@ export type AcquireCreateMap = {
|
|
|
75
76
|
* real emails (e.g. explorium discovers, pipe0 resolves the work email).
|
|
76
77
|
*/
|
|
77
78
|
export type AcquireDiscoveryConfig = {
|
|
78
|
-
provider: "explorium" | "pipe0";
|
|
79
|
+
provider: "explorium" | "pipe0" | "linkedin" | "heyreach";
|
|
79
80
|
filters?: Record<string, unknown>;
|
|
80
81
|
size?: number;
|
|
81
82
|
resolveEmailsWith?: "pipe0";
|
|
83
|
+
/** LinkedIn sources only: the provider-native list id to read (HeyReach lead-list id). */
|
|
84
|
+
listId?: string;
|
|
82
85
|
};
|
|
83
86
|
export type AcquireConfig = {
|
|
84
87
|
/** Windowed budget enforced by the acquire meter; absent = unmetered. */
|
|
@@ -89,6 +92,12 @@ export type AcquireConfig = {
|
|
|
89
92
|
create: Partial<Record<EnrichObjectType, AcquireCreateMap>>;
|
|
90
93
|
/** Net-new discovery params for API sources, by source id. */
|
|
91
94
|
discovery?: Record<string, AcquireDiscoveryConfig>;
|
|
95
|
+
/**
|
|
96
|
+
* Ownership rule stamped onto every created lead so it is never born
|
|
97
|
+
* ownerless. Absent = no auto-assignment (the CLI may still default to the
|
|
98
|
+
* portal's sole active owner). Shared with `reassign --assign-unowned`.
|
|
99
|
+
*/
|
|
100
|
+
assign?: AssignmentPolicy;
|
|
92
101
|
};
|
|
93
102
|
export declare const ENRICH_CONFIG_FILE_NAME = "enrich.config.json";
|
|
94
103
|
export declare const DEFAULT_STALE_DAYS = 90;
|
|
@@ -216,6 +225,10 @@ export type AcquireCounts = {
|
|
|
216
225
|
created: number;
|
|
217
226
|
/** unmatched rows that would have been created but for the meter ceiling. */
|
|
218
227
|
withheldByMeter: number;
|
|
228
|
+
/** created ops that got an owner stamped (a subset of `created`). */
|
|
229
|
+
assigned: number;
|
|
230
|
+
/** created ops left ownerless (no policy, or policy could not place them). */
|
|
231
|
+
unassigned: number;
|
|
219
232
|
};
|
|
220
233
|
export type AcquirePlanResult = {
|
|
221
234
|
plan: PatchPlan;
|
|
@@ -232,6 +245,13 @@ export type BuildAcquirePlanOptions = {
|
|
|
232
245
|
maxRecords?: number | null;
|
|
233
246
|
now?: () => Date;
|
|
234
247
|
};
|
|
248
|
+
/**
|
|
249
|
+
* Lift a prospect/source payload into the routing attributes a territory rule
|
|
250
|
+
* reads. Tolerant of provider spellings (explorium vs pipe0); absent attributes
|
|
251
|
+
* stay undefined, so a rule that routes on them simply won't match (falling to
|
|
252
|
+
* the policy fallback) instead of mis-routing.
|
|
253
|
+
*/
|
|
254
|
+
export declare function acquireAssignmentContext(payload: Record<string, unknown>, companyName: string | undefined, accountOwnerByName: ReadonlyMap<string, string>): AssignmentContext;
|
|
235
255
|
/**
|
|
236
256
|
* Match each sourced record against the snapshot and route it: matched =
|
|
237
257
|
* already in the CRM (skip), ambiguous = a possible duplicate exists (skip —
|