fullstackgtm 0.57.0 → 0.58.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/src/enrich.ts CHANGED
@@ -20,7 +20,7 @@ import type {
20
20
  *
21
21
  * Every enrichment vendor ships fire-and-forget writeback — data lands without
22
22
  * a diff, without approval, over whatever a human typed. This layer inverts
23
- * that: a source (Apollo pull, Clay ingest) feeds a deterministic matcher,
23
+ * that: a source (Apollo/ZoomInfo pull, Clay ingest) feeds a deterministic matcher,
24
24
  * the matcher feeds a fill-blanks-only patch plan, and the plan goes through
25
25
  * the existing dry-run → approval → apply contract. Every proposed value is
26
26
  * traceable to the source payload that produced it (`GtmEvidence` on the
@@ -154,7 +154,7 @@ const MATCH_KEYS: Record<EnrichObjectType, string[]> = {
154
154
  };
155
155
 
156
156
  /** API source ids the MVP can pull from. */
157
- export const SUPPORTED_API_SOURCES = ["apollo", "explorium", "pipe0", "clay", "linkedin"];
157
+ export const SUPPORTED_API_SOURCES = ["apollo", "zoominfo", "explorium", "pipe0", "clay", "linkedin"];
158
158
 
159
159
  /**
160
160
  * Canonical fields enrich may target, plus the HubSpot property spellings the
@@ -0,0 +1,197 @@
1
+ import { execFile } from "node:child_process";
2
+ import type { EnrichObjectType, EnrichSourceRecord } from "./enrich.ts";
3
+ import type { ApolloPullKey as EnrichPullKey, ApolloPullOptions as EnrichPullOptions, ApolloPullResult as EnrichPullResult } from "./enrichApollo.ts";
4
+
5
+ /**
6
+ * ZoomInfo source adapter for governed enrichment.
7
+ *
8
+ * ZoomInfo owns authentication and credit accounting in its official `gtm`
9
+ * CLI. fullstackgtm invokes that CLI as a read-only source, normalizes the
10
+ * JSON response, then sends proposed CRM changes through the normal
11
+ * diff → patch plan → approval → apply lifecycle. No ZoomInfo credential is
12
+ * copied into fullstackgtm and this adapter never writes to the CRM directly.
13
+ */
14
+
15
+ export type ZoomInfoCommandResult = {
16
+ stdout: string;
17
+ stderr: string;
18
+ };
19
+
20
+ export type ZoomInfoCommandRunner = (
21
+ executable: string,
22
+ args: string[],
23
+ ) => Promise<ZoomInfoCommandResult>;
24
+
25
+ export type ZoomInfoClientOptions = {
26
+ /** Official ZoomInfo CLI executable. Defaults to `gtm`. */
27
+ executable?: string;
28
+ /** Injectable command runner for tests. */
29
+ run?: ZoomInfoCommandRunner;
30
+ };
31
+
32
+ export type ZoomInfoClient = {
33
+ enrichCompany(domain: string): Promise<unknown>;
34
+ enrichContact(email: string): Promise<unknown>;
35
+ };
36
+
37
+ const DEFAULT_MAX_BUFFER = 16 * 1024 * 1024;
38
+
39
+ const runCommand: ZoomInfoCommandRunner = (executable, args) =>
40
+ new Promise((resolve, reject) => {
41
+ execFile(executable, args, { encoding: "utf8", maxBuffer: DEFAULT_MAX_BUFFER }, (error, stdout, stderr) => {
42
+ if (error) {
43
+ const code = (error as NodeJS.ErrnoException).code;
44
+ if (code === "ENOENT") {
45
+ reject(
46
+ new Error(
47
+ "ZoomInfo CLI not found. Install it with `brew install zoominfo/gtm-ai/gtm-ai-cli` " +
48
+ "or `npm install -g @zoominfo/gtm-ai-cli`, then run `gtm auth login`.",
49
+ ),
50
+ );
51
+ return;
52
+ }
53
+ // Do not echo stdout/stderr: provider errors can contain submitted
54
+ // company domains, contact emails, or tenant-specific data and this
55
+ // message may be persisted in a scheduled-run record.
56
+ reject(
57
+ new Error(
58
+ `ZoomInfo CLI failed${typeof code === "string" || typeof code === "number" ? ` (${code})` : ""}. ` +
59
+ "Run `gtm auth whoami`, then retry the same `gtm ... enrich` command directly if needed.",
60
+ ),
61
+ );
62
+ return;
63
+ }
64
+ resolve({ stdout, stderr });
65
+ });
66
+ });
67
+
68
+ function parseOutput(stdout: string): unknown {
69
+ const text = stdout.trim();
70
+ if (!text) return null;
71
+ try {
72
+ return JSON.parse(text);
73
+ } catch {
74
+ throw new Error(
75
+ "ZoomInfo CLI returned non-JSON output. Upgrade `gtm` and verify that `gtm ... enrich -f json` works directly.",
76
+ );
77
+ }
78
+ }
79
+
80
+ export function createZoomInfoClient(options: ZoomInfoClientOptions = {}): ZoomInfoClient {
81
+ const executable = options.executable ?? process.env.ZOOMINFO_GTM_BIN ?? "gtm";
82
+ const run = options.run ?? runCommand;
83
+
84
+ async function invoke(args: string[]): Promise<unknown> {
85
+ const result = await run(executable, [...args, "-f", "json"]);
86
+ return parseOutput(result.stdout);
87
+ }
88
+
89
+ return {
90
+ enrichCompany(domain) {
91
+ return invoke(["companies", "enrich", "--domain", domain]);
92
+ },
93
+ enrichContact(email) {
94
+ return invoke(["contacts", "enrich", "--email", email]);
95
+ },
96
+ };
97
+ }
98
+
99
+ function recordCandidates(value: unknown): Record<string, unknown>[] {
100
+ if (Array.isArray(value)) {
101
+ return value.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object" && !Array.isArray(item));
102
+ }
103
+ if (!value || typeof value !== "object") return [];
104
+ const object = value as Record<string, unknown>;
105
+ if (Array.isArray(object.data)) return recordCandidates(object.data);
106
+ if (object.data && typeof object.data === "object") return recordCandidates(object.data);
107
+ if (Array.isArray(object.companies)) return recordCandidates(object.companies);
108
+ if (Array.isArray(object.contacts)) return recordCandidates(object.contacts);
109
+ return [object];
110
+ }
111
+
112
+ function valueAt(record: Record<string, unknown>, paths: string[]): string | undefined {
113
+ for (const path of paths) {
114
+ let current: unknown = record;
115
+ for (const segment of path.split(".")) {
116
+ if (!current || typeof current !== "object") {
117
+ current = undefined;
118
+ break;
119
+ }
120
+ current = (current as Record<string, unknown>)[segment];
121
+ }
122
+ if (typeof current === "string" && current.trim()) return current.trim();
123
+ if (typeof current === "number" && Number.isFinite(current)) return String(current);
124
+ }
125
+ return undefined;
126
+ }
127
+
128
+ function normalizeRecord(payload: unknown, key: EnrichPullKey): EnrichSourceRecord | undefined {
129
+ const record = recordCandidates(payload)[0];
130
+ if (!record) return undefined;
131
+ const id = valueAt(record, ["id", "attributes.id", "companyId", "personId", "attributes.companyId", "attributes.personId"]);
132
+ if (key.objectType === "company") {
133
+ return {
134
+ id: `zoominfo:company_${id ?? key.value}`,
135
+ objectType: "company",
136
+ keys: {
137
+ domain: valueAt(record, [
138
+ "attributes.domain",
139
+ "attributes.website",
140
+ "attributes.companyWebsite",
141
+ "domain",
142
+ "website",
143
+ "companyWebsite",
144
+ ]) ?? key.value,
145
+ name: valueAt(record, ["attributes.name", "attributes.companyName", "name", "companyName"]),
146
+ },
147
+ payload: record,
148
+ };
149
+ }
150
+ return {
151
+ id: `zoominfo:contact_${id ?? key.value}`,
152
+ objectType: "contact",
153
+ keys: {
154
+ email: valueAt(record, [
155
+ "attributes.email",
156
+ "attributes.emailAddress",
157
+ "attributes.contactEmail",
158
+ "email",
159
+ "emailAddress",
160
+ "contactEmail",
161
+ ]) ?? key.value,
162
+ name: valueAt(record, ["attributes.fullName", "attributes.name", "fullName", "name"]),
163
+ },
164
+ payload: record,
165
+ };
166
+ }
167
+
168
+ /** Pull snapshot-driven enrichment keys through ZoomInfo's official CLI. */
169
+ export async function pullZoomInfoRecords(
170
+ client: ZoomInfoClient,
171
+ keys: EnrichPullKey[],
172
+ options: EnrichPullOptions = {},
173
+ ): Promise<EnrichPullResult> {
174
+ const records: EnrichSourceRecord[] = [];
175
+ const misses: EnrichPullKey[] = [];
176
+ const resumeIndex = options.resumeAfter ? keys.findIndex((key) => key.value === options.resumeAfter) : -1;
177
+
178
+ for (const key of keys.slice(resumeIndex + 1)) {
179
+ const payload = key.objectType === "company"
180
+ ? await client.enrichCompany(key.value)
181
+ : await client.enrichContact(key.value);
182
+ const record = normalizeRecord(payload, key);
183
+ if (record) records.push(record);
184
+ else misses.push(key);
185
+ await options.onProgress?.({
186
+ lastKeyValue: key.value,
187
+ record,
188
+ miss: record ? undefined : key,
189
+ });
190
+ }
191
+ return { records, misses };
192
+ }
193
+
194
+ export type ZoomInfoPullKey = EnrichPullKey;
195
+ export type ZoomInfoPullOptions = EnrichPullOptions;
196
+ export type ZoomInfoPullResult = EnrichPullResult;
197
+ export type ZoomInfoObjectType = EnrichObjectType;
package/src/index.ts CHANGED
@@ -276,6 +276,17 @@ export {
276
276
  type ApolloPullKey,
277
277
  type ApolloPullResult,
278
278
  } from "./enrichApollo.ts";
279
+ export {
280
+ createZoomInfoClient,
281
+ pullZoomInfoRecords,
282
+ type ZoomInfoClient,
283
+ type ZoomInfoClientOptions,
284
+ type ZoomInfoCommandResult,
285
+ type ZoomInfoCommandRunner,
286
+ type ZoomInfoPullKey,
287
+ type ZoomInfoPullOptions,
288
+ type ZoomInfoPullResult,
289
+ } from "./enrichZoomInfo.ts";
279
290
  export {
280
291
  diffFindings,
281
292
  diffSnapshots,
package/src/runReport.ts CHANGED
@@ -126,6 +126,10 @@ let inflightHeartbeat: AbortController | null = null;
126
126
  * commands simply never stream.
127
127
  */
128
128
  export function beginRunReport(args: string[], startedAt: number): void {
129
+ counts = undefined;
130
+ findings = undefined;
131
+ crm = undefined;
132
+ events.length = 0;
129
133
  lastHeartbeatAt = 0;
130
134
  heartbeatBroker = undefined;
131
135
  inflightHeartbeat = null;
@@ -241,6 +245,14 @@ export async function flushRunReport(
241
245
  // replay of local history never duplicates.
242
246
  const identity = runIdentity(args, startedAt);
243
247
  if (!identity) return;
248
+ // A successful command with no result, finding, event, or live progress is
249
+ // terminal telemetry rather than something a person can explore. Keep it
250
+ // out of Runs. Errors always report; heartbeating commands still send their
251
+ // terminal status so an in-progress row cannot be stranded.
252
+ const hasUsefulOutput = Boolean(
253
+ (counts && Object.keys(counts).length > 0) || findings?.length || events.length,
254
+ );
255
+ if (status === "success" && !hasUsefulOutput && lastHeartbeatAt === 0) return;
244
256
  const broker = getCredential("broker");
245
257
  if (!broker?.baseUrl || !broker.accessToken) return; // opt-in: only when paired
246
258