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/src/cli/plans.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { formatPatchPlanRun, patchPlanToMarkdown } from "../format.ts";
|
|
|
11
11
|
import { mergeSnapshots } from "../merge.ts";
|
|
12
12
|
import { verifyApprovalDigests } from "../integrity.ts";
|
|
13
13
|
import { buildAuditLog, verifyAuditLog } from "../auditLog.ts";
|
|
14
|
-
import { createFilePlanStore } from "../planStore.ts";
|
|
14
|
+
import { createFilePlanStore, type StoredPlan } from "../planStore.ts";
|
|
15
15
|
import { ENRICH_CONFIG_FILE_NAME, loadEnrichConfig, type EnrichConfig } from "../enrich.ts";
|
|
16
16
|
import { loadMeter, recordConsumption, remaining } from "../acquireMeter.ts";
|
|
17
17
|
import { progressReporter, reportCounts } from "../runReport.ts";
|
|
@@ -20,7 +20,7 @@ import { APPLY_STAGES, composeListeners, createProgressEmitter } from "../progre
|
|
|
20
20
|
import type { ValueSuggestion } from "../suggest.ts";
|
|
21
21
|
import type { CanonicalGtmSnapshot, CreateRecordPayload, PatchOperation, PatchPlan, PatchPlanRun } from "../types.ts";
|
|
22
22
|
import { connectorFor, isOptionValue, numericOption, option, repeatedOption, selectedRules } from "./shared.ts";
|
|
23
|
-
import { colorEnabled, createProgressRenderer, paint, planStatusWord, stylizePlanMarkdown, table, truncateToWidth } from "./ui.ts";
|
|
23
|
+
import { box, colorEnabled, createProgressRenderer, createStatusLine, formatDuration, paint, planStatusWord, stylizePlanMarkdown, table, truncateToWidth } from "./ui.ts";
|
|
24
24
|
import { unknownSubcommandError } from "./suggest.ts";
|
|
25
25
|
|
|
26
26
|
|
|
@@ -34,6 +34,115 @@ function parseValueOverrides(args: string[]) {
|
|
|
34
34
|
return valueOverrides;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
function recordHeadline(operation: PatchOperation): { title: string; subtitle?: string; company?: string } {
|
|
38
|
+
const after = operation.afterValue && typeof operation.afterValue === "object" && !Array.isArray(operation.afterValue)
|
|
39
|
+
? operation.afterValue as Record<string, unknown> : {};
|
|
40
|
+
const properties = after.properties && typeof after.properties === "object" && !Array.isArray(after.properties)
|
|
41
|
+
? after.properties as Record<string, unknown> : {};
|
|
42
|
+
const first = typeof properties.firstname === "string" ? properties.firstname : "";
|
|
43
|
+
const last = typeof properties.lastname === "string" ? properties.lastname : "";
|
|
44
|
+
const company = typeof properties.company === "string" ? properties.company
|
|
45
|
+
: typeof after.associateCompanyName === "string" ? after.associateCompanyName : undefined;
|
|
46
|
+
const title = [first, last].filter(Boolean).join(" ") || company || operation.objectId || operation.id;
|
|
47
|
+
const role = typeof properties.jobtitle === "string" ? properties.jobtitle : undefined;
|
|
48
|
+
return { title, ...(role ? { subtitle: role } : {}), ...(company ? { company } : {}) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function planEffect(stored: StoredPlan): string {
|
|
52
|
+
const total = stored.plan.operations.length;
|
|
53
|
+
const approved = stored.approvedOperationIds.length;
|
|
54
|
+
if (stored.status === "applied") {
|
|
55
|
+
const results = stored.runs.at(-1)?.results ?? [];
|
|
56
|
+
const applied = results.filter((result) => result.status === "applied").length;
|
|
57
|
+
const skipped = results.filter((result) => result.status === "skipped").length;
|
|
58
|
+
return `Completed ${applied} operation${applied === 1 ? "" : "s"}${skipped ? `; ${skipped} skipped` : ""}. No further writes will run.`;
|
|
59
|
+
}
|
|
60
|
+
if (stored.status === "approved") return `Apply will execute ${approved} selected operation${approved === 1 ? "" : "s"}; ${total - approved} will not run.`;
|
|
61
|
+
if (stored.status === "rejected") return "Rejected. No provider writes can run.";
|
|
62
|
+
return `No provider writes can run until operations are approved (0 of ${total} selected).`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function printPlanCards(stored: StoredPlan, hostedUrl?: string) {
|
|
66
|
+
const p = paint(colorEnabled(process.stdout));
|
|
67
|
+
const total = stored.plan.operations.length;
|
|
68
|
+
const approved = new Set(stored.approvedOperationIds);
|
|
69
|
+
const width = Math.max(56, Math.min(100, (process.stdout.columns ?? 100) - 4));
|
|
70
|
+
const fit = (value: string) => truncateToWidth(value, width);
|
|
71
|
+
const summary = [
|
|
72
|
+
fit(stored.plan.title),
|
|
73
|
+
`Status ${stored.status.toUpperCase().replaceAll("_", " ")}`,
|
|
74
|
+
`Selection ${approved.size} of ${total} operation${total === 1 ? "" : "s"} approved`,
|
|
75
|
+
`Runs ${stored.runs.length}`,
|
|
76
|
+
fit(`Effect ${planEffect(stored)}`),
|
|
77
|
+
];
|
|
78
|
+
console.log(box(summary, p, "Plan").join("\n"));
|
|
79
|
+
if (hostedUrl) console.log(`Hosted: ${hostedUrl}`);
|
|
80
|
+
console.log("\nOperations");
|
|
81
|
+
let operationIndex = 0;
|
|
82
|
+
const latestResults = new Map((stored.runs.at(-1)?.results ?? []).map((result) => [result.operationId, result.status]));
|
|
83
|
+
for (const operation of stored.plan.operations) {
|
|
84
|
+
const selected = approved.has(operation.id);
|
|
85
|
+
const resultStatus = latestResults.get(operation.id);
|
|
86
|
+
const headline = recordHeadline(operation);
|
|
87
|
+
const action = operation.operation === "create_record"
|
|
88
|
+
? `Create ${operation.objectType}${headline.company ? ` and resolve/link ${headline.company}` : ""}`
|
|
89
|
+
: `${operation.operation.replaceAll("_", " ")} ${operation.objectType}`;
|
|
90
|
+
const lines = [
|
|
91
|
+
fit(headline.title),
|
|
92
|
+
...(headline.subtitle || headline.company
|
|
93
|
+
? [fit([headline.subtitle, headline.company].filter(Boolean).join(" · "))]
|
|
94
|
+
: []),
|
|
95
|
+
fit(`Action ${action}`),
|
|
96
|
+
`Operation ${operation.id}`,
|
|
97
|
+
];
|
|
98
|
+
if (operationIndex++ > 0) console.log("");
|
|
99
|
+
const state = resultStatus === "applied" ? "✓ APPLIED"
|
|
100
|
+
: resultStatus && selected ? `! ${resultStatus.toUpperCase()}`
|
|
101
|
+
: selected ? "✓ APPROVED" : stored.status === "applied" ? "○ EXCLUDED" : "○ NOT APPROVED";
|
|
102
|
+
console.log(box(lines, p, state).join("\n"));
|
|
103
|
+
}
|
|
104
|
+
if (stored.status === "approved") {
|
|
105
|
+
console.log(`\nNext: fullstackgtm apply --plan-id ${stored.plan.id} --provider <hubspot|salesforce>`);
|
|
106
|
+
}
|
|
107
|
+
console.log(`Details: fullstackgtm plans show ${stored.plan.id} --verbose`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function printApplyCards(run: PatchPlanRun, plan: PatchPlan, approvedOperationIds: string[], planIdStored: boolean) {
|
|
111
|
+
const p = paint(colorEnabled(process.stdout));
|
|
112
|
+
const approved = new Set(approvedOperationIds);
|
|
113
|
+
const operations = new Map(plan.operations.map((operation) => [operation.id, operation]));
|
|
114
|
+
const counts = { applied: 0, skipped: 0, failed: 0, conflict: 0 };
|
|
115
|
+
for (const result of run.results) counts[result.status] += 1;
|
|
116
|
+
const elapsed = Math.max(0, Date.parse(run.finishedAt) - Date.parse(run.startedAt));
|
|
117
|
+
const summary = [
|
|
118
|
+
`Status ${run.status.toUpperCase()}`,
|
|
119
|
+
`Provider ${run.provider}`,
|
|
120
|
+
`Outcome ${counts.applied} applied · ${run.results.length - approved.size} excluded · ${counts.failed + counts.conflict} failed/conflicted`,
|
|
121
|
+
`Duration ${formatDuration(elapsed)}`,
|
|
122
|
+
...(planIdStored ? ["Receipt recorded locally; hosted reconciliation requested"] : []),
|
|
123
|
+
];
|
|
124
|
+
console.log(box(summary, p, "Apply result").join("\n"));
|
|
125
|
+
console.log("\nOperations");
|
|
126
|
+
let index = 0;
|
|
127
|
+
for (const result of run.results) {
|
|
128
|
+
const operation = operations.get(result.operationId);
|
|
129
|
+
if (!operation) continue;
|
|
130
|
+
const selected = approved.has(result.operationId);
|
|
131
|
+
const headline = recordHeadline(operation);
|
|
132
|
+
const state = !selected ? "○ EXCLUDED" : result.status === "applied" ? "✓ APPLIED" : `! ${result.status.toUpperCase()}`;
|
|
133
|
+
const lines = [
|
|
134
|
+
headline.title,
|
|
135
|
+
...(headline.subtitle || headline.company ? [[headline.subtitle, headline.company].filter(Boolean).join(" · ")] : []),
|
|
136
|
+
`Operation ${result.operationId}`,
|
|
137
|
+
...(result.detail ? [truncateToWidth(`Result ${result.detail}`, 100)] : []),
|
|
138
|
+
];
|
|
139
|
+
if (index++ > 0) console.log("");
|
|
140
|
+
console.log(box(lines, p, state).join("\n"));
|
|
141
|
+
}
|
|
142
|
+
if (planIdStored) console.log(`\nInspect: fullstackgtm plans show ${run.planId}`);
|
|
143
|
+
console.log("Details: rerun with --verbose; machine output: --json");
|
|
144
|
+
}
|
|
145
|
+
|
|
37
146
|
function tryLoadAcquireConfig(args: string[]): EnrichConfig | undefined {
|
|
38
147
|
try {
|
|
39
148
|
const path = resolve(process.cwd(), option(args, "--config") ?? ENRICH_CONFIG_FILE_NAME);
|
|
@@ -361,8 +470,10 @@ export async function apply(args: string[]) {
|
|
|
361
470
|
|
|
362
471
|
if (args.includes("--json")) {
|
|
363
472
|
console.log(JSON.stringify(run, null, 2));
|
|
364
|
-
} else {
|
|
473
|
+
} else if (args.includes("--verbose")) {
|
|
365
474
|
console.log(formatPatchPlanRun(run));
|
|
475
|
+
} else {
|
|
476
|
+
printApplyCards(run, plan, approvedOperationIds, Boolean(planId && store));
|
|
366
477
|
}
|
|
367
478
|
if (run.status === "failed") process.exitCode = 1;
|
|
368
479
|
}
|
|
@@ -484,14 +595,23 @@ export async function plansCommand(args: string[]) {
|
|
|
484
595
|
}
|
|
485
596
|
const p = paint(colorEnabled(process.stdout));
|
|
486
597
|
if (p.enabled) {
|
|
487
|
-
|
|
488
|
-
|
|
598
|
+
const statusCounts = new Map<string, number>();
|
|
599
|
+
for (const stored of plans) statusCounts.set(stored.status, (statusCounts.get(stored.status) ?? 0) + 1);
|
|
600
|
+
const awaiting = plans.filter((stored) => stored.status === "needs_approval").length;
|
|
601
|
+
const approved = plans.filter((stored) => stored.status === "approved").length;
|
|
602
|
+
console.log(box([
|
|
603
|
+
`${plans.length} plan${plans.length === 1 ? "" : "s"} · ${awaiting} awaiting approval · ${approved} ready to apply`,
|
|
604
|
+
[...statusCounts.entries()].map(([name, count]) => `${name.replaceAll("_", " ")}: ${count}`).join(" · "),
|
|
605
|
+
], p, "Change queue").join("\n"));
|
|
606
|
+
console.log("");
|
|
607
|
+
// Interactive terminals get an aligned decision table; the plain
|
|
608
|
+
// per-line format below remains stable for pipes and scripts.
|
|
489
609
|
const rows = plans.map((stored) => [
|
|
490
|
-
stored.plan.id,
|
|
491
610
|
stored.status,
|
|
492
|
-
|
|
611
|
+
stored.plan.title,
|
|
612
|
+
`${stored.approvedOperationIds.length}/${stored.plan.operations.length} selected`,
|
|
493
613
|
`${stored.runs.length} run${stored.runs.length === 1 ? "" : "s"}`,
|
|
494
|
-
stored.plan.
|
|
614
|
+
stored.plan.id,
|
|
495
615
|
]);
|
|
496
616
|
// Long summaries would wrap and break the table's alignment. The summary
|
|
497
617
|
// is the LAST column: cap it to what's left of the terminal width after
|
|
@@ -499,19 +619,26 @@ export async function plansCommand(args: string[]) {
|
|
|
499
619
|
// terminals still show something useful).
|
|
500
620
|
const columns = process.stdout.columns ?? 80;
|
|
501
621
|
const fixedWidth =
|
|
502
|
-
[0,
|
|
622
|
+
[0, 2, 3, 4].reduce(
|
|
503
623
|
(sum, index) => sum + Math.max(...rows.map((row) => row[index].length)),
|
|
504
624
|
0,
|
|
505
625
|
) + 2 * 4;
|
|
506
|
-
const
|
|
507
|
-
for (const row of rows) row[
|
|
626
|
+
const titleWidth = Math.max(24, columns - fixedWidth);
|
|
627
|
+
for (const row of rows) row[1] = truncateToWidth(row[1], titleWidth);
|
|
508
628
|
const statusPainter = (cell: string) => {
|
|
509
629
|
const painted = planStatusWord(cell.trimEnd(), p);
|
|
510
630
|
return painted + cell.slice(cell.trimEnd().length);
|
|
511
631
|
};
|
|
512
|
-
for (const line of table(rows, [
|
|
632
|
+
for (const line of table(rows, [statusPainter, null, p.dim, p.dim, p.dim])) {
|
|
513
633
|
console.log(line);
|
|
514
634
|
}
|
|
635
|
+
const next = plans.find((stored) => stored.status === "approved") ?? plans.find((stored) => stored.status === "needs_approval");
|
|
636
|
+
if (next) {
|
|
637
|
+
const command = next.status === "approved"
|
|
638
|
+
? `fullstackgtm apply --plan-id ${next.plan.id} --provider <name>`
|
|
639
|
+
: `fullstackgtm plans show ${next.plan.id}`;
|
|
640
|
+
console.log(`\nNext: ${command}`);
|
|
641
|
+
}
|
|
515
642
|
return;
|
|
516
643
|
}
|
|
517
644
|
for (const stored of plans) {
|
|
@@ -538,22 +665,21 @@ export async function plansCommand(args: string[]) {
|
|
|
538
665
|
console.log(JSON.stringify(stored, null, 2));
|
|
539
666
|
return;
|
|
540
667
|
}
|
|
541
|
-
const showPaint = paint(colorEnabled(process.stdout));
|
|
542
|
-
console.log(`Status: ${planStatusWord(stored.status, showPaint)}`);
|
|
543
|
-
console.log(`Approved operations: ${stored.approvedOperationIds.join(", ") || "none"}`);
|
|
544
|
-
console.log(`Runs: ${stored.runs.length}`);
|
|
545
668
|
const mirror = await uploadHostedPatchPlan(stored);
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
if (stored.applyAttempts?.length) {
|
|
669
|
+
printPlanCards(stored, mirror.status === "saved" ? mirror.state.url : undefined);
|
|
670
|
+
if (mirror.status === "unavailable" || mirror.status === "conflict") console.log(`Hosted sync pending: ${mirror.reason}`);
|
|
671
|
+
if (rest.includes("--verbose") && stored.applyAttempts?.length) {
|
|
549
672
|
console.log("Apply attempts:");
|
|
550
673
|
for (const attempt of stored.applyAttempts) {
|
|
551
674
|
console.log(` ${attempt.id} ${attempt.status} ${attempt.provider} via ${attempt.source} ${attempt.claimedAt}`);
|
|
552
675
|
if (attempt.note) console.log(` ${attempt.note}`);
|
|
553
676
|
}
|
|
554
677
|
}
|
|
555
|
-
|
|
556
|
-
|
|
678
|
+
if (rest.includes("--verbose")) {
|
|
679
|
+
const showPaint = paint(colorEnabled(process.stdout));
|
|
680
|
+
console.log("\nFull plan document\n");
|
|
681
|
+
console.log(stylizePlanMarkdown(patchPlanToMarkdown(stored.plan), showPaint));
|
|
682
|
+
}
|
|
557
683
|
return;
|
|
558
684
|
}
|
|
559
685
|
|
|
@@ -654,16 +780,35 @@ export async function plansCommand(args: string[]) {
|
|
|
654
780
|
if (subcommand === "sync") {
|
|
655
781
|
const plans = await store.list();
|
|
656
782
|
let updated = 0;
|
|
783
|
+
let completed = 0;
|
|
657
784
|
const warnings: string[] = [];
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
785
|
+
const status = createStatusLine();
|
|
786
|
+
status.set(`Synchronizing plan replicas 0/${plans.length}`);
|
|
787
|
+
// A workspace can accumulate hundreds of local plans. Reconcile with a
|
|
788
|
+
// small worker pool so one network round-trip per plan does not turn into
|
|
789
|
+
// a minutes-long silent serial wait, without stampeding the hosted API.
|
|
790
|
+
let next = 0;
|
|
791
|
+
const worker = async () => {
|
|
792
|
+
while (next < plans.length) {
|
|
793
|
+
const stored = plans[next++];
|
|
794
|
+
const result = await reconcileHostedPatchPlan(store, stored);
|
|
795
|
+
if (result.status === "updated") updated += 1;
|
|
796
|
+
else if (result.status === "conflict" || result.status === "unavailable") warnings.push(`${stored.plan.id}: ${result.reason}`);
|
|
797
|
+
// Historical local-only plans have no remote row to receive a receipt.
|
|
798
|
+
// `backfill runs` is the explicit import path for that old history.
|
|
799
|
+
if (stored.status === "applied" && result.status !== "missing" && (stored.applyAttempts?.length ?? 0) > 0) {
|
|
800
|
+
const hostedClaimId = [...(stored.applyAttempts ?? [])].reverse().find((attempt) => attempt.hostedClaimId)?.hostedClaimId;
|
|
801
|
+
const pushed = await reportHostedPlanLifecycle(stored, { claimId: hostedClaimId });
|
|
802
|
+
if (pushed.status === "conflict" || pushed.status === "unavailable") warnings.push(`${stored.plan.id} receipt: ${pushed.reason}`);
|
|
803
|
+
}
|
|
804
|
+
completed += 1;
|
|
805
|
+
status.set(`Synchronizing plan replicas ${completed}/${plans.length}`);
|
|
666
806
|
}
|
|
807
|
+
};
|
|
808
|
+
try {
|
|
809
|
+
await Promise.all(Array.from({ length: Math.min(6, plans.length) }, () => worker()));
|
|
810
|
+
} finally {
|
|
811
|
+
status.done();
|
|
667
812
|
}
|
|
668
813
|
console.log(`Plan replicas synchronized: ${updated} updated, ${plans.length - updated} unchanged.`);
|
|
669
814
|
for (const warning of warnings) console.error(` ${warning}`);
|
package/src/cli/schedule.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { computeMissedFirings, createFileScheduleRunStore, createFileScheduleSto
|
|
|
9
9
|
import { runCli } from "../cli.ts";
|
|
10
10
|
import { isOptionValue, numericOption, option } from "./shared.ts";
|
|
11
11
|
import { unknownSubcommandError } from "./suggest.ts";
|
|
12
|
+
import { colorEnabled, paint, table } from "./ui.ts";
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -120,12 +121,19 @@ trigger: manual. status shows next firing and surfaces missed firings
|
|
|
120
121
|
console.log('No schedules. Add one with `fullstackgtm schedule add "<command>" --cron "<expr>"`.');
|
|
121
122
|
return;
|
|
122
123
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
|
|
124
|
+
const p = paint(colorEnabled(process.stdout));
|
|
125
|
+
const verbose = rest.includes("--verbose");
|
|
126
|
+
const rows = [
|
|
127
|
+
["STATE", "SCHEDULE", "NEXT", ...(verbose ? ["CRON", "COMMAND"] : [])],
|
|
128
|
+
...withNext.map((entry) => [
|
|
129
|
+
entry.enabled ? "on" : "off",
|
|
130
|
+
`${entry.label} · ${entry.id}`,
|
|
131
|
+
entry.nextFiring ?? "—",
|
|
132
|
+
...(verbose ? [entry.cron, entry.argv.join(" ")] : []),
|
|
133
|
+
]),
|
|
134
|
+
];
|
|
135
|
+
console.log(table(rows, [p.dim, null, null]).join("\n"));
|
|
136
|
+
console.log("\nDeclarative only · run `fullstackgtm schedule install` after changes.");
|
|
129
137
|
return;
|
|
130
138
|
}
|
|
131
139
|
|
|
@@ -272,21 +280,19 @@ trigger: manual. status shows next firing and surfaces missed firings
|
|
|
272
280
|
const last = entry.lastRun as { firedAt: string; trigger: string; exitCode: number; noopReason?: string; artifacts: { planIds: string[]; runLabels: string[] } } | null;
|
|
273
281
|
const streak = entry.streak as { outcome: string; length: number } | null;
|
|
274
282
|
const missed = entry.missedFirings as string[];
|
|
275
|
-
|
|
276
|
-
|
|
283
|
+
const verbose = rest.includes("--verbose");
|
|
284
|
+
const outcome = !last ? "never run" : last.exitCode === 0 ? (last.noopReason ? `no-op · ${last.noopReason}` : "healthy") : `failed · exit ${last.exitCode}`;
|
|
285
|
+
console.log(`${entry.enabled ? "●" : "○"} ${entry.label} · ${outcome}`);
|
|
286
|
+
console.log(` ${entry.id} · next ${entry.nextFiring ?? "— (disabled)"}`);
|
|
287
|
+
if (verbose) console.log(` ${(entry.argv as string[]).join(" ")} · cron ${entry.cron}`);
|
|
277
288
|
if (last) {
|
|
278
289
|
const artifacts = [
|
|
279
290
|
...last.artifacts.planIds.map((planId) => `plan ${planId}`),
|
|
280
291
|
...last.artifacts.runLabels.map((runLabel) => `run ${runLabel}`),
|
|
281
292
|
];
|
|
282
|
-
console.log(
|
|
283
|
-
` last: ${last.firedAt} (${last.trigger}) exit ${last.exitCode}` +
|
|
284
|
-
(last.noopReason ? ` — no-op: ${last.noopReason}` : "") +
|
|
285
|
-
(artifacts.length ? ` — ${artifacts.join(", ")}` : ""),
|
|
286
|
-
);
|
|
287
|
-
console.log(` streak: ${streak!.length} ${streak!.outcome}(s)`);
|
|
293
|
+
console.log(` last ${last.firedAt} · ${last.trigger} · ${streak!.length} ${streak!.outcome}${streak!.length === 1 ? "" : "s"}${artifacts.length ? ` · ${artifacts.join(", ")}` : ""}`);
|
|
288
294
|
} else {
|
|
289
|
-
console.log(" last
|
|
295
|
+
console.log(" last never fired");
|
|
290
296
|
}
|
|
291
297
|
if (missed.length > 0) {
|
|
292
298
|
console.log(
|
package/src/cli/signals.ts
CHANGED
|
@@ -27,6 +27,21 @@ function resolveSignalsConfig(args: string[]): SignalsConfig {
|
|
|
27
27
|
/** A watchlist entry: an account domain plus optional per-source board tokens. */
|
|
28
28
|
type WatchlistAccount = { domain: string; boards?: Partial<Record<AtsBoardSource, string>> };
|
|
29
29
|
|
|
30
|
+
function concise(value: string, max = 72): string {
|
|
31
|
+
const oneLine = value.replace(/\s+/g, " ").trim();
|
|
32
|
+
return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max - 1)}…`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderSignals(signals: Signal[]): string {
|
|
36
|
+
if (signals.length === 0) return "No signals matched.";
|
|
37
|
+
const lines = [`Fresh signals (${signals.length})`, ""];
|
|
38
|
+
for (const signal of signals) {
|
|
39
|
+
lines.push(`${signal.weight.toFixed(2).padStart(5)} ${signal.accountDomain} · ${signal.bucket}`);
|
|
40
|
+
lines.push(` ${concise(signal.trigger)}`);
|
|
41
|
+
}
|
|
42
|
+
return lines.join("\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
30
45
|
/**
|
|
31
46
|
* Resolve the watchlist of accounts to scan. Sources, in precedence order:
|
|
32
47
|
* - a --watchlist file (JSON array of {domain, boards?} or bare domain strings),
|
|
@@ -228,7 +243,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
|
|
|
228
243
|
const ranked = [...fresh].sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
|
|
229
244
|
|
|
230
245
|
// Ranked fresh signals to stdout; guidance to stderr.
|
|
231
|
-
console.log(JSON.stringify(ranked, null, 2));
|
|
246
|
+
console.log(rest.includes("--json") || rest.includes("--verbose") ? JSON.stringify(ranked, null, 2) : renderSignals(ranked));
|
|
232
247
|
console.error(
|
|
233
248
|
`Fetched ${fetched} candidate signal(s); ${fresh.length} fresh, ${deduped.length} deduped ` +
|
|
234
249
|
`(window ${config.dedupWindowDays}d). NO plan emitted — signals are read-only re: CRM.`,
|
|
@@ -275,7 +290,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
|
|
|
275
290
|
return true;
|
|
276
291
|
});
|
|
277
292
|
const ranked = filtered.sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
|
|
278
|
-
console.log(JSON.stringify(ranked, null, 2));
|
|
293
|
+
console.log(rest.includes("--json") || rest.includes("--verbose") ? JSON.stringify(ranked, null, 2) : renderSignals(ranked));
|
|
279
294
|
console.error(`${ranked.length} signal(s)${unjudgedOnly ? " (unjudged)" : ""}.`);
|
|
280
295
|
return;
|
|
281
296
|
}
|
|
@@ -305,7 +320,11 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
|
|
|
305
320
|
const outcomes = await store.listOutcomes();
|
|
306
321
|
const signalsById = new Map((await store.allSignals()).map((s) => [s.id, s]));
|
|
307
322
|
const weights = computeWeights(config, outcomes, signalsById);
|
|
308
|
-
|
|
323
|
+
if (rest.includes("--json") || rest.includes("--verbose")) {
|
|
324
|
+
console.log(JSON.stringify(weights, null, 2));
|
|
325
|
+
} else {
|
|
326
|
+
console.log(["Learned signal weights", "", ...SIGNAL_BUCKETS.map((bucket) => `${bucket.padEnd(10)} ${weights[bucket].toFixed(4)}`)].join("\n"));
|
|
327
|
+
}
|
|
309
328
|
if (rest.includes("--explain")) {
|
|
310
329
|
// Per-bucket config default vs learned + booked/total over credited signals.
|
|
311
330
|
const booked = new Map<SignalBucket, number>();
|
package/src/cli/tam.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { getCredential } from "../credentials.ts";
|
|
|
6
6
|
import { appendCoverage, computeCoverage, coverageCountsFromSnapshot, classifyCoverage, coverageToText, deriveAcvFromClosedWon, deriveBuyersPerAccount, estimateTam, loadTamModel, projectEta, readCoverageTimeline, saveTamModel, tamReportToMarkdown, type AcvBasis, type TamCrossCheck } from "../tam.ts";
|
|
7
7
|
import { probeExploriumBusinessCount } from "../connectors/prospectSources.ts";
|
|
8
8
|
import { theirStackCountCompanies, theirStackPullCost, theirStackSearchCompanies } from "../connectors/theirstack.ts";
|
|
9
|
+
import { clayApiKey } from "../connectors/clay.ts";
|
|
9
10
|
import { icpToExploriumBusinessFilters, icpToTheirStackFilters } from "../icp.ts";
|
|
10
11
|
import { scheduleCommand } from "./schedule.ts";
|
|
11
12
|
import { loadIcp, numericOption, option, readSnapshot, saveRequested } from "./shared.ts";
|
|
@@ -14,7 +15,8 @@ import { unknownSubcommandError } from "./suggest.ts";
|
|
|
14
15
|
|
|
15
16
|
/** Best-effort enrich-config load for apply-time acquire-budget enforcement. */
|
|
16
17
|
/** Provider API key: env override first, then the credential store (`login`). */
|
|
17
|
-
export function providerKey(provider: "explorium" | "pipe0" | "heyreach" | "theirstack"): string {
|
|
18
|
+
export function providerKey(provider: "explorium" | "pipe0" | "clay" | "heyreach" | "theirstack"): string {
|
|
19
|
+
if (provider === "clay") return clayApiKey();
|
|
18
20
|
const envName =
|
|
19
21
|
provider === "explorium"
|
|
20
22
|
? "EXPLORIUM_API_KEY"
|
|
@@ -44,7 +46,7 @@ export async function tamCommand(args: string[]) {
|
|
|
44
46
|
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]
|
|
45
47
|
fullstackgtm tam status [--name <n>] <source options> [--save] [--json]
|
|
46
48
|
fullstackgtm tam report [--name <n>] [--out <path>]
|
|
47
|
-
fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--label <l>]
|
|
49
|
+
fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--label <l>]
|
|
48
50
|
|
|
49
51
|
Estimate the reachable market FROM your ICP: a real account count × a confirmed
|
|
50
52
|
ANNUAL ACV (--acv <annual-usd>, or --acv-from-crm --deal-period monthly|quarterly|
|
|
@@ -275,8 +277,15 @@ RevOps universe, with real names. --source explorium is a firmographic count onl
|
|
|
275
277
|
if (out) {
|
|
276
278
|
writeFileSync(resolve(process.cwd(), out), md);
|
|
277
279
|
console.log(`Wrote ${out}.`);
|
|
278
|
-
} else {
|
|
280
|
+
} else if (rest.includes("--verbose")) {
|
|
279
281
|
console.log(md);
|
|
282
|
+
} else if (timeline.length > 0) {
|
|
283
|
+
console.log(coverageToText(model, timeline.at(-1)!, eta));
|
|
284
|
+
console.log("\nUse --verbose for assumptions, cross-checks, and the full coverage history.");
|
|
285
|
+
} else {
|
|
286
|
+
console.log(`TAM "${model.name}" · ${model.universe.accounts.toLocaleString()} accounts · ${model.universe.contacts.toLocaleString()} buyers · $${Math.round(model.tamUsd).toLocaleString()}`);
|
|
287
|
+
console.log(`ICP ${model.icpName} · ${model.acv.basis}-basis ACV $${Math.round(model.acv.valueUsd).toLocaleString()} (${model.acv.source})`);
|
|
288
|
+
console.log("No coverage readings yet. Run `fullstackgtm tam status --save` to establish one; use --verbose for the full assumptions report.");
|
|
280
289
|
}
|
|
281
290
|
return;
|
|
282
291
|
}
|
package/src/cli/ui.ts
CHANGED
|
@@ -324,8 +324,12 @@ export function createChecklist(
|
|
|
324
324
|
const label = entry.state === "pending" ? p.dim(item.label.padEnd(labelWidth)) : item.label.padEnd(labelWidth);
|
|
325
325
|
return ` ${glyph} ${label}${note}`;
|
|
326
326
|
});
|
|
327
|
-
|
|
328
|
-
|
|
327
|
+
// Keep the cursor on the board's last row while it is live. A trailing
|
|
328
|
+
// newline on every repaint can scroll the old top row into permanent
|
|
329
|
+
// history when the board sits at the bottom of the terminal, producing
|
|
330
|
+
// apparent duplicate/errored frames in terminal captures.
|
|
331
|
+
const up = painted > 1 ? `\u001b[${painted - 1}A` : "";
|
|
332
|
+
stream.write(`${up}${lines.map((line) => `\u001b[2K${line}`).join("\n")}`);
|
|
329
333
|
painted = lines.length;
|
|
330
334
|
};
|
|
331
335
|
|
|
@@ -353,14 +357,19 @@ export function createChecklist(
|
|
|
353
357
|
timer = null;
|
|
354
358
|
if (options.persist) {
|
|
355
359
|
// The caller's final state update already painted the completed board.
|
|
356
|
-
//
|
|
357
|
-
|
|
360
|
+
// Advance exactly once so subsequent output begins below it.
|
|
361
|
+
if (painted > 0) stream.write("\n");
|
|
358
362
|
painted = 0;
|
|
359
363
|
return;
|
|
360
364
|
}
|
|
361
365
|
if (painted > 0) {
|
|
362
|
-
// Erase
|
|
363
|
-
|
|
366
|
+
// Erase in place without linefeeds, which could themselves scroll.
|
|
367
|
+
const up = painted > 1 ? `\u001b[${painted - 1}A` : "";
|
|
368
|
+
const clear = Array.from({ length: painted }, (_, index) =>
|
|
369
|
+
`\u001b[2K${index < painted - 1 ? "\u001b[1B" : ""}`,
|
|
370
|
+
).join("");
|
|
371
|
+
const restore = painted > 1 ? `\u001b[${painted - 1}A` : "";
|
|
372
|
+
stream.write(`${up}${clear}${restore}`);
|
|
364
373
|
painted = 0;
|
|
365
374
|
}
|
|
366
375
|
},
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { getCredential } from "../credentials.ts";
|
|
2
|
+
import { ProviderHttpError } from "../providerError.ts";
|
|
3
|
+
import type { Prospect } from "./prospectSources.ts";
|
|
4
|
+
|
|
5
|
+
export const CLAY_PUBLIC_API_BASE = "https://api.clay.com/public/v0";
|
|
6
|
+
|
|
7
|
+
export type ClayKeyValidation = {
|
|
8
|
+
ok: boolean;
|
|
9
|
+
detail: string;
|
|
10
|
+
workspaceId?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ClaySearchSourceType = "people" | "companies";
|
|
14
|
+
|
|
15
|
+
export type ClayPeopleSearchPage = {
|
|
16
|
+
prospects: Prospect[];
|
|
17
|
+
hasMore: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function clayHeaders(apiKey: string): Record<string, string> {
|
|
21
|
+
return { "clay-api-key": apiKey, Accept: "application/json", "Content-Type": "application/json" };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Create a forward-only Clay search iterator. This call does not fetch a page. */
|
|
25
|
+
export async function createClaySearch(opts: {
|
|
26
|
+
apiKey: string;
|
|
27
|
+
sourceType: ClaySearchSourceType;
|
|
28
|
+
filters: Record<string, unknown>;
|
|
29
|
+
fetchImpl?: typeof fetch;
|
|
30
|
+
apiBaseUrl?: string;
|
|
31
|
+
}): Promise<string> {
|
|
32
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
33
|
+
const base = (opts.apiBaseUrl ?? CLAY_PUBLIC_API_BASE).replace(/\/$/, "");
|
|
34
|
+
const response = await fetchImpl(`${base}/search/filters-mode`, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: clayHeaders(opts.apiKey),
|
|
37
|
+
body: JSON.stringify({ source_type: opts.sourceType, filters: opts.filters }),
|
|
38
|
+
});
|
|
39
|
+
if (!response.ok) throw new ProviderHttpError("Clay", "create search", response.status);
|
|
40
|
+
const body = await response.json() as { search_id?: unknown; searchId?: unknown };
|
|
41
|
+
const searchId = body.search_id ?? body.searchId;
|
|
42
|
+
if (typeof searchId !== "string" || !searchId) throw new Error("Clay create search returned no search_id.");
|
|
43
|
+
return searchId;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Consume the next people page from a Clay search iterator. */
|
|
47
|
+
export async function runClayPeopleSearchPage(opts: {
|
|
48
|
+
apiKey: string;
|
|
49
|
+
searchId: string;
|
|
50
|
+
limit?: number;
|
|
51
|
+
fetchImpl?: typeof fetch;
|
|
52
|
+
apiBaseUrl?: string;
|
|
53
|
+
}): Promise<ClayPeopleSearchPage> {
|
|
54
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
55
|
+
const base = (opts.apiBaseUrl ?? CLAY_PUBLIC_API_BASE).replace(/\/$/, "");
|
|
56
|
+
const limit = Math.max(1, Math.min(opts.limit ?? 25, 100));
|
|
57
|
+
const response = await fetchImpl(`${base}/search/filters-mode/${encodeURIComponent(opts.searchId)}/run`, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: clayHeaders(opts.apiKey),
|
|
60
|
+
body: JSON.stringify({ limit }),
|
|
61
|
+
});
|
|
62
|
+
if (!response.ok) throw new ProviderHttpError("Clay", "run people search", response.status);
|
|
63
|
+
const body = await response.json() as { data?: unknown; has_more?: unknown; hasMore?: unknown };
|
|
64
|
+
if (!Array.isArray(body.data)) throw new Error("Clay people search returned an invalid data array.");
|
|
65
|
+
const hasMore = body.has_more ?? body.hasMore;
|
|
66
|
+
if (typeof hasMore !== "boolean") throw new Error("Clay people search returned no has_more flag.");
|
|
67
|
+
return { prospects: body.data.map(normalizeClayPerson), hasMore };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function normalizeClayPerson(value: unknown): Prospect {
|
|
71
|
+
const row = value && typeof value === "object" ? value as Record<string, unknown> : {};
|
|
72
|
+
const location = row.structured_location && typeof row.structured_location === "object"
|
|
73
|
+
? row.structured_location as Record<string, unknown>
|
|
74
|
+
: {};
|
|
75
|
+
const fullName = stringValue(row.name);
|
|
76
|
+
return {
|
|
77
|
+
firstName: stringValue(row.first_name),
|
|
78
|
+
lastName: stringValue(row.last_name),
|
|
79
|
+
fullName,
|
|
80
|
+
jobTitle: stringValue(row.latest_experience_title),
|
|
81
|
+
companyName: stringValue(row.latest_experience_company),
|
|
82
|
+
companyDomain: bareDomain(stringValue(row.domain)),
|
|
83
|
+
linkedin: normalizeLinkedin(stringValue(row.url)),
|
|
84
|
+
headline: undefined,
|
|
85
|
+
sourceId: normalizeLinkedin(stringValue(row.url)) ?? fullName,
|
|
86
|
+
location: {
|
|
87
|
+
city: stringValue(location.city),
|
|
88
|
+
state: stringValue(location.state),
|
|
89
|
+
region: stringValue(location.region),
|
|
90
|
+
country: stringValue(location.country),
|
|
91
|
+
countryCode: stringValue(location.country_iso),
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function stringValue(value: unknown): string | undefined {
|
|
97
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function bareDomain(value: string | undefined): string | undefined {
|
|
101
|
+
return value?.replace(/^https?:\/\//i, "").replace(/^www\./i, "").replace(/\/.*$/, "").toLowerCase() || undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function normalizeLinkedin(value: string | undefined): string | undefined {
|
|
105
|
+
if (!value) return undefined;
|
|
106
|
+
const normalized = value.startsWith("http") ? value : `https://${value.replace(/^\/+/, "")}`;
|
|
107
|
+
return normalized.replace(/\/$/, "");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Validate a Clay Public API key without creating a search or spending enrichment credits. */
|
|
111
|
+
export async function validateClayApiKey(
|
|
112
|
+
apiKey: string,
|
|
113
|
+
fetchImpl: typeof fetch = fetch,
|
|
114
|
+
apiBaseUrl = CLAY_PUBLIC_API_BASE,
|
|
115
|
+
): Promise<ClayKeyValidation> {
|
|
116
|
+
let response: Response;
|
|
117
|
+
try {
|
|
118
|
+
response = await fetchImpl(`${apiBaseUrl.replace(/\/$/, "")}/me`, {
|
|
119
|
+
headers: { "clay-api-key": apiKey, Accept: "application/json" },
|
|
120
|
+
});
|
|
121
|
+
} catch (error) {
|
|
122
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
123
|
+
return { ok: false, detail: `Cannot reach the Clay Public API: ${detail}` };
|
|
124
|
+
}
|
|
125
|
+
if (!response.ok) return { ok: false, detail: `Clay rejected the key: HTTP ${response.status}` };
|
|
126
|
+
let body: unknown;
|
|
127
|
+
try {
|
|
128
|
+
body = await response.json();
|
|
129
|
+
} catch {
|
|
130
|
+
return { ok: false, detail: "Clay accepted the request but returned an invalid JSON response." };
|
|
131
|
+
}
|
|
132
|
+
const record = body && typeof body === "object" ? body as Record<string, unknown> : {};
|
|
133
|
+
const workspace = record.workspace && typeof record.workspace === "object"
|
|
134
|
+
? record.workspace as Record<string, unknown>
|
|
135
|
+
: undefined;
|
|
136
|
+
const rawWorkspaceId = record.workspace_id ?? record.workspaceId ?? workspace?.id;
|
|
137
|
+
const workspaceId = typeof rawWorkspaceId === "string" || typeof rawWorkspaceId === "number"
|
|
138
|
+
? String(rawWorkspaceId)
|
|
139
|
+
: undefined;
|
|
140
|
+
return {
|
|
141
|
+
ok: true,
|
|
142
|
+
detail: workspaceId ? `Key accepted by Clay workspace ${workspaceId}.` : "Key accepted by the Clay Public API.",
|
|
143
|
+
workspaceId,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Resolve Clay credentials using the standard env -> profile-scoped store ladder. */
|
|
148
|
+
export function clayApiKey(env: Record<string, string | undefined> = process.env): string {
|
|
149
|
+
const key = env.CLAY_API_KEY ?? getCredential("clay")?.accessToken;
|
|
150
|
+
if (key) return key;
|
|
151
|
+
throw new Error(
|
|
152
|
+
'No Clay credentials. Run `clay api-keys create --name "fullstackgtm"`, then pipe the key to ' +
|
|
153
|
+
'`fullstackgtm login clay`, or set CLAY_API_KEY.',
|
|
154
|
+
);
|
|
155
|
+
}
|