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/dist/bin.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { runCli } from "./cli.js";
|
|
3
|
-
import { flushRunReport } from "./runReport.js";
|
|
3
|
+
import { beginRunReport, flushRunReport } from "./runReport.js";
|
|
4
4
|
const args = process.argv.slice(2);
|
|
5
5
|
const startedAt = Date.now();
|
|
6
|
+
// Arm live progress heartbeats (paired CLIs stream long runs to the hosted
|
|
7
|
+
// app under the same clientRunId the final flush below will upsert).
|
|
8
|
+
beginRunReport(args, startedAt);
|
|
6
9
|
runCli(args)
|
|
7
10
|
.then(async () => {
|
|
8
11
|
// exitCode 2 (audit findings / create-gate) or 1 (no value) = "partial":
|
package/dist/cli/auth.d.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* shell escaping does nothing for a token sent in cleartext.
|
|
6
6
|
*/
|
|
7
7
|
export declare function assertSecureBrokerUrl(raw: string): URL;
|
|
8
|
+
type CrmProvider = "hubspot" | "salesforce";
|
|
9
|
+
export declare function hostedProviderLogin(provider: CrmProvider, baseUrl: string): Promise<void>;
|
|
8
10
|
export declare function login(args: string[]): Promise<void>;
|
|
9
11
|
export declare function logout(args: string[]): void;
|
|
10
12
|
type ProviderDoctorStatus = {
|
package/dist/cli/auth.js
CHANGED
|
@@ -47,6 +47,7 @@ export function assertSecureBrokerUrl(raw) {
|
|
|
47
47
|
throw new Error(`Refusing to pair over ${url.protocol}//${url.host}: the broker exchanges a long-lived token and mints live CRM ` +
|
|
48
48
|
"credentials, so it must use https (http is allowed only for localhost dev).");
|
|
49
49
|
}
|
|
50
|
+
const DEFAULT_HOSTED_BASE_URL = "https://app.fullstackgtm.com";
|
|
50
51
|
async function brokerLogin(baseUrl) {
|
|
51
52
|
const viaUrl = assertSecureBrokerUrl(baseUrl);
|
|
52
53
|
const base = baseUrl.replace(/\/$/, "");
|
|
@@ -118,12 +119,115 @@ async function brokerLogin(baseUrl) {
|
|
|
118
119
|
}
|
|
119
120
|
throw new Error("Pairing timed out before it was approved.");
|
|
120
121
|
}
|
|
122
|
+
export async function hostedProviderLogin(provider, baseUrl) {
|
|
123
|
+
const viaUrl = assertSecureBrokerUrl(baseUrl);
|
|
124
|
+
const base = baseUrl.replace(/\/$/, "");
|
|
125
|
+
const os = await import("node:os");
|
|
126
|
+
const requesterLabel = `${os.hostname()} (${process.platform}, ${os.userInfo().username})`;
|
|
127
|
+
let startResponse;
|
|
128
|
+
try {
|
|
129
|
+
startResponse = await fetch(`${base}/api/cli/oauth/start`, {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: { "Content-Type": "application/json" },
|
|
132
|
+
body: JSON.stringify({ provider, requesterLabel }),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
137
|
+
throw new Error(`Cannot reach the hosted deployment at ${base}${cause}. Check the --via URL and network access.`);
|
|
138
|
+
}
|
|
139
|
+
if (!startResponse.ok) {
|
|
140
|
+
throw new Error(`Could not start ${provider} OAuth with ${base} (${startResponse.status}). Is this a FullStackGTM deployment?`);
|
|
141
|
+
}
|
|
142
|
+
const start = await startResponse.json();
|
|
143
|
+
let sameOrigin = false;
|
|
144
|
+
try {
|
|
145
|
+
sameOrigin = new URL(start.verificationUrl).origin === viaUrl.origin;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
sameOrigin = false;
|
|
149
|
+
}
|
|
150
|
+
console.error(`\n${provider} OAuth code: ${start.userCode}\n\nApprove this CLI ("${requesterLabel}") in your hosted FullStackGTM dashboard:\n\n ${start.verificationUrl}\n`);
|
|
151
|
+
if (sameOrigin) {
|
|
152
|
+
void openInBrowser(start.verificationUrl);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
console.error(`(Not auto-opening: the verification URL is not on ${viaUrl.origin}. Open it manually only if you trust it.)`);
|
|
156
|
+
}
|
|
157
|
+
const deadline = Date.now() + (start.expiresInSeconds ?? 600) * 1000;
|
|
158
|
+
const intervalMs = Math.max(0, (start.intervalSeconds ?? 3) * 1000);
|
|
159
|
+
while (Date.now() < deadline) {
|
|
160
|
+
await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
|
|
161
|
+
const pollResponse = await fetch(`${base}/api/cli/auth/poll`, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: { "Content-Type": "application/json" },
|
|
164
|
+
body: JSON.stringify({ deviceCode: start.deviceCode }),
|
|
165
|
+
});
|
|
166
|
+
if (!pollResponse.ok)
|
|
167
|
+
throw new Error(`Pairing poll failed (${pollResponse.status}).`);
|
|
168
|
+
const poll = await pollResponse.json();
|
|
169
|
+
if (poll.status === "pending")
|
|
170
|
+
continue;
|
|
171
|
+
if (poll.status === "approved" && poll.cliToken) {
|
|
172
|
+
const now = new Date().toISOString();
|
|
173
|
+
storeCredential("broker", {
|
|
174
|
+
kind: "broker",
|
|
175
|
+
accessToken: poll.cliToken,
|
|
176
|
+
baseUrl: base,
|
|
177
|
+
createdAt: now,
|
|
178
|
+
updatedAt: now,
|
|
179
|
+
});
|
|
180
|
+
console.log(`Logged in to ${provider} via hosted OAuth at ${base}. Credentials stored in ${credentialsPath()}.`);
|
|
181
|
+
console.log("Provider tokens are minted server-side by the hosted app; no provider app secret is stored in this CLI.");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
throw new Error(`Pairing was ${poll.status}.`);
|
|
185
|
+
}
|
|
186
|
+
throw new Error("Pairing timed out before it was approved.");
|
|
187
|
+
}
|
|
188
|
+
function hostedBase(args) {
|
|
189
|
+
return option(args, "--via") ?? process.env.FULLSTACKGTM_HOSTED_URL ?? DEFAULT_HOSTED_BASE_URL;
|
|
190
|
+
}
|
|
191
|
+
async function guidedProviderLogin(provider, args, reason) {
|
|
192
|
+
const command = `fullstackgtm login ${provider} --hosted`;
|
|
193
|
+
if (!process.stdin.isTTY || process.env.CI) {
|
|
194
|
+
console.error(`${reason}\nNext command: ${command}`);
|
|
195
|
+
throw new Error(reason);
|
|
196
|
+
}
|
|
197
|
+
console.error(`\n${provider} login options:\n` +
|
|
198
|
+
` 1) Hosted OAuth (default): no provider app needed; browser approval via FullStackGTM.\n` +
|
|
199
|
+
` 2) BYO ${provider === "salesforce" ? "Connected App" : "HubSpot app"}: advanced OAuth/device flow.\n` +
|
|
200
|
+
` 3) Paste token: read from stdin/prompt; never from argv.\n`);
|
|
201
|
+
const readline = await import("node:readline/promises");
|
|
202
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
203
|
+
const answer = (await rl.question("Choose [1]: ")).trim();
|
|
204
|
+
rl.close();
|
|
205
|
+
if (answer === "" || answer === "1")
|
|
206
|
+
return hostedProviderLogin(provider, hostedBase(args));
|
|
207
|
+
if (answer === "2") {
|
|
208
|
+
throw new Error(provider === "salesforce"
|
|
209
|
+
? "Advanced BYO Salesforce: run `fullstackgtm login salesforce --device --client-id <consumer key>` or pipe a token with `--instance-url <url>`."
|
|
210
|
+
: `Advanced BYO HubSpot: run \`fullstackgtm login hubspot --oauth --client-id <id>\` (redirect http://localhost:${numericOption(args, "--port") ?? DEFAULT_LOOPBACK_PORT}/callback).`);
|
|
211
|
+
}
|
|
212
|
+
if (answer === "3") {
|
|
213
|
+
throw new Error(provider === "salesforce"
|
|
214
|
+
? "Paste-token Salesforce: pipe the token with `fullstackgtm login salesforce --instance-url <url>`."
|
|
215
|
+
: "Paste-token HubSpot: pipe the token with `fullstackgtm login hubspot --private-token`.");
|
|
216
|
+
}
|
|
217
|
+
return hostedProviderLogin(provider, hostedBase(args));
|
|
218
|
+
}
|
|
121
219
|
async function salesforceLogin(args) {
|
|
122
220
|
const now = new Date().toISOString();
|
|
221
|
+
rejectArgvSecret(args, "--token");
|
|
222
|
+
if (args.includes("--hosted") || (!args.includes("--device") && !args.includes("--instance-url"))) {
|
|
223
|
+
await hostedProviderLogin("salesforce", hostedBase(args));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
123
226
|
if (args.includes("--device")) {
|
|
124
227
|
const clientId = option(args, "--client-id");
|
|
125
228
|
if (!clientId) {
|
|
126
|
-
|
|
229
|
+
await guidedProviderLogin("salesforce", args, "--device requires --client-id (the consumer key of a Connected App with device flow enabled).");
|
|
230
|
+
return;
|
|
127
231
|
}
|
|
128
232
|
const loginUrl = option(args, "--login-url") ?? undefined;
|
|
129
233
|
const authorization = await startSalesforceDeviceLogin({ clientId, loginUrl });
|
|
@@ -150,11 +254,10 @@ async function salesforceLogin(args) {
|
|
|
150
254
|
console.log("Tokens refresh silently; no further browser interaction is needed.");
|
|
151
255
|
return;
|
|
152
256
|
}
|
|
153
|
-
rejectArgvSecret(args, "--token");
|
|
154
257
|
const instanceUrl = option(args, "--instance-url");
|
|
155
258
|
if (!instanceUrl) {
|
|
156
|
-
|
|
157
|
-
|
|
259
|
+
await guidedProviderLogin("salesforce", args, "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.");
|
|
260
|
+
return;
|
|
158
261
|
}
|
|
159
262
|
const token = await readSecret("Salesforce access token");
|
|
160
263
|
if (!token)
|
|
@@ -175,12 +278,12 @@ async function salesforceLogin(args) {
|
|
|
175
278
|
console.log(`Logged in to Salesforce. Credentials stored in ${credentialsPath()}.`);
|
|
176
279
|
}
|
|
177
280
|
export async function login(args) {
|
|
281
|
+
const provider = args.find((arg) => !arg.startsWith("--") && !isOptionValue(args, arg));
|
|
178
282
|
const via = option(args, "--via");
|
|
179
|
-
if (via) {
|
|
283
|
+
if (via && !provider) {
|
|
180
284
|
await brokerLogin(via);
|
|
181
285
|
return;
|
|
182
286
|
}
|
|
183
|
-
const provider = args.find((arg) => !arg.startsWith("--") && !isOptionValue(args, arg));
|
|
184
287
|
if (provider === "salesforce") {
|
|
185
288
|
await salesforceLogin(args);
|
|
186
289
|
return;
|
|
@@ -261,13 +364,18 @@ export async function login(args) {
|
|
|
261
364
|
throw new Error("login supports: hubspot, salesforce, stripe, anthropic, openai, apollo, pipe0, explorium, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com");
|
|
262
365
|
}
|
|
263
366
|
const now = new Date().toISOString();
|
|
367
|
+
rejectArgvSecret(args, "--token");
|
|
368
|
+
if (args.includes("--hosted") || (!args.includes("--oauth") && !args.includes("--private-token"))) {
|
|
369
|
+
await hostedProviderLogin("hubspot", hostedBase(args));
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
264
372
|
if (args.includes("--oauth")) {
|
|
265
373
|
rejectArgvSecret(args, "--client-secret");
|
|
266
374
|
const clientId = option(args, "--client-id");
|
|
267
375
|
if (!clientId) {
|
|
268
|
-
|
|
269
|
-
`(register http://localhost:${numericOption(args, "--port") ?? DEFAULT_LOOPBACK_PORT}/callback as a redirect URL)
|
|
270
|
-
|
|
376
|
+
await guidedProviderLogin("hubspot", args, "--oauth requires --client-id from your own HubSpot app " +
|
|
377
|
+
`(register http://localhost:${numericOption(args, "--port") ?? DEFAULT_LOOPBACK_PORT}/callback as a redirect URL).`);
|
|
378
|
+
return;
|
|
271
379
|
}
|
|
272
380
|
const clientSecret = await readSecret("HubSpot app client secret");
|
|
273
381
|
if (!clientSecret)
|
|
@@ -294,7 +402,6 @@ export async function login(args) {
|
|
|
294
402
|
console.log("Tokens refresh silently; no further browser interaction is needed.");
|
|
295
403
|
return;
|
|
296
404
|
}
|
|
297
|
-
rejectArgvSecret(args, "--token");
|
|
298
405
|
const token = await readSecret("HubSpot private app token");
|
|
299
406
|
if (!token)
|
|
300
407
|
throw new Error("No token provided.");
|
|
@@ -354,7 +461,7 @@ export function doctorReport(env = process.env) {
|
|
|
354
461
|
const nextSteps = connected.length === 0
|
|
355
462
|
? [
|
|
356
463
|
"fullstackgtm audit --demo # no credentials needed",
|
|
357
|
-
"fullstackgtm login hubspot #
|
|
464
|
+
"fullstackgtm login hubspot # hosted browser OAuth (default; or: login salesforce)",
|
|
358
465
|
]
|
|
359
466
|
: [`fullstackgtm audit --provider ${connected[0][0]}`];
|
|
360
467
|
return {
|
|
@@ -380,7 +487,7 @@ function providerStatus(provider, broker) {
|
|
|
380
487
|
if (broker) {
|
|
381
488
|
return { source: "broker", detail: `via ${broker.baseUrl ?? "hosted deployment"}` };
|
|
382
489
|
}
|
|
383
|
-
return { source: "none", detail: `fullstackgtm login ${provider}` };
|
|
490
|
+
return { source: "none", detail: provider === "hubspot" || provider === "salesforce" ? `fullstackgtm login ${provider} (hosted OAuth default)` : `fullstackgtm login ${provider}` };
|
|
384
491
|
}
|
|
385
492
|
/**
|
|
386
493
|
* The workspace slice of doctor: current health + plans awaiting approval.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function backfillCommand(args: string[]): Promise<void>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// `backfill stripe` — paid Stripe invoices → closed-won deal proposals through
|
|
2
|
+
// the governed plan/apply flow. This command NEVER writes to a CRM: it reads
|
|
3
|
+
// paid invoices (Stripe, read-only), reads a CRM snapshot, and builds a
|
|
4
|
+
// dry-run create_record plan. --save persists the plan for the normal
|
|
5
|
+
// plans approve → apply spine, where the HubSpot connector re-resolves each
|
|
6
|
+
// invoice id (resolve-first) and creates only on a confirmed miss.
|
|
7
|
+
import { buildStripeBackfillPlan, DEFAULT_BACKFILL_MATCH_PROPERTY } from "../backfill.js";
|
|
8
|
+
import { fetchStripePaidInvoices } from "../connectors/stripe.js";
|
|
9
|
+
import { getCredential } from "../credentials.js";
|
|
10
|
+
import { patchPlanToMarkdown } from "../format.js";
|
|
11
|
+
import { createFilePlanStore } from "../planStore.js";
|
|
12
|
+
import { BACKFILL_STRIPE_STAGES, composeListeners, createProgressEmitter } from "../progress.js";
|
|
13
|
+
import { progressReporter } from "../runReport.js";
|
|
14
|
+
import { unknownSubcommandError } from "./suggest.js";
|
|
15
|
+
import { option, readSnapshot, saveRequested } from "./shared.js";
|
|
16
|
+
import { colorEnabled, createProgressRenderer, paint, stylizePlanMarkdown, table } from "./ui.js";
|
|
17
|
+
/** STRIPE_SECRET_KEY env ∨ stored `login stripe` credential (same ladder as `--provider stripe`). */
|
|
18
|
+
function resolveStripeKey() {
|
|
19
|
+
const key = process.env.STRIPE_SECRET_KEY ?? getCredential("stripe")?.accessToken;
|
|
20
|
+
if (!key) {
|
|
21
|
+
throw new Error("No Stripe credentials. Run `echo \"$STRIPE_KEY\" | fullstackgtm login stripe` or set STRIPE_SECRET_KEY. " +
|
|
22
|
+
"A restricted key with read access to Invoices and Customers is enough. (`fullstackgtm doctor` shows credential status.)");
|
|
23
|
+
}
|
|
24
|
+
return key;
|
|
25
|
+
}
|
|
26
|
+
export async function backfillCommand(args) {
|
|
27
|
+
const [subcommand, ...rest] = args;
|
|
28
|
+
if (subcommand === "runs") {
|
|
29
|
+
const { backfillRunsCommand } = await import("./backfillRuns.js");
|
|
30
|
+
await backfillRunsCommand(rest);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (subcommand !== "stripe") {
|
|
34
|
+
throw unknownSubcommandError("backfill", subcommand ?? "", ["stripe", "runs"]);
|
|
35
|
+
}
|
|
36
|
+
const since = option(rest, "--since");
|
|
37
|
+
const pipeline = option(rest, "--pipeline") ?? undefined;
|
|
38
|
+
const matchProperty = option(rest, "--match-property") ?? undefined;
|
|
39
|
+
const skipUnmatched = rest.includes("--skip-unmatched");
|
|
40
|
+
const save = saveRequested(rest);
|
|
41
|
+
// Shared progress: the BACKFILL_STRIPE_STAGES tick on an interactive stderr
|
|
42
|
+
// board (inert when piped) and heartbeat to a paired hosted app on long runs.
|
|
43
|
+
const renderer = createProgressRenderer(BACKFILL_STRIPE_STAGES);
|
|
44
|
+
const progress = createProgressEmitter(composeListeners(renderer.listener, progressReporter()));
|
|
45
|
+
const stage = (name) => progress.stage(name, BACKFILL_STRIPE_STAGES.indexOf(name), BACKFILL_STRIPE_STAGES.length);
|
|
46
|
+
let result;
|
|
47
|
+
try {
|
|
48
|
+
// 1. Revenue truth: paid invoices from Stripe (read-only).
|
|
49
|
+
stage("invoices");
|
|
50
|
+
const stripeKey = resolveStripeKey();
|
|
51
|
+
const invoices = await fetchStripePaidInvoices({ getApiKey: () => stripeKey }, { ...(since ? { sinceIso: since } : {}), onPage: (fetched) => progress.items(fetched) });
|
|
52
|
+
// 2. The CRM as it stands (same source options as audit: --provider <crm>,
|
|
53
|
+
// --input <snapshot.json>, --demo, --sample). The pull's own stage/items
|
|
54
|
+
// events ride the same emitter (the board ignores the nested stages).
|
|
55
|
+
stage("snapshot");
|
|
56
|
+
const snapshot = await readSnapshot(rest, progress);
|
|
57
|
+
// 3. Dry-run plan: one closed-won create_record deal per invoice with no
|
|
58
|
+
// deal yet; unmatched customers also get a proposed account (unless
|
|
59
|
+
// --skip-unmatched restores report-only behavior).
|
|
60
|
+
stage("matching");
|
|
61
|
+
result = buildStripeBackfillPlan(invoices, snapshot, {
|
|
62
|
+
pipeline,
|
|
63
|
+
matchProperty,
|
|
64
|
+
source: "stripe:invoices",
|
|
65
|
+
createMissingAccounts: !skipUnmatched,
|
|
66
|
+
});
|
|
67
|
+
stage("plan");
|
|
68
|
+
progress.flush();
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
renderer.done();
|
|
72
|
+
}
|
|
73
|
+
if (rest.includes("--json")) {
|
|
74
|
+
console.log(JSON.stringify({ plan: result.plan, counts: result.counts, unmatched: result.unmatched, proposedAccounts: result.proposedAccounts }, null, 2));
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
// TTY-only styling; piped output stays byte-identical plain text.
|
|
78
|
+
console.log(stylizePlanMarkdown(patchPlanToMarkdown(result.plan), paint(colorEnabled(process.stdout))));
|
|
79
|
+
if (result.proposedAccounts.length > 0) {
|
|
80
|
+
console.log(`\nProposed new accounts (${result.proposedAccounts.length}) — Stripe customers the CRM doesn't know; ` +
|
|
81
|
+
"created only if this plan is approved and applied:");
|
|
82
|
+
const accountRows = [
|
|
83
|
+
["account", "domain", "invoices"],
|
|
84
|
+
...result.proposedAccounts.map((entry) => [
|
|
85
|
+
entry.name,
|
|
86
|
+
entry.domain ?? "—",
|
|
87
|
+
String(entry.invoiceCount),
|
|
88
|
+
]),
|
|
89
|
+
];
|
|
90
|
+
for (const line of table(accountRows))
|
|
91
|
+
console.log(` ${line}`);
|
|
92
|
+
}
|
|
93
|
+
if (result.unmatched.length > 0) {
|
|
94
|
+
console.log(`\nUnmatched invoices (${result.unmatched.length}) — ${skipUnmatched
|
|
95
|
+
? "customer matched no CRM account by domain or name; reported only (--skip-unmatched)"
|
|
96
|
+
: "customer has no usable name or company domain; reported only"}:`);
|
|
97
|
+
const rows = [
|
|
98
|
+
["invoice", "customer", "domain", "amount", "paid"],
|
|
99
|
+
...result.unmatched.map((entry) => [
|
|
100
|
+
entry.invoiceNumber ?? entry.invoiceId,
|
|
101
|
+
entry.customerName ?? "—",
|
|
102
|
+
entry.customerDomain ?? "—",
|
|
103
|
+
`${entry.amountPaid}${entry.currency ? ` ${entry.currency}` : ""}`,
|
|
104
|
+
entry.paidAt ?? "—",
|
|
105
|
+
]),
|
|
106
|
+
];
|
|
107
|
+
for (const line of table(rows))
|
|
108
|
+
console.log(` ${line}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (!save) {
|
|
112
|
+
console.error("\nDry run — nothing written. Re-run with --save to persist the plan, then approve + apply.");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (result.plan.operations.length === 0) {
|
|
116
|
+
console.error("No deals to backfill (every paid invoice already matched a deal or its customer was unmatched); nothing saved.");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
await createFilePlanStore().save(result.plan);
|
|
120
|
+
console.error(`Saved plan ${result.plan.id} — ${result.counts.planned} closed-won deal(s)` +
|
|
121
|
+
`${result.counts.accountsProposed > 0 ? ` + ${result.counts.accountsProposed} new account(s)` : ""} from ${result.counts.invoices} paid invoice(s). ` +
|
|
122
|
+
`Review \`fullstackgtm plans show ${result.plan.id}\`, approve \`fullstackgtm plans approve ${result.plan.id} --operations all\`, ` +
|
|
123
|
+
`then \`fullstackgtm apply --plan-id ${result.plan.id} --provider hubspot\`. ` +
|
|
124
|
+
`Apply re-checks ${result.plan.operations.length ? `the ${matchProperty ?? DEFAULT_BACKFILL_MATCH_PROPERTY} dedupe key` : "resolve-first"} before creating.`);
|
|
125
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function backfillRunsCommand(args: string[]): Promise<void>;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// `backfill runs` — replay the LOCAL execution history to the paired hosted
|
|
2
|
+
// app, so an engagement that started CLI-only gets its full trail the moment
|
|
3
|
+
// it pairs (instead of a feed that begins mid-engagement):
|
|
4
|
+
// - every stored plan's runs[] (apply attempts, per-op result counts)
|
|
5
|
+
// → POST /api/cli/run, one record per run
|
|
6
|
+
// - the health.jsonl timeline (per-audit hygiene scores)
|
|
7
|
+
// → POST /api/cli/health, chunked
|
|
8
|
+
// Idempotent by construction: each run carries a DETERMINISTIC clientRunId
|
|
9
|
+
// (fnv1a of planId + startedAt + index) and health entries dedupe server-side
|
|
10
|
+
// on their timestamp — re-running backfill never duplicates anything.
|
|
11
|
+
// Privacy: replays only what live reporting would have sent — statuses,
|
|
12
|
+
// counts, timestamps, scores. No CRM field values leave the machine.
|
|
13
|
+
import { getCredential } from "../credentials.js";
|
|
14
|
+
import { readHealthTimeline } from "../health.js";
|
|
15
|
+
import { createFilePlanStore } from "../planStore.js";
|
|
16
|
+
import { activeProfile } from "../credentials.js";
|
|
17
|
+
import { composeListeners, createProgressEmitter, RUNS_REPLAY_STAGES } from "../progress.js";
|
|
18
|
+
import { progressReporter } from "../runReport.js";
|
|
19
|
+
import { createProgressRenderer } from "./ui.js";
|
|
20
|
+
function fnv1a(value) {
|
|
21
|
+
let hash = 0x811c9dc5;
|
|
22
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
23
|
+
hash ^= value.charCodeAt(i);
|
|
24
|
+
hash = Math.imul(hash, 0x01000193);
|
|
25
|
+
}
|
|
26
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
27
|
+
}
|
|
28
|
+
const RUN_STATUS = {
|
|
29
|
+
applied: "success",
|
|
30
|
+
partial: "partial",
|
|
31
|
+
failed: "error",
|
|
32
|
+
rejected: "error",
|
|
33
|
+
};
|
|
34
|
+
export async function backfillRunsCommand(args) {
|
|
35
|
+
const dryRun = args.includes("--dry-run");
|
|
36
|
+
const json = args.includes("--json");
|
|
37
|
+
const broker = getCredential("broker");
|
|
38
|
+
if (!broker?.baseUrl || !broker.accessToken) {
|
|
39
|
+
throw new Error("Not paired with a hosted app. Run `fullstackgtm login --via <url>` first — backfill replays local history to the paired backend.");
|
|
40
|
+
}
|
|
41
|
+
const baseUrl = broker.baseUrl.replace(/\/+$/, "");
|
|
42
|
+
const headers = {
|
|
43
|
+
Authorization: `Bearer ${broker.accessToken}`,
|
|
44
|
+
"Content-Type": "application/json",
|
|
45
|
+
};
|
|
46
|
+
const profile = activeProfile();
|
|
47
|
+
// 1. Gather: every stored plan's runs[] + the health timeline.
|
|
48
|
+
const plans = await createFilePlanStore().list();
|
|
49
|
+
const runPayloads = plans.flatMap((stored) => stored.runs.map((run, index) => {
|
|
50
|
+
const startedAt = Date.parse(run.startedAt);
|
|
51
|
+
const finishedAt = Date.parse(run.finishedAt);
|
|
52
|
+
const tally = { applied: 0, skipped: 0, conflicts: 0, failed: 0 };
|
|
53
|
+
for (const result of run.results) {
|
|
54
|
+
if (result.status === "applied")
|
|
55
|
+
tally.applied += 1;
|
|
56
|
+
else if (result.status === "skipped")
|
|
57
|
+
tally.skipped += 1;
|
|
58
|
+
else if (result.status === "conflict")
|
|
59
|
+
tally.conflicts += 1;
|
|
60
|
+
else
|
|
61
|
+
tally.failed += 1;
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
clientRunId: `bkf_${fnv1a(`${stored.plan.id}:${run.startedAt}:${index}`)}`,
|
|
65
|
+
command: `apply --plan-id ${stored.plan.id}`,
|
|
66
|
+
status: RUN_STATUS[run.status] ?? "error",
|
|
67
|
+
profile,
|
|
68
|
+
startedAt: Number.isFinite(startedAt) ? startedAt : Date.now(),
|
|
69
|
+
finishedAt: Number.isFinite(finishedAt) ? finishedAt : startedAt,
|
|
70
|
+
durationMs: Number.isFinite(startedAt) && Number.isFinite(finishedAt) && finishedAt >= startedAt
|
|
71
|
+
? finishedAt - startedAt
|
|
72
|
+
: 0,
|
|
73
|
+
counts: { ...tally, provider: run.provider, backfilled: true },
|
|
74
|
+
};
|
|
75
|
+
}));
|
|
76
|
+
const healthEntries = readHealthTimeline().map((entry) => ({
|
|
77
|
+
at: Date.parse(entry.at),
|
|
78
|
+
score: entry.score,
|
|
79
|
+
findings: entry.findings,
|
|
80
|
+
weightedFindings: entry.weightedFindings,
|
|
81
|
+
records: entry.records,
|
|
82
|
+
severityCounts: entry.severityCounts,
|
|
83
|
+
byObjectType: entry.byObjectType,
|
|
84
|
+
byRule: entry.byRule,
|
|
85
|
+
})).filter((entry) => Number.isFinite(entry.at));
|
|
86
|
+
if (dryRun) {
|
|
87
|
+
const summary = {
|
|
88
|
+
ok: true,
|
|
89
|
+
dryRun: true,
|
|
90
|
+
plans: plans.length,
|
|
91
|
+
runs: runPayloads.length,
|
|
92
|
+
healthEntries: healthEntries.length,
|
|
93
|
+
target: baseUrl,
|
|
94
|
+
};
|
|
95
|
+
if (json)
|
|
96
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
97
|
+
else {
|
|
98
|
+
console.log(`Dry run — would replay ${runPayloads.length} plan run(s) (from ${plans.length} stored plan(s)) ` +
|
|
99
|
+
`and ${healthEntries.length} health reading(s) to ${baseUrl}. Nothing sent.`);
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// Shared progress: replay ticks on an interactive stderr board (inert when
|
|
104
|
+
// piped) and heartbeats to the paired app — this command is paired by
|
|
105
|
+
// definition, so a big replay shows live on the dashboard as it lands.
|
|
106
|
+
const renderer = createProgressRenderer(RUNS_REPLAY_STAGES);
|
|
107
|
+
const progress = createProgressEmitter(composeListeners(renderer.listener, progressReporter()));
|
|
108
|
+
// 2. Replay runs (sequential; server dedupes on clientRunId).
|
|
109
|
+
let reported = 0;
|
|
110
|
+
let failed = 0;
|
|
111
|
+
const errors = [];
|
|
112
|
+
let healthInserted = 0;
|
|
113
|
+
let healthDeduped = 0;
|
|
114
|
+
try {
|
|
115
|
+
progress.stage(RUNS_REPLAY_STAGES[0], 0, RUNS_REPLAY_STAGES.length);
|
|
116
|
+
for (const payload of runPayloads) {
|
|
117
|
+
try {
|
|
118
|
+
const response = await fetch(`${baseUrl}/api/cli/run`, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers,
|
|
121
|
+
body: JSON.stringify(payload),
|
|
122
|
+
signal: AbortSignal.timeout(10_000),
|
|
123
|
+
});
|
|
124
|
+
if (!response.ok)
|
|
125
|
+
throw new Error(`HTTP ${response.status}`);
|
|
126
|
+
reported += 1;
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
failed += 1;
|
|
130
|
+
if (errors.length < 5) {
|
|
131
|
+
errors.push(`${payload.command}: ${error instanceof Error ? error.message : String(error)}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
progress.items(reported + failed, runPayloads.length);
|
|
135
|
+
}
|
|
136
|
+
// 3. Replay health history in chunks (server dedupes on timestamp).
|
|
137
|
+
progress.stage(RUNS_REPLAY_STAGES[1], 1, RUNS_REPLAY_STAGES.length);
|
|
138
|
+
for (let i = 0; i < healthEntries.length; i += 200) {
|
|
139
|
+
const chunk = healthEntries.slice(i, i + 200);
|
|
140
|
+
try {
|
|
141
|
+
const response = await fetch(`${baseUrl}/api/cli/health`, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers,
|
|
144
|
+
body: JSON.stringify({ entries: chunk }),
|
|
145
|
+
signal: AbortSignal.timeout(15_000),
|
|
146
|
+
});
|
|
147
|
+
if (!response.ok)
|
|
148
|
+
throw new Error(`HTTP ${response.status}`);
|
|
149
|
+
const result = (await response.json());
|
|
150
|
+
healthInserted += result.inserted ?? 0;
|
|
151
|
+
healthDeduped += result.deduped ?? 0;
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
failed += 1;
|
|
155
|
+
if (errors.length < 5) {
|
|
156
|
+
errors.push(`health chunk: ${error instanceof Error ? error.message : String(error)}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
progress.items(Math.min(i + 200, healthEntries.length), healthEntries.length);
|
|
160
|
+
}
|
|
161
|
+
progress.flush();
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
renderer.done();
|
|
165
|
+
}
|
|
166
|
+
const summary = {
|
|
167
|
+
ok: failed === 0,
|
|
168
|
+
runsReported: reported,
|
|
169
|
+
runsFailed: failed,
|
|
170
|
+
healthInserted,
|
|
171
|
+
healthDeduped,
|
|
172
|
+
target: baseUrl,
|
|
173
|
+
...(errors.length > 0 ? { errors } : {}),
|
|
174
|
+
};
|
|
175
|
+
if (json) {
|
|
176
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
console.log(`Replayed ${reported}/${runPayloads.length} plan run(s) and ${healthInserted} health reading(s) ` +
|
|
180
|
+
`to ${baseUrl} (${healthDeduped} readings already there — dedupe is server-side, re-running is safe).`);
|
|
181
|
+
for (const line of errors)
|
|
182
|
+
console.error(` error: ${line}`);
|
|
183
|
+
}
|
|
184
|
+
if (failed > 0) {
|
|
185
|
+
process.exitCode = 1;
|
|
186
|
+
}
|
|
187
|
+
}
|
package/dist/cli/call.js
CHANGED
|
@@ -5,13 +5,13 @@ import { patchPlanToMarkdown } from "../format.js";
|
|
|
5
5
|
import { createFilePlanStore } from "../planStore.js";
|
|
6
6
|
import { normalizeTranscript, parseCall, suggestCallDeal } from "../calls.js";
|
|
7
7
|
import { classifyCall, rubricForCallType, rubricPresets, CALL_TYPES, CALL_TYPE_IDS } from "../callTypes.js";
|
|
8
|
-
import { DEFAULT_RUBRIC, classifyCallLlm,
|
|
8
|
+
import { DEFAULT_RUBRIC, classifyCallLlm, extractInsightsChunked, parseRubric, resolveLlmCredential, scoreCallLlm } from "../llm.js";
|
|
9
9
|
import { option, readSnapshot, requireLlmCredential, saveRequested } from "./shared.js";
|
|
10
10
|
import { startElapsedStatus } from "./ui.js";
|
|
11
11
|
export async function callCommand(args) {
|
|
12
12
|
const [subcommand, ...rest] = args;
|
|
13
13
|
if (args.includes("--help") || args.includes("-h")) {
|
|
14
|
-
console.log(`call parse --transcript <file> [--title t] [--source s] [--model m] [--
|
|
14
|
+
console.log(`call parse --transcript <file> [--title t] [--source s] [--model m] [--llm] [--heuristics] [--json|--ndjson] [--out <path>]
|
|
15
15
|
call classify --transcript <file>|--call <parsed.json> [--llm] [--deterministic] [--json] [--list]
|
|
16
16
|
call score --transcript <file>|--call <parsed.json> [--call-type <t>] [--rubric <rubric.json>] [--model m] [--json|--out <path>] [--list-rubrics]
|
|
17
17
|
call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
|
|
@@ -21,9 +21,12 @@ classify picks the call type (deterministic signals; --llm for a model tiebreak)
|
|
|
21
21
|
score auto-selects the type-specific rubric from that classification unless you
|
|
22
22
|
pass --call-type or --rubric. Call types: ${CALL_TYPE_IDS.join(", ")}.
|
|
23
23
|
|
|
24
|
-
parse
|
|
25
|
-
\`login anthropic|openai
|
|
26
|
-
|
|
24
|
+
parse defaults to chunked LLM extraction when a key resolves (ANTHROPIC_API_KEY/
|
|
25
|
+
OPENAI_API_KEY or \`login anthropic|openai\`) and falls back to the free
|
|
26
|
+
deterministic engine when none does. --heuristics (alias --deterministic)
|
|
27
|
+
forces the deterministic engine; --llm forces the LLM path (one-time key prompt
|
|
28
|
+
on a TTY). Model: --model, else env FSGTM_INSIGHTS_MODEL, else the provider
|
|
29
|
+
default. classify --deterministic needs no key.
|
|
27
30
|
score always needs a key (scoring is LLM work).`);
|
|
28
31
|
return;
|
|
29
32
|
}
|
|
@@ -42,19 +45,30 @@ score always needs a key (scoring is LLM work).`);
|
|
|
42
45
|
sourceSystem: source,
|
|
43
46
|
capturedAt: new Date().toISOString(),
|
|
44
47
|
};
|
|
45
|
-
if (rest.includes("--deterministic")) {
|
|
48
|
+
if (rest.includes("--heuristics") || rest.includes("--deterministic")) {
|
|
49
|
+
return parseCall(raw, base);
|
|
50
|
+
}
|
|
51
|
+
// Engine parity with the hosted app: the chunked LLM pipeline runs by
|
|
52
|
+
// default whenever a key resolves (env or stored login), and the free
|
|
53
|
+
// deterministic engine runs when none does. --llm keeps the strict path
|
|
54
|
+
// for scripts (one-time TTY key prompt / actionable error, never silent).
|
|
55
|
+
if (!rest.includes("--llm") && !resolveLlmCredential()) {
|
|
56
|
+
console.error("No LLM key — using deterministic extraction. `fullstackgtm login anthropic|openai` or set ANTHROPIC_API_KEY/OPENAI_API_KEY for chunked LLM insights.");
|
|
46
57
|
return parseCall(raw, base);
|
|
47
58
|
}
|
|
48
|
-
// LLM extraction is the default: bring-your-own-key (Anthropic or OpenAI).
|
|
49
59
|
const credential = await requireLlmCredential();
|
|
50
60
|
const normalized = normalizeTranscript(raw);
|
|
51
61
|
// Elapsed-time spinner on interactive terminals while the model works.
|
|
52
62
|
const wait = startElapsedStatus((elapsed) => `Extracting insights · ${credential.provider} · ${elapsed}`);
|
|
53
63
|
let extraction;
|
|
54
64
|
try {
|
|
55
|
-
|
|
65
|
+
// Chunked langextract-style pipeline: small per-chunk extraction calls
|
|
66
|
+
// beat one whole-transcript pass on long calls (single-shot truncates).
|
|
67
|
+
extraction = await extractInsightsChunked(normalized, {
|
|
56
68
|
...credential,
|
|
57
|
-
model
|
|
69
|
+
// --model wins, then the hosted app's env contract
|
|
70
|
+
// (FSGTM_INSIGHTS_MODEL), then the provider default downstream.
|
|
71
|
+
model: option(rest, "--model") ?? (process.env.FSGTM_INSIGHTS_MODEL || undefined),
|
|
58
72
|
title: base.title,
|
|
59
73
|
});
|
|
60
74
|
}
|