fullstackgtm 0.45.0 → 0.47.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 +115 -1
- package/INSTALL_FOR_AGENTS.md +13 -11
- package/README.md +27 -18
- package/dist/backfill.d.ts +86 -0
- package/dist/backfill.js +251 -0
- package/dist/bin.js +4 -1
- package/dist/cli/auth.d.ts +2 -0
- package/dist/cli/auth.js +119 -12
- package/dist/cli/backfill.d.ts +1 -0
- package/dist/cli/backfill.js +125 -0
- package/dist/cli/backfillRuns.d.ts +1 -0
- package/dist/cli/backfillRuns.js +187 -0
- package/dist/cli/call.js +23 -9
- package/dist/cli/enrich.js +28 -10
- package/dist/cli/fix.js +9 -7
- package/dist/cli/help.js +60 -23
- package/dist/cli/plans.js +13 -11
- package/dist/cli/shared.d.ts +4 -3
- package/dist/cli/shared.js +31 -20
- package/dist/cli/ui.d.ts +21 -0
- package/dist/cli/ui.js +53 -1
- package/dist/cli.js +37 -41
- package/dist/connector.d.ts +8 -0
- package/dist/connector.js +104 -21
- package/dist/connectors/hubspot.d.ts +8 -1
- package/dist/connectors/hubspot.js +406 -13
- package/dist/connectors/hubspotAuth.js +5 -1
- package/dist/connectors/linkedin.js +14 -14
- package/dist/connectors/salesforce.d.ts +8 -1
- package/dist/connectors/salesforce.js +163 -2
- package/dist/connectors/stripe.d.ts +37 -0
- package/dist/connectors/stripe.js +103 -31
- package/dist/enrich.d.ts +7 -0
- package/dist/enrich.js +7 -0
- package/dist/health.d.ts +11 -69
- package/dist/health.js +4 -134
- package/dist/healthScore.d.ts +71 -0
- package/dist/healthScore.js +143 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/llm.d.ts +29 -0
- package/dist/llm.js +206 -0
- package/dist/market.d.ts +39 -1
- package/dist/market.js +116 -44
- package/dist/marketClassify.d.ts +9 -1
- package/dist/marketClassify.js +10 -1
- package/dist/marketTaxonomy.d.ts +6 -1
- package/dist/marketTaxonomy.js +4 -2
- package/dist/mcp-bin.js +29 -0
- package/dist/mcp.js +117 -4
- package/dist/progress.d.ts +96 -0
- package/dist/progress.js +142 -0
- package/dist/runReport.d.ts +24 -0
- package/dist/runReport.js +139 -4
- package/dist/types.d.ts +33 -1
- package/docs/api.md +4 -2
- package/docs/architecture.md +2 -0
- package/docs/linkedin-connector-spec.md +1 -1
- package/llms.txt +3 -3
- package/package.json +10 -3
- package/skills/fullstackgtm/SKILL.md +1 -0
- package/src/backfill.ts +340 -0
- package/src/bin.ts +5 -1
- package/src/cli/auth.ts +135 -15
- package/src/cli/backfill.ts +156 -0
- package/src/cli/backfillRuns.ts +198 -0
- package/src/cli/call.ts +26 -10
- package/src/cli/enrich.ts +33 -10
- package/src/cli/fix.ts +11 -10
- package/src/cli/help.ts +61 -23
- package/src/cli/plans.ts +15 -14
- package/src/cli/shared.ts +44 -27
- package/src/cli/ui.ts +72 -1
- package/src/cli.ts +38 -41
- package/src/connector.ts +110 -16
- package/src/connectors/hubspot.ts +423 -14
- package/src/connectors/hubspotAuth.ts +5 -1
- package/src/connectors/linkedin.ts +14 -14
- package/src/connectors/salesforce.ts +168 -3
- package/src/connectors/stripe.ts +154 -34
- package/src/enrich.ts +13 -0
- package/src/health.ts +14 -213
- package/src/healthScore.ts +223 -0
- package/src/index.ts +28 -1
- package/src/llm.ts +239 -0
- package/src/market.ts +157 -44
- package/src/marketClassify.ts +18 -1
- package/src/marketTaxonomy.ts +10 -2
- package/src/mcp-bin.ts +32 -0
- package/src/mcp.ts +140 -6
- package/src/progress.ts +197 -0
- package/src/runReport.ts +159 -4
- package/src/types.ts +35 -2
- package/docs/dx-punch-list.md +0 -87
- package/docs/roadmap-to-1.0.md +0 -211
package/src/backfill.ts
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backfill: Stripe paid invoices → closed-won CRM deals, through the normal
|
|
3
|
+
* governed plan/apply flow.
|
|
4
|
+
*
|
|
5
|
+
* Product assumption this encodes: billing (Stripe) is the revenue source
|
|
6
|
+
* of truth; the CRM should retroactively carry ONE closed-won deal per paid
|
|
7
|
+
* invoice (amount = invoice total, close date = paid date, associated to the
|
|
8
|
+
* customer's company, deduped by invoice id). This module only BUILDS the
|
|
9
|
+
* dry-run plan — nothing here touches a CRM. Writes happen exclusively via
|
|
10
|
+
* `apply` on operations approved through `plans approve`, where the HubSpot
|
|
11
|
+
* connector re-resolves each invoice id (resolve-first) and creates only on
|
|
12
|
+
* a confirmed miss.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
CanonicalAccount,
|
|
17
|
+
CanonicalGtmSnapshot,
|
|
18
|
+
CreateRecordPayload,
|
|
19
|
+
GtmEvidence,
|
|
20
|
+
PatchOperation,
|
|
21
|
+
PatchPlan,
|
|
22
|
+
} from "./types.ts";
|
|
23
|
+
import type { StripePaidInvoice } from "./connectors/stripe.ts";
|
|
24
|
+
import { normalizeDomain } from "./merge.ts";
|
|
25
|
+
|
|
26
|
+
// Mirrors stableHash in rules.ts (FNV-1a); duplicated to keep backfill.ts
|
|
27
|
+
// importable without pulling the audit engine (the enrich.ts precedent).
|
|
28
|
+
function fnv1a(value: string): string {
|
|
29
|
+
let hash = 0x811c9dc5;
|
|
30
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
31
|
+
hash ^= value.charCodeAt(i);
|
|
32
|
+
hash = Math.imul(hash, 0x01000193);
|
|
33
|
+
}
|
|
34
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_BACKFILL_MATCH_PROPERTY = "stripe_invoice_id";
|
|
38
|
+
|
|
39
|
+
// Freemail domains never become a company domain: stamping "gmail.com" on an
|
|
40
|
+
// account (or resolving a company BY gmail.com) would collapse unrelated
|
|
41
|
+
// customers onto one record. Mirrors FREE_MAIL in connectors/signalSources.ts
|
|
42
|
+
// (duplicated to keep backfill.ts importable without connector code, the
|
|
43
|
+
// fnv1a precedent above).
|
|
44
|
+
const FREE_MAIL = new Set([
|
|
45
|
+
"gmail.com",
|
|
46
|
+
"yahoo.com",
|
|
47
|
+
"hotmail.com",
|
|
48
|
+
"outlook.com",
|
|
49
|
+
"icloud.com",
|
|
50
|
+
"aol.com",
|
|
51
|
+
"proton.me",
|
|
52
|
+
"protonmail.com",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
export type StripeBackfillOptions = {
|
|
56
|
+
/** Pipeline id or case-insensitive label; absent = the portal's default pipeline. */
|
|
57
|
+
pipeline?: string;
|
|
58
|
+
/** Deal dedupe property stamped with the invoice id (default "stripe_invoice_id"). */
|
|
59
|
+
matchProperty?: string;
|
|
60
|
+
/** Source label recorded on payloads/evidence (default "stripe:invoices"). */
|
|
61
|
+
source?: string;
|
|
62
|
+
/**
|
|
63
|
+
* When a customer matches no CRM account, propose creating the account as
|
|
64
|
+
* part of this plan (default true). The account op is explicit and
|
|
65
|
+
* approval-gated like everything else; apply is resolve-first, so an
|
|
66
|
+
* account that appeared in the meantime is reused, not duplicated. False =
|
|
67
|
+
* the conservative original behavior: unmatched customers are reported
|
|
68
|
+
* only and their invoices produce no operations.
|
|
69
|
+
*/
|
|
70
|
+
createMissingAccounts?: boolean;
|
|
71
|
+
/** Injectable clock for deterministic tests. */
|
|
72
|
+
now?: () => Date;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** One invoice whose customer could not be matched to any CRM account. */
|
|
76
|
+
export type StripeBackfillUnmatched = {
|
|
77
|
+
invoiceId: string;
|
|
78
|
+
invoiceNumber?: string;
|
|
79
|
+
customerName?: string;
|
|
80
|
+
customerDomain?: string;
|
|
81
|
+
amountPaid: number;
|
|
82
|
+
currency?: string;
|
|
83
|
+
paidAt?: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** One account this plan proposes to create for an unmatched Stripe customer. */
|
|
87
|
+
export type StripeBackfillProposedAccount = {
|
|
88
|
+
name: string;
|
|
89
|
+
domain?: string;
|
|
90
|
+
/** Paid invoices from this customer that the plan attaches to the account. */
|
|
91
|
+
invoiceCount: number;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export type StripeBackfillCounts = {
|
|
95
|
+
invoices: number;
|
|
96
|
+
/** create_record deal ops emitted. */
|
|
97
|
+
planned: number;
|
|
98
|
+
/** create_record account ops emitted for unmatched customers. */
|
|
99
|
+
accountsProposed: number;
|
|
100
|
+
/** Customers with no CRM match AND no usable name/domain — reported only. */
|
|
101
|
+
unmatched: number;
|
|
102
|
+
/** Plan-time prefilter: a deal with the same name already exists. */
|
|
103
|
+
alreadyInCrm: number;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export type StripeBackfillResult = {
|
|
107
|
+
plan: PatchPlan;
|
|
108
|
+
counts: StripeBackfillCounts;
|
|
109
|
+
unmatched: StripeBackfillUnmatched[];
|
|
110
|
+
proposedAccounts: StripeBackfillProposedAccount[];
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** `Invoice <number || id> — <customer name>` (the plan-time dedupe name). */
|
|
114
|
+
function backfillDealName(invoice: StripePaidInvoice, companyName: string): string {
|
|
115
|
+
return `Invoice ${invoice.number ?? invoice.id} — ${invoice.customerName ?? companyName}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Match each paid invoice's customer to a snapshot account (normalized domain
|
|
120
|
+
* first — the accurate key — then exact case-insensitive name) and emit one
|
|
121
|
+
* create_record deal op per invoice that has no deal yet.
|
|
122
|
+
*
|
|
123
|
+
* Unmatched customers (default): the plan ALSO proposes creating the account —
|
|
124
|
+
* one explicit, approval-gated account create_record op per new customer,
|
|
125
|
+
* with the customer's invoices attached as deals. Freemail billing domains
|
|
126
|
+
* (gmail/outlook/…) are never used as a company domain. A customer with no
|
|
127
|
+
* usable name or domain stays report-only. `createMissingAccounts: false`
|
|
128
|
+
* restores the original conservative behavior (unmatched = reported, no ops).
|
|
129
|
+
*
|
|
130
|
+
* The by-deal-name prefilter here is best-effort plan hygiene; the
|
|
131
|
+
* AUTHORITATIVE duplicate gate is the connector's apply-time resolve-first
|
|
132
|
+
* search on the invoice-id property (and resolve-by-domain/name for accounts).
|
|
133
|
+
*/
|
|
134
|
+
export function buildStripeBackfillPlan(
|
|
135
|
+
invoices: StripePaidInvoice[],
|
|
136
|
+
snapshot: CanonicalGtmSnapshot,
|
|
137
|
+
opts: StripeBackfillOptions = {},
|
|
138
|
+
): StripeBackfillResult {
|
|
139
|
+
const nowIso = (opts.now ?? (() => new Date()))().toISOString();
|
|
140
|
+
const matchProperty = opts.matchProperty ?? DEFAULT_BACKFILL_MATCH_PROPERTY;
|
|
141
|
+
const source = opts.source ?? "stripe:invoices";
|
|
142
|
+
|
|
143
|
+
// Snapshot lookups. First occurrence wins on a duplicate key — the
|
|
144
|
+
// connector's own resolve/associate step handles ambiguity at apply time.
|
|
145
|
+
const accountsByDomain = new Map<string, CanonicalAccount>();
|
|
146
|
+
const accountsByName = new Map<string, CanonicalAccount>();
|
|
147
|
+
for (const account of snapshot.accounts ?? []) {
|
|
148
|
+
const domain = normalizeDomain(account.domain);
|
|
149
|
+
if (domain && !accountsByDomain.has(domain)) accountsByDomain.set(domain, account);
|
|
150
|
+
const name = account.name?.trim().toLowerCase();
|
|
151
|
+
if (name && !accountsByName.has(name)) accountsByName.set(name, account);
|
|
152
|
+
}
|
|
153
|
+
const existingDealNames = new Set(
|
|
154
|
+
(snapshot.deals ?? [])
|
|
155
|
+
.map((deal) => deal.name?.trim().toLowerCase())
|
|
156
|
+
.filter((name): name is string => Boolean(name)),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const createMissingAccounts = opts.createMissingAccounts ?? true;
|
|
160
|
+
const operations: PatchOperation[] = [];
|
|
161
|
+
const evidence: GtmEvidence[] = [];
|
|
162
|
+
const unmatched: StripeBackfillUnmatched[] = [];
|
|
163
|
+
const proposedByKey = new Map<string, StripeBackfillProposedAccount>();
|
|
164
|
+
const counts: StripeBackfillCounts = {
|
|
165
|
+
invoices: invoices.length,
|
|
166
|
+
planned: 0,
|
|
167
|
+
accountsProposed: 0,
|
|
168
|
+
unmatched: 0,
|
|
169
|
+
alreadyInCrm: 0,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
for (const invoice of invoices) {
|
|
173
|
+
// Match the customer to a CRM account: domain first, then exact name.
|
|
174
|
+
const domain = normalizeDomain(invoice.customerDomain);
|
|
175
|
+
let account = domain ? accountsByDomain.get(domain) : undefined;
|
|
176
|
+
if (!account && invoice.customerName) {
|
|
177
|
+
account = accountsByName.get(invoice.customerName.trim().toLowerCase());
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The company the deal will land on: the matched account, or (default) a
|
|
181
|
+
// new account this plan proposes for the unmatched customer.
|
|
182
|
+
let companyName: string | undefined = account?.name;
|
|
183
|
+
let companyDomain = normalizeDomain(account?.domain);
|
|
184
|
+
let accountIsNew = false;
|
|
185
|
+
if (!account && createMissingAccounts) {
|
|
186
|
+
const usableDomain = domain && !FREE_MAIL.has(domain) ? domain : undefined;
|
|
187
|
+
const name = invoice.customerName?.trim() || usableDomain;
|
|
188
|
+
if (name) {
|
|
189
|
+
companyName = name;
|
|
190
|
+
companyDomain = usableDomain;
|
|
191
|
+
accountIsNew = true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!companyName) {
|
|
195
|
+
counts.unmatched += 1;
|
|
196
|
+
unmatched.push({
|
|
197
|
+
invoiceId: invoice.id,
|
|
198
|
+
invoiceNumber: invoice.number,
|
|
199
|
+
customerName: invoice.customerName,
|
|
200
|
+
customerDomain: invoice.customerDomain,
|
|
201
|
+
amountPaid: invoice.amountPaid,
|
|
202
|
+
currency: invoice.currency,
|
|
203
|
+
paidAt: invoice.paidAt,
|
|
204
|
+
});
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Plan-time dupe prefilter by deal name (best-effort; apply-time
|
|
209
|
+
// resolve-first on the invoice-id property is the authoritative gate).
|
|
210
|
+
const dealName = backfillDealName(invoice, companyName);
|
|
211
|
+
if (existingDealNames.has(dealName.trim().toLowerCase())) {
|
|
212
|
+
counts.alreadyInCrm += 1;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const invoiceLabel = invoice.number ?? invoice.id;
|
|
217
|
+
const amountLabel = `${invoice.amountPaid}${invoice.currency ? ` ${invoice.currency}` : ""}`;
|
|
218
|
+
const recordEvidence: GtmEvidence = {
|
|
219
|
+
id: `ev_bkf_${fnv1a(`${source}:${invoice.id}`)}`,
|
|
220
|
+
sourceSystem: "web",
|
|
221
|
+
sourceObjectType: "invoice",
|
|
222
|
+
sourceObjectId: invoice.id,
|
|
223
|
+
title: `Stripe paid invoice ${invoiceLabel}`,
|
|
224
|
+
text:
|
|
225
|
+
`Stripe invoice ${invoiceLabel} (${invoice.id}) for ${invoice.customerName ?? companyName}: ` +
|
|
226
|
+
`${amountLabel} paid${invoice.paidAt ? ` on ${invoice.paidAt}` : ""}.`,
|
|
227
|
+
capturedAt: nowIso,
|
|
228
|
+
metadata: {
|
|
229
|
+
source,
|
|
230
|
+
invoiceId: invoice.id,
|
|
231
|
+
amountPaid: invoice.amountPaid,
|
|
232
|
+
currency: invoice.currency ?? null,
|
|
233
|
+
paidAt: invoice.paidAt ?? null,
|
|
234
|
+
customerId: invoice.customerId ?? null,
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
evidence.push(recordEvidence);
|
|
238
|
+
|
|
239
|
+
// One explicit account op per NEW customer (not per invoice): the reviewer
|
|
240
|
+
// sees exactly which companies this plan will create. Apply resolves by
|
|
241
|
+
// domain-then-name first, so a concurrently-created account is reused.
|
|
242
|
+
if (accountIsNew) {
|
|
243
|
+
const accountKey = (companyDomain ?? companyName).toLowerCase();
|
|
244
|
+
const existing = proposedByKey.get(accountKey);
|
|
245
|
+
if (existing) {
|
|
246
|
+
existing.invoiceCount += 1;
|
|
247
|
+
} else {
|
|
248
|
+
proposedByKey.set(accountKey, {
|
|
249
|
+
name: companyName,
|
|
250
|
+
...(companyDomain ? { domain: companyDomain } : {}),
|
|
251
|
+
invoiceCount: 1,
|
|
252
|
+
});
|
|
253
|
+
const accountPayload: CreateRecordPayload = {
|
|
254
|
+
properties: {
|
|
255
|
+
name: companyName,
|
|
256
|
+
...(companyDomain ? { domain: companyDomain } : {}),
|
|
257
|
+
},
|
|
258
|
+
matchKey: "name",
|
|
259
|
+
matchValue: companyName,
|
|
260
|
+
source,
|
|
261
|
+
};
|
|
262
|
+
operations.push({
|
|
263
|
+
id: `op_bkf_${fnv1a(`${source}:account:${accountKey}`)}`,
|
|
264
|
+
objectType: "account",
|
|
265
|
+
objectId: `create:stripe:customer:${invoice.customerId ?? accountKey}`,
|
|
266
|
+
operation: "create_record",
|
|
267
|
+
beforeValue: null,
|
|
268
|
+
afterValue: accountPayload,
|
|
269
|
+
reason:
|
|
270
|
+
`Stripe customer "${companyName}"${companyDomain ? ` (${companyDomain})` : ""} has paid ` +
|
|
271
|
+
`invoice(s) but no CRM account — create it so the backfilled deals have a home. ` +
|
|
272
|
+
`Resolve-first by ${companyDomain ? "domain, then " : ""}name at apply.`,
|
|
273
|
+
sourceRuleOrPolicy: `backfill:${source}`,
|
|
274
|
+
riskLevel: "medium",
|
|
275
|
+
approvalRequired: true,
|
|
276
|
+
rollback: "Archive the created company (it was net-new).",
|
|
277
|
+
evidenceIds: [recordEvidence.id],
|
|
278
|
+
});
|
|
279
|
+
counts.accountsProposed += 1;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Prefer the matched ACCOUNT's own name/domain for the association so the
|
|
284
|
+
// connector's resolve-first company step lands on the matched record; for
|
|
285
|
+
// a new customer these are the proposed account's name/domain.
|
|
286
|
+
const payload: CreateRecordPayload = {
|
|
287
|
+
properties: {
|
|
288
|
+
dealname: dealName,
|
|
289
|
+
amount: String(invoice.amountPaid),
|
|
290
|
+
...(invoice.paidAt ? { closedate: invoice.paidAt } : {}),
|
|
291
|
+
...(invoice.description ? { description: invoice.description } : {}),
|
|
292
|
+
},
|
|
293
|
+
matchKey: matchProperty,
|
|
294
|
+
matchValue: invoice.id,
|
|
295
|
+
source,
|
|
296
|
+
dealStage: "closed_won",
|
|
297
|
+
...(opts.pipeline ? { dealPipeline: opts.pipeline } : {}),
|
|
298
|
+
associateCompanyName: companyName,
|
|
299
|
+
...(companyDomain ? { associateCompanyDomain: companyDomain } : {}),
|
|
300
|
+
};
|
|
301
|
+
operations.push({
|
|
302
|
+
id: `op_bkf_${fnv1a(`${source}:deal:${invoice.id}`)}`,
|
|
303
|
+
objectType: "deal",
|
|
304
|
+
objectId: `create:stripe:invoice:${invoice.id}`,
|
|
305
|
+
operation: "create_record",
|
|
306
|
+
beforeValue: null,
|
|
307
|
+
afterValue: payload,
|
|
308
|
+
reason:
|
|
309
|
+
`Paid Stripe invoice ${invoiceLabel} (${amountLabel}, paid ${invoice.paidAt ?? "date unknown"}) ` +
|
|
310
|
+
`has no CRM deal — backfill as closed-won on ${accountIsNew ? "NEW account" : "account"} ` +
|
|
311
|
+
`"${companyName}"${accountIsNew ? " (created by this plan)" : ""} ` +
|
|
312
|
+
`(deduped by ${matchProperty}=${invoice.id}).`,
|
|
313
|
+
sourceRuleOrPolicy: `backfill:${source}`,
|
|
314
|
+
riskLevel: "medium",
|
|
315
|
+
approvalRequired: true,
|
|
316
|
+
rollback: "Archive the created deal (it was net-new).",
|
|
317
|
+
evidenceIds: [recordEvidence.id],
|
|
318
|
+
});
|
|
319
|
+
counts.planned += 1;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const plan: PatchPlan = {
|
|
323
|
+
id: `patch_plan_backfill_${fnv1a(`${source}:${nowIso}:${invoices.map((invoice) => invoice.id).join(",")}`)}`,
|
|
324
|
+
title: `Backfill closed-won deals — ${source}`,
|
|
325
|
+
createdAt: nowIso,
|
|
326
|
+
status: operations.length > 0 ? "needs_approval" : "draft",
|
|
327
|
+
dryRun: true,
|
|
328
|
+
summary:
|
|
329
|
+
`${counts.planned} closed-won deal(s) proposed from ${counts.invoices} paid Stripe invoice(s)` +
|
|
330
|
+
`${counts.accountsProposed > 0 ? `, plus ${counts.accountsProposed} new account(s) proposed for customers the CRM doesn't know` : ""} ` +
|
|
331
|
+
`(${counts.alreadyInCrm} already have a matching deal, ${counts.unmatched} ` +
|
|
332
|
+
`${createMissingAccounts ? "customer(s) with no usable name/domain" : "unmatched customer(s)"} reported only` +
|
|
333
|
+
`${createMissingAccounts ? "" : " — account creation disabled"}). ` +
|
|
334
|
+
`Deduped by ${matchProperty} = invoice id; apply re-checks resolve-first before creating.`,
|
|
335
|
+
findings: [],
|
|
336
|
+
evidence,
|
|
337
|
+
operations,
|
|
338
|
+
};
|
|
339
|
+
return { plan, counts, unmatched, proposedAccounts: [...proposedByKey.values()] };
|
|
340
|
+
}
|
package/src/bin.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { runCli } from "./cli.ts";
|
|
3
|
-
import { flushRunReport } from "./runReport.ts";
|
|
3
|
+
import { beginRunReport, flushRunReport } from "./runReport.ts";
|
|
4
4
|
|
|
5
5
|
const args = process.argv.slice(2);
|
|
6
6
|
const startedAt = Date.now();
|
|
7
7
|
|
|
8
|
+
// Arm live progress heartbeats (paired CLIs stream long runs to the hosted
|
|
9
|
+
// app under the same clientRunId the final flush below will upsert).
|
|
10
|
+
beginRunReport(args, startedAt);
|
|
11
|
+
|
|
8
12
|
runCli(args)
|
|
9
13
|
.then(async () => {
|
|
10
14
|
// exitCode 2 (audit findings / create-gate) or 1 (no value) = "partial":
|
package/src/cli/auth.ts
CHANGED
|
@@ -55,6 +55,10 @@ export function assertSecureBrokerUrl(raw: string): URL {
|
|
|
55
55
|
);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
const DEFAULT_HOSTED_BASE_URL = "https://app.fullstackgtm.com";
|
|
59
|
+
|
|
60
|
+
type CrmProvider = "hubspot" | "salesforce";
|
|
61
|
+
|
|
58
62
|
async function brokerLogin(baseUrl: string) {
|
|
59
63
|
const viaUrl = assertSecureBrokerUrl(baseUrl);
|
|
60
64
|
const base = baseUrl.replace(/\/$/, "");
|
|
@@ -130,15 +134,123 @@ async function brokerLogin(baseUrl: string) {
|
|
|
130
134
|
throw new Error("Pairing timed out before it was approved.");
|
|
131
135
|
}
|
|
132
136
|
|
|
137
|
+
export async function hostedProviderLogin(provider: CrmProvider, baseUrl: string) {
|
|
138
|
+
const viaUrl = assertSecureBrokerUrl(baseUrl);
|
|
139
|
+
const base = baseUrl.replace(/\/$/, "");
|
|
140
|
+
const os = await import("node:os");
|
|
141
|
+
const requesterLabel = `${os.hostname()} (${process.platform}, ${os.userInfo().username})`;
|
|
142
|
+
let startResponse: Response;
|
|
143
|
+
try {
|
|
144
|
+
startResponse = await fetch(`${base}/api/cli/oauth/start`, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: { "Content-Type": "application/json" },
|
|
147
|
+
body: JSON.stringify({ provider, requesterLabel }),
|
|
148
|
+
});
|
|
149
|
+
} catch (error) {
|
|
150
|
+
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
151
|
+
throw new Error(`Cannot reach the hosted deployment at ${base}${cause}. Check the --via URL and network access.`);
|
|
152
|
+
}
|
|
153
|
+
if (!startResponse.ok) {
|
|
154
|
+
throw new Error(`Could not start ${provider} OAuth with ${base} (${startResponse.status}). Is this a FullStackGTM deployment?`);
|
|
155
|
+
}
|
|
156
|
+
const start = await startResponse.json();
|
|
157
|
+
let sameOrigin = false;
|
|
158
|
+
try {
|
|
159
|
+
sameOrigin = new URL(start.verificationUrl).origin === viaUrl.origin;
|
|
160
|
+
} catch {
|
|
161
|
+
sameOrigin = false;
|
|
162
|
+
}
|
|
163
|
+
console.error(
|
|
164
|
+
`\n${provider} OAuth code: ${start.userCode}\n\nApprove this CLI ("${requesterLabel}") in your hosted FullStackGTM dashboard:\n\n ${start.verificationUrl}\n`,
|
|
165
|
+
);
|
|
166
|
+
if (sameOrigin) {
|
|
167
|
+
void openInBrowser(start.verificationUrl);
|
|
168
|
+
} else {
|
|
169
|
+
console.error(`(Not auto-opening: the verification URL is not on ${viaUrl.origin}. Open it manually only if you trust it.)`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const deadline = Date.now() + (start.expiresInSeconds ?? 600) * 1000;
|
|
173
|
+
const intervalMs = Math.max(0, (start.intervalSeconds ?? 3) * 1000);
|
|
174
|
+
while (Date.now() < deadline) {
|
|
175
|
+
await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
|
|
176
|
+
const pollResponse = await fetch(`${base}/api/cli/auth/poll`, {
|
|
177
|
+
method: "POST",
|
|
178
|
+
headers: { "Content-Type": "application/json" },
|
|
179
|
+
body: JSON.stringify({ deviceCode: start.deviceCode }),
|
|
180
|
+
});
|
|
181
|
+
if (!pollResponse.ok) throw new Error(`Pairing poll failed (${pollResponse.status}).`);
|
|
182
|
+
const poll = await pollResponse.json();
|
|
183
|
+
if (poll.status === "pending") continue;
|
|
184
|
+
if (poll.status === "approved" && poll.cliToken) {
|
|
185
|
+
const now = new Date().toISOString();
|
|
186
|
+
storeCredential("broker", {
|
|
187
|
+
kind: "broker",
|
|
188
|
+
accessToken: poll.cliToken,
|
|
189
|
+
baseUrl: base,
|
|
190
|
+
createdAt: now,
|
|
191
|
+
updatedAt: now,
|
|
192
|
+
});
|
|
193
|
+
console.log(`Logged in to ${provider} via hosted OAuth at ${base}. Credentials stored in ${credentialsPath()}.`);
|
|
194
|
+
console.log("Provider tokens are minted server-side by the hosted app; no provider app secret is stored in this CLI.");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
throw new Error(`Pairing was ${poll.status}.`);
|
|
198
|
+
}
|
|
199
|
+
throw new Error("Pairing timed out before it was approved.");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function hostedBase(args: string[]): string {
|
|
203
|
+
return option(args, "--via") ?? process.env.FULLSTACKGTM_HOSTED_URL ?? DEFAULT_HOSTED_BASE_URL;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function guidedProviderLogin(provider: CrmProvider, args: string[], reason: string) {
|
|
207
|
+
const command = `fullstackgtm login ${provider} --hosted`;
|
|
208
|
+
if (!process.stdin.isTTY || process.env.CI) {
|
|
209
|
+
console.error(`${reason}\nNext command: ${command}`);
|
|
210
|
+
throw new Error(reason);
|
|
211
|
+
}
|
|
212
|
+
console.error(
|
|
213
|
+
`\n${provider} login options:\n` +
|
|
214
|
+
` 1) Hosted OAuth (default): no provider app needed; browser approval via FullStackGTM.\n` +
|
|
215
|
+
` 2) BYO ${provider === "salesforce" ? "Connected App" : "HubSpot app"}: advanced OAuth/device flow.\n` +
|
|
216
|
+
` 3) Paste token: read from stdin/prompt; never from argv.\n`,
|
|
217
|
+
);
|
|
218
|
+
const readline = await import("node:readline/promises");
|
|
219
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
220
|
+
const answer = (await rl.question("Choose [1]: ")).trim();
|
|
221
|
+
rl.close();
|
|
222
|
+
if (answer === "" || answer === "1") return hostedProviderLogin(provider, hostedBase(args));
|
|
223
|
+
if (answer === "2") {
|
|
224
|
+
throw new Error(
|
|
225
|
+
provider === "salesforce"
|
|
226
|
+
? "Advanced BYO Salesforce: run `fullstackgtm login salesforce --device --client-id <consumer key>` or pipe a token with `--instance-url <url>`."
|
|
227
|
+
: `Advanced BYO HubSpot: run \`fullstackgtm login hubspot --oauth --client-id <id>\` (redirect http://localhost:${numericOption(args, "--port") ?? DEFAULT_LOOPBACK_PORT}/callback).`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
if (answer === "3") {
|
|
231
|
+
throw new Error(
|
|
232
|
+
provider === "salesforce"
|
|
233
|
+
? "Paste-token Salesforce: pipe the token with `fullstackgtm login salesforce --instance-url <url>`."
|
|
234
|
+
: "Paste-token HubSpot: pipe the token with `fullstackgtm login hubspot --private-token`.",
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
return hostedProviderLogin(provider, hostedBase(args));
|
|
238
|
+
}
|
|
239
|
+
|
|
133
240
|
async function salesforceLogin(args: string[]) {
|
|
134
241
|
const now = new Date().toISOString();
|
|
242
|
+
rejectArgvSecret(args, "--token");
|
|
243
|
+
|
|
244
|
+
if (args.includes("--hosted") || (!args.includes("--device") && !args.includes("--instance-url"))) {
|
|
245
|
+
await hostedProviderLogin("salesforce", hostedBase(args));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
135
248
|
|
|
136
249
|
if (args.includes("--device")) {
|
|
137
250
|
const clientId = option(args, "--client-id");
|
|
138
251
|
if (!clientId) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
);
|
|
252
|
+
await guidedProviderLogin("salesforce", args, "--device requires --client-id (the consumer key of a Connected App with device flow enabled).");
|
|
253
|
+
return;
|
|
142
254
|
}
|
|
143
255
|
const loginUrl = option(args, "--login-url") ?? undefined;
|
|
144
256
|
const authorization = await startSalesforceDeviceLogin({ clientId, loginUrl });
|
|
@@ -170,13 +282,14 @@ async function salesforceLogin(args: string[]) {
|
|
|
170
282
|
return;
|
|
171
283
|
}
|
|
172
284
|
|
|
173
|
-
rejectArgvSecret(args, "--token");
|
|
174
285
|
const instanceUrl = option(args, "--instance-url");
|
|
175
286
|
if (!instanceUrl) {
|
|
176
|
-
|
|
177
|
-
"
|
|
178
|
-
|
|
287
|
+
await guidedProviderLogin(
|
|
288
|
+
"salesforce",
|
|
289
|
+
args,
|
|
290
|
+
"Salesforce login needs hosted OAuth, --device --client-id <consumer key>, or --instance-url <https://yourorg.my.salesforce.com> with the access token piped on stdin.",
|
|
179
291
|
);
|
|
292
|
+
return;
|
|
180
293
|
}
|
|
181
294
|
const token = await readSecret("Salesforce access token");
|
|
182
295
|
if (!token) throw new Error("No access token provided.");
|
|
@@ -196,12 +309,12 @@ async function salesforceLogin(args: string[]) {
|
|
|
196
309
|
}
|
|
197
310
|
|
|
198
311
|
export async function login(args: string[]) {
|
|
312
|
+
const provider = args.find((arg) => !arg.startsWith("--") && !isOptionValue(args, arg));
|
|
199
313
|
const via = option(args, "--via");
|
|
200
|
-
if (via) {
|
|
314
|
+
if (via && !provider) {
|
|
201
315
|
await brokerLogin(via);
|
|
202
316
|
return;
|
|
203
317
|
}
|
|
204
|
-
const provider = args.find((arg) => !arg.startsWith("--") && !isOptionValue(args, arg));
|
|
205
318
|
if (provider === "salesforce") {
|
|
206
319
|
await salesforceLogin(args);
|
|
207
320
|
return;
|
|
@@ -280,16 +393,24 @@ export async function login(args: string[]) {
|
|
|
280
393
|
);
|
|
281
394
|
}
|
|
282
395
|
const now = new Date().toISOString();
|
|
396
|
+
rejectArgvSecret(args, "--token");
|
|
397
|
+
|
|
398
|
+
if (args.includes("--hosted") || (!args.includes("--oauth") && !args.includes("--private-token"))) {
|
|
399
|
+
await hostedProviderLogin("hubspot", hostedBase(args));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
283
402
|
|
|
284
403
|
if (args.includes("--oauth")) {
|
|
285
404
|
rejectArgvSecret(args, "--client-secret");
|
|
286
405
|
const clientId = option(args, "--client-id");
|
|
287
406
|
if (!clientId) {
|
|
288
|
-
|
|
407
|
+
await guidedProviderLogin(
|
|
408
|
+
"hubspot",
|
|
409
|
+
args,
|
|
289
410
|
"--oauth requires --client-id from your own HubSpot app " +
|
|
290
|
-
`(register http://localhost:${numericOption(args, "--port") ?? DEFAULT_LOOPBACK_PORT}/callback as a redirect URL)
|
|
291
|
-
"The client secret is read from stdin or an interactive prompt.",
|
|
411
|
+
`(register http://localhost:${numericOption(args, "--port") ?? DEFAULT_LOOPBACK_PORT}/callback as a redirect URL).`,
|
|
292
412
|
);
|
|
413
|
+
return;
|
|
293
414
|
}
|
|
294
415
|
const clientSecret = await readSecret("HubSpot app client secret");
|
|
295
416
|
if (!clientSecret) throw new Error("No client secret provided.");
|
|
@@ -316,7 +437,6 @@ export async function login(args: string[]) {
|
|
|
316
437
|
return;
|
|
317
438
|
}
|
|
318
439
|
|
|
319
|
-
rejectArgvSecret(args, "--token");
|
|
320
440
|
const token = await readSecret("HubSpot private app token");
|
|
321
441
|
if (!token) throw new Error("No token provided.");
|
|
322
442
|
if (!args.includes("--no-validate")) {
|
|
@@ -384,7 +504,7 @@ export function doctorReport(env: Record<string, string | undefined> = process.e
|
|
|
384
504
|
connected.length === 0
|
|
385
505
|
? [
|
|
386
506
|
"fullstackgtm audit --demo # no credentials needed",
|
|
387
|
-
"fullstackgtm login hubspot #
|
|
507
|
+
"fullstackgtm login hubspot # hosted browser OAuth (default; or: login salesforce)",
|
|
388
508
|
]
|
|
389
509
|
: [`fullstackgtm audit --provider ${connected[0][0]}`];
|
|
390
510
|
|
|
@@ -412,7 +532,7 @@ function providerStatus(provider: string, broker: StoredCredential | null): Prov
|
|
|
412
532
|
if (broker) {
|
|
413
533
|
return { source: "broker", detail: `via ${broker.baseUrl ?? "hosted deployment"}` };
|
|
414
534
|
}
|
|
415
|
-
return { source: "none", detail: `fullstackgtm login ${provider}` };
|
|
535
|
+
return { source: "none", detail: provider === "hubspot" || provider === "salesforce" ? `fullstackgtm login ${provider} (hosted OAuth default)` : `fullstackgtm login ${provider}` };
|
|
416
536
|
}
|
|
417
537
|
|
|
418
538
|
export type WorkspaceDoctor = {
|