fullstackgtm 0.37.0 → 0.38.1

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/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>). Extra --where scoping
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
- if (!from || !to) {
2811
- throw new Error("Usage: fullstackgtm reassign --from <ownerId> --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]");
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)
@@ -493,18 +493,27 @@ export function createHubspotConnector(options) {
493
493
  }
494
494
  return { operationId: operation.id, status: "applied", detail: `Resolved/created company "${matchValue}" (${id}).`, providerData: { id } };
495
495
  }
496
- // contact — resolve-first on the dedupe key (email by default)
496
+ // contact — resolve-first on the dedupe key (email by default). The match
497
+ // key is canonical (e.g. "linkedin"); translate it to the HubSpot property
498
+ // name ("hs_linkedin_url") before searching — HubSpot 400s on a filter
499
+ // against a property it doesn't have (there is no property named "linkedin").
497
500
  const matchKey = payload.matchKey || "email";
501
+ const searchProperty = HUBSPOT_DEFAULT_FIELD_MAPPINGS.contacts[matchKey] ?? matchKey;
498
502
  const matchKeyLower = `${matchKey}:${matchValue.toLowerCase()}`;
499
503
  if (createdContactsByMatch.has(matchKeyLower)) {
500
504
  return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already created earlier in this run; not duplicating.`, providerData: { id: createdContactsByMatch.get(matchKeyLower), existing: true } };
501
505
  }
502
- const existing = await searchContactsBy(matchKey, matchValue);
506
+ const existing = await searchContactsBy(searchProperty, matchValue);
503
507
  if (existing.length > 0) {
504
508
  createdContactsByMatch.set(matchKeyLower, existing[0]);
505
509
  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
510
  }
507
511
  const properties = { ...payload.properties };
512
+ // Stamp ownership at create time so the lead is never born ownerless. The
513
+ // canonical ownerId maps to HubSpot's standard owner property.
514
+ if (payload.ownerId && !properties.hubspot_owner_id) {
515
+ properties.hubspot_owner_id = String(payload.ownerId);
516
+ }
508
517
  let created;
509
518
  try {
510
519
  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 —