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.
Files changed (95) hide show
  1. package/CHANGELOG.md +115 -1
  2. package/INSTALL_FOR_AGENTS.md +13 -11
  3. package/README.md +27 -18
  4. package/dist/backfill.d.ts +86 -0
  5. package/dist/backfill.js +251 -0
  6. package/dist/bin.js +4 -1
  7. package/dist/cli/auth.d.ts +2 -0
  8. package/dist/cli/auth.js +119 -12
  9. package/dist/cli/backfill.d.ts +1 -0
  10. package/dist/cli/backfill.js +125 -0
  11. package/dist/cli/backfillRuns.d.ts +1 -0
  12. package/dist/cli/backfillRuns.js +187 -0
  13. package/dist/cli/call.js +23 -9
  14. package/dist/cli/enrich.js +28 -10
  15. package/dist/cli/fix.js +9 -7
  16. package/dist/cli/help.js +60 -23
  17. package/dist/cli/plans.js +13 -11
  18. package/dist/cli/shared.d.ts +4 -3
  19. package/dist/cli/shared.js +31 -20
  20. package/dist/cli/ui.d.ts +21 -0
  21. package/dist/cli/ui.js +53 -1
  22. package/dist/cli.js +37 -41
  23. package/dist/connector.d.ts +8 -0
  24. package/dist/connector.js +104 -21
  25. package/dist/connectors/hubspot.d.ts +8 -1
  26. package/dist/connectors/hubspot.js +406 -13
  27. package/dist/connectors/hubspotAuth.js +5 -1
  28. package/dist/connectors/linkedin.js +14 -14
  29. package/dist/connectors/salesforce.d.ts +8 -1
  30. package/dist/connectors/salesforce.js +163 -2
  31. package/dist/connectors/stripe.d.ts +37 -0
  32. package/dist/connectors/stripe.js +103 -31
  33. package/dist/enrich.d.ts +7 -0
  34. package/dist/enrich.js +7 -0
  35. package/dist/health.d.ts +11 -69
  36. package/dist/health.js +4 -134
  37. package/dist/healthScore.d.ts +71 -0
  38. package/dist/healthScore.js +143 -0
  39. package/dist/index.d.ts +3 -1
  40. package/dist/index.js +3 -1
  41. package/dist/llm.d.ts +29 -0
  42. package/dist/llm.js +206 -0
  43. package/dist/market.d.ts +39 -1
  44. package/dist/market.js +116 -44
  45. package/dist/marketClassify.d.ts +9 -1
  46. package/dist/marketClassify.js +10 -1
  47. package/dist/marketTaxonomy.d.ts +6 -1
  48. package/dist/marketTaxonomy.js +4 -2
  49. package/dist/mcp-bin.js +29 -0
  50. package/dist/mcp.js +117 -4
  51. package/dist/progress.d.ts +96 -0
  52. package/dist/progress.js +142 -0
  53. package/dist/runReport.d.ts +24 -0
  54. package/dist/runReport.js +139 -4
  55. package/dist/types.d.ts +33 -1
  56. package/docs/api.md +4 -2
  57. package/docs/architecture.md +2 -0
  58. package/docs/linkedin-connector-spec.md +1 -1
  59. package/llms.txt +3 -3
  60. package/package.json +10 -3
  61. package/skills/fullstackgtm/SKILL.md +1 -0
  62. package/src/backfill.ts +340 -0
  63. package/src/bin.ts +5 -1
  64. package/src/cli/auth.ts +135 -15
  65. package/src/cli/backfill.ts +156 -0
  66. package/src/cli/backfillRuns.ts +198 -0
  67. package/src/cli/call.ts +26 -10
  68. package/src/cli/enrich.ts +33 -10
  69. package/src/cli/fix.ts +11 -10
  70. package/src/cli/help.ts +61 -23
  71. package/src/cli/plans.ts +15 -14
  72. package/src/cli/shared.ts +44 -27
  73. package/src/cli/ui.ts +72 -1
  74. package/src/cli.ts +38 -41
  75. package/src/connector.ts +110 -16
  76. package/src/connectors/hubspot.ts +423 -14
  77. package/src/connectors/hubspotAuth.ts +5 -1
  78. package/src/connectors/linkedin.ts +14 -14
  79. package/src/connectors/salesforce.ts +168 -3
  80. package/src/connectors/stripe.ts +154 -34
  81. package/src/enrich.ts +13 -0
  82. package/src/health.ts +14 -213
  83. package/src/healthScore.ts +223 -0
  84. package/src/index.ts +28 -1
  85. package/src/llm.ts +239 -0
  86. package/src/market.ts +157 -44
  87. package/src/marketClassify.ts +18 -1
  88. package/src/marketTaxonomy.ts +10 -2
  89. package/src/mcp-bin.ts +32 -0
  90. package/src/mcp.ts +140 -6
  91. package/src/progress.ts +197 -0
  92. package/src/runReport.ts +159 -4
  93. package/src/types.ts +35 -2
  94. package/docs/dx-punch-list.md +0 -87
  95. package/docs/roadmap-to-1.0.md +0 -211
package/src/cli/help.ts CHANGED
@@ -9,11 +9,15 @@ export function usage() {
9
9
  and apply only explicitly approved operations.
10
10
 
11
11
  Usage:
12
- fullstackgtm login --via <hosted url> pair with a team deployment (recommended)
13
- fullstackgtm login hubspot [--no-validate]
14
- fullstackgtm login hubspot --oauth --client-id <id> [--port ${DEFAULT_LOOPBACK_PORT}] [--scopes a,b]
15
- fullstackgtm login salesforce --device --client-id <consumer key> [--login-url <url>]
16
- fullstackgtm login salesforce --instance-url <url> [--no-validate]
12
+ fullstackgtm login hubspot hosted browser OAuth (default; no app needed)
13
+ fullstackgtm login salesforce hosted browser OAuth (default; no app needed)
14
+ fullstackgtm login hubspot --hosted [--via <url>]
15
+ fullstackgtm login salesforce --hosted [--via <url>]
16
+ fullstackgtm login --via <hosted url> pair with a team deployment (broker only)
17
+ fullstackgtm login hubspot --private-token [--no-validate] advanced: paste private app token
18
+ fullstackgtm login hubspot --oauth --client-id <id> [--port ${DEFAULT_LOOPBACK_PORT}] [--scopes a,b] advanced: BYO HubSpot app
19
+ fullstackgtm login salesforce --device --client-id <consumer key> [--login-url <url>] advanced: BYO Connected App
20
+ fullstackgtm login salesforce --instance-url <url> [--no-validate] advanced: paste access token
17
21
  fullstackgtm login stripe [--no-validate]
18
22
  fullstackgtm login anthropic | openai store an LLM API key for call parse/score
19
23
  fullstackgtm login apollo store an Apollo API key for enrich pulls\n fullstackgtm login pipe0 | explorium | theirstack store a discovery-provider key (theirstack = technographic TAM)\n fullstackgtm login heyreach store a HeyReach key for enrich acquire --source linkedin\n fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|pipe0|explorium|heyreach|broker>
@@ -21,7 +25,7 @@ Usage:
21
25
  Secrets (tokens, client secrets) are NEVER passed as flags — they leak via
22
26
  the process list and shell history. Pipe them on stdin or enter them at the
23
27
  interactive prompt:
24
- echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot
28
+ echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot --private-token
25
29
  fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
26
30
  cold start: scaffold icp.json + enrich.config.json + a
27
31
  PLAYBOOK wired for this workspace (the CLI ships primitives,
@@ -31,16 +35,18 @@ Usage:
31
35
  fullstackgtm report [source options] [audit options] [report options]
32
36
  fullstackgtm diff --before <a.json> --after <b.json> [--json] [--fail-on-new-findings]
33
37
  fullstackgtm merge --input <a.json> --input <b.json> [...] --out <merged.json> [--json]
34
- fullstackgtm call parse --transcript <file> [--title t] [--source fathom|granola|...] [--model m] [--deterministic] [--json|--ndjson] [--out <path>]
38
+ fullstackgtm call parse --transcript <file> [--title t] [--source fathom|granola|...] [--model m] [--llm] [--heuristics] [--json|--ndjson] [--out <path>]
35
39
  fullstackgtm call classify --transcript <file>|--call <parsed.json> [--llm] [--deterministic] [--json]
36
40
  fullstackgtm call score --transcript <file>|--call <parsed.json> [--call-type <t>] [--rubric <rubric.json>] [--model m] [--json|--out <path>]
37
41
  fullstackgtm call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
38
42
  fullstackgtm call plan --transcript <file>|--call <parsed.json> --deal <id> [source options] [--save|--json]
39
- calls become evidence: LLM extraction by default (bring your own
40
- Anthropic or OpenAI key captured once on first use, or
41
- ANTHROPIC_API_KEY/OPENAI_API_KEY, or \`login anthropic|openai\`);
42
- --deterministic uses the free keyword baseline. Then link the call
43
- to its deal and propose governed next-step writes.
43
+ calls become evidence: chunked LLM extraction by default when a
44
+ key resolves (ANTHROPIC_API_KEY/OPENAI_API_KEY or \`login
45
+ anthropic|openai\`), free deterministic fallback when none does;
46
+ --heuristics (alias --deterministic) forces the deterministic
47
+ engine, --llm forces the LLM path. Model: --model, else env
48
+ FSGTM_INSIGHTS_MODEL. Then link the call to its deal and propose
49
+ governed next-step writes.
44
50
  fullstackgtm resolve <account|contact|deal> [--name N] [--domain D] [--email E] [--account-id A] [source options] [--json]
45
51
  the create gate: exit 0 = safe to create, exit 2 = match
46
52
  found (exists/ambiguous) — call before ANY record creation
@@ -80,6 +86,15 @@ Usage:
80
86
  fill-blanks-only patch plan through the normal dry-run →
81
87
  approve → apply gate. refresh re-checks stale stamped fields
82
88
  and proposes updates only where the source value changed.
89
+ fullstackgtm backfill stripe|runs [--since <iso>] [--pipeline <id|label>] [--match-property <name>] [--skip-unmatched] [source options] [--save] [--dry-run] [--json]
90
+ revenue truth backfill: one closed-won deal per PAID Stripe
91
+ invoice (amount = invoice total, close date = paid date,
92
+ associated to the matched customer account, deduped by
93
+ invoice id). Reads Stripe + the CRM snapshot, emits a
94
+ dry-run create_record plan through the normal approve →
95
+ apply gate; unmatched customers get a proposed NEW account
96
+ in the same plan (freemail domains never used;
97
+ --skip-unmatched = report-only). HubSpot-only for now.
83
98
  fullstackgtm signals fetch [--bucket job,…] [--source greenhouse,lever,ashby] [--watchlist <path|crm:seg>] [--keywords …] [--from <file.json|spool.jsonl|spool-dir>] [--save]
84
99
  fullstackgtm signals list [--since 7d] [--bucket b] [--account d] [--unjudged]
85
100
  fullstackgtm signals outcome --account <d> [--touch <id>] --result replied|meeting|bounced|no_reply
@@ -188,13 +203,14 @@ Authentication (checked in order):
188
203
  1. --token-env <name> explicit env var for this invocation (hubspot)
189
204
  2. ambient env HUBSPOT_ACCESS_TOKEN, or SALESFORCE_ACCESS_TOKEN +
190
205
  SALESFORCE_INSTANCE_URL (CI, agent sandboxes)
191
- 3. stored provider login fullstackgtm login <provider>; kept in ~/.fullstackgtm
192
- (hubspot: private app token or loopback OAuth;
193
- salesforce: device flow code on any device, no
194
- secret, silent refresh)
195
- 4. broker pairing fullstackgtm login --via <url>: the team connects the
196
- CRM once in the hosted dashboard; every paired CLI
197
- uses those stored sync credentials from then on
206
+ 3. stored provider login BYO direct credentials kept in ~/.fullstackgtm
207
+ (hubspot: --private-token or loopback OAuth;
208
+ salesforce: pasted token or device flow). These
209
+ direct credentials override broker-hosted tokens.
210
+ 4. hosted/broker login fullstackgtm login hubspot|salesforce (or --hosted):
211
+ hosted browser OAuth stores a broker credential;
212
+ provider tokens are minted server-side. \`login --via
213
+ <url>\` pairs with an existing team deployment broker.
198
214
 
199
215
  Source options (snapshot and audit):
200
216
  --sample Built-in minimal mock CRM data (default)
@@ -280,12 +296,13 @@ export const HELP: Record<string, HelpEntry> = {
280
296
  summary: "connect a provider or LLM key (secrets via stdin/env, never argv)",
281
297
  phase: "Setup",
282
298
  synopsis: [
299
+ "fullstackgtm login hubspot | salesforce hosted browser OAuth (default)",
283
300
  "fullstackgtm login --via <hosted url> pair with a team deployment",
284
- "fullstackgtm login hubspot | salesforce | stripe",
301
+ "fullstackgtm login stripe",
285
302
  "fullstackgtm login anthropic | openai | apollo",
286
303
  ],
287
304
  detail:
288
- "Secrets are NEVER passed as flags (they leak via the process list and shell history) — pipe on stdin or enter at the prompt: `echo \"$TOKEN\" | fullstackgtm login hubspot`.",
305
+ "HubSpot/Salesforce use hosted browser OAuth by default; BYO app/token paths are advanced. Secrets are NEVER passed as flags — pipe on stdin or enter at the prompt: `echo \"$TOKEN\" | fullstackgtm login hubspot --private-token`.",
289
306
  seeAlso: ["doctor", "logout", "profiles"],
290
307
  },
291
308
  logout: {
@@ -442,6 +459,27 @@ export const HELP: Record<string, HelpEntry> = {
442
459
  "Extra `--where` scoping is account-lifted for deals/contacts. `--except-deal-stage <stage>` excludes that stage and any record whose account has an open deal in it, re-verified per record at apply.",
443
460
  seeAlso: ["bulk-update", "plans", "apply"],
444
461
  },
462
+ backfill: {
463
+ summary: "Stripe paid invoices → closed-won deal proposals (governed)",
464
+ phase: "Remediate",
465
+ synopsis: [
466
+ "fullstackgtm backfill stripe|runs [--since <iso>] [--pipeline <id|label>] [--match-property <name>] [--skip-unmatched] [source options] [--save] [--dry-run] [--json]",
467
+ ],
468
+ detail:
469
+ "`backfill runs` replays LOCAL execution history to the paired hosted app (idempotent: deterministic run ids + server-side dedupe): every stored plan's apply runs and the health.jsonl timeline, so the hosted feed and Home health trend cover the engagement from its CLI-only beginning. Requires `login --via`; only what live reporting sends leaves the machine (statuses, counts, timestamps, scores — never CRM field values). `backfill stripe`: billing is the revenue source of truth — proposes ONE closed-won deal per paid Stripe invoice (amount = invoice total, close date = paid date, associated to the matched customer account, deduped by invoice id). Customers are matched to CRM accounts by domain, then exact name; a customer the CRM doesn't know gets an explicit proposed ACCOUNT create in the same plan (billing-email domain used unless it is freemail; a customer with no usable name/domain stays report-only; `--skip-unmatched` restores report-only for all unmatched). Emits a dry-run create_record plan — nothing is written until `plans approve` → `apply`, where the connector re-resolves each invoice id (and each account by domain/name) and creates only on a confirmed miss. Deal creation is HubSpot-only for now.",
470
+ options: [
471
+ ["--since <iso>", "only invoices created on/after this timestamp"],
472
+ ["--pipeline <id|label>", "target deal pipeline (default: the portal's default pipeline)"],
473
+ ["--match-property <name>", "deal dedupe property stamped with the invoice id (default stripe_invoice_id)"],
474
+ ["--skip-unmatched", "do not propose accounts for unmatched customers (report-only, the pre-1.3 behavior)"],
475
+ ["--provider <name>", "CRM snapshot to match against: hubspot | salesforce"],
476
+ ["--input <path>", "match against a saved snapshot JSON instead of a live pull"],
477
+ ["--save", "persist the dry-run plan for approve → apply"],
478
+ ["--json", "machine-readable {plan, counts, unmatched, proposedAccounts} (stripe) or replay summary (runs)"],
479
+ ["--dry-run", "(runs) count what would be replayed without sending anything"],
480
+ ],
481
+ seeAlso: ["enrich", "plans", "apply", "resolve"],
482
+ },
445
483
  enrich: {
446
484
  summary: "governed third-party enrichment (Apollo/Clay), fill-blanks-only",
447
485
  phase: "Remediate",
@@ -457,7 +495,7 @@ export const HELP: Record<string, HelpEntry> = {
457
495
  phase: "Remediate",
458
496
  synopsis: ["fullstackgtm call parse|classify|score|link|plan … (run `call --help` for full options)"],
459
497
  detail:
460
- "`parse` normalizes any transcript into canonical segments + evidence (LLM by default, bring your own key; `--deterministic` for the free baseline). `classify` picks the call type, `score` rates it against the type's rubric, `link` finds the deal, `plan` proposes governed next-step writes.",
498
+ "`parse` normalizes any transcript into canonical segments + evidence — chunked LLM extraction by default when a key resolves (ANTHROPIC_API_KEY/OPENAI_API_KEY or `login anthropic|openai`), free deterministic fallback when none does; `--heuristics` forces the deterministic engine, `--llm` forces the LLM path, model via `--model` or env FSGTM_INSIGHTS_MODEL. `classify` picks the call type, `score` rates it against the type's rubric, `link` finds the deal, `plan` proposes governed next-step writes.",
461
499
  seeAlso: ["plans", "apply"],
462
500
  },
463
501
 
@@ -585,7 +623,7 @@ export function shortUsage() {
585
623
  ["Setup & health", ["login", "logout", "doctor", "capabilities", "robot-docs", "profiles", "health"]],
586
624
  ["Detect — read-only", ["audit", "report", "snapshot", "diff", "rules"]],
587
625
  ["Prevent — gate writes", ["resolve"]],
588
- ["Remediate — governed writes", ["fix", "bulk-update", "dedupe", "reassign", "enrich"]],
626
+ ["Remediate — governed writes", ["fix", "bulk-update", "dedupe", "reassign", "enrich", "backfill"]],
589
627
  ["Calls → evidence", ["call"]],
590
628
  ["Govern — the plan/apply spine", ["suggest", "plans", "apply", "audit-log", "merge"]],
591
629
  ["Market intelligence", ["market", "tam"]],
package/src/cli/plans.ts CHANGED
@@ -14,11 +14,12 @@ import { buildAuditLog, verifyAuditLog } from "../auditLog.ts";
14
14
  import { createFilePlanStore } 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
- import { reportCounts } from "../runReport.ts";
17
+ import { progressReporter, reportCounts } from "../runReport.ts";
18
+ import { APPLY_STAGES, composeListeners, createProgressEmitter } from "../progress.ts";
18
19
  import type { ValueSuggestion } from "../suggest.ts";
19
20
  import type { CanonicalGtmSnapshot, CreateRecordPayload, PatchOperation, PatchPlan, PatchPlanRun } from "../types.ts";
20
21
  import { connectorFor, isOptionValue, numericOption, option, repeatedOption, selectedRules } from "./shared.ts";
21
- import { colorEnabled, createStatusLine, paint, planStatusWord, stylizePlanMarkdown, table, truncateToWidth } from "./ui.ts";
22
+ import { colorEnabled, createProgressRenderer, paint, planStatusWord, stylizePlanMarkdown, table, truncateToWidth } from "./ui.ts";
22
23
  import { unknownSubcommandError } from "./suggest.ts";
23
24
 
24
25
 
@@ -233,23 +234,23 @@ export async function apply(args: string[]) {
233
234
  // A channel (e.g. outbox) renders approved ops to a local artifact and
234
235
  // transmits nothing; a CRM provider writes records. Same governed apply path.
235
236
  const connector = channel ? createChannelConnector(channel) : await connectorFor(provider!, args);
236
- // Interactive terminals get a live safety ticker on stderr while the run
237
- // executes; the summary line it leaves behind is plain text. Inert otherwise.
238
- const ticker = createStatusLine();
237
+ // Interactive terminals get a live apply board on stderr while the run
238
+ // executes (preflight operations results, with a per-op safety ticker);
239
+ // piped runs render nothing. Either way the emitter streams heartbeats to
240
+ // the paired hosted app when a long run is in flight.
241
+ const renderer = createProgressRenderer(APPLY_STAGES);
242
+ const progress = createProgressEmitter(
243
+ composeListeners(renderer.listener, progressReporter()),
244
+ );
239
245
  let run: PatchPlanRun;
240
246
  try {
241
247
  run = await applyPatchPlan(connector, plan, {
242
248
  approvedOperationIds,
243
249
  valueOverrides,
244
- onOperation: ticker.active
245
- ? (progress) =>
246
- ticker.set(
247
- `Applying ${progress.completed}/${progress.total} · ✓ ${progress.applied} applied · ${progress.failed} failed · ${progress.conflicts} conflict${progress.conflicts === 1 ? "" : "s"} · ${progress.skipped} skipped`,
248
- )
249
- : undefined,
250
+ progress,
250
251
  });
251
252
  } finally {
252
- ticker.done();
253
+ renderer.done();
253
254
  }
254
255
  if (planId && store) {
255
256
  await store.recordRun(planId, run);
@@ -272,9 +273,9 @@ export async function apply(args: string[]) {
272
273
  for (const result of run.results) applyTally[result.status] = (applyTally[result.status] ?? 0) + 1;
273
274
  reportCounts(applyTally);
274
275
 
275
- // Rich-only closing line on stderr (ticker.active implies an interactive,
276
+ // Rich-only closing line on stderr (renderer.active implies an interactive,
276
277
  // color-safe stderr): the one-glance outcome of the run.
277
- if (ticker.active) {
278
+ if (renderer.active) {
278
279
  const pe = paint(true);
279
280
  const badge =
280
281
  run.status === "applied"
package/src/cli/shared.ts CHANGED
@@ -2,19 +2,22 @@
2
2
 
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { resolve } from "node:path";
5
- import { createHubspotConnector } from "../connectors/hubspot.ts";
6
- import { createSalesforceConnector } from "../connectors/salesforce.ts";
7
- import { createStripeConnector } from "../connectors/stripe.ts";
8
5
  import { credentialsPath, getCredential, resolveHubspotConnection, resolveSalesforceConnection, storeCredential } from "../credentials.ts";
9
- import { generateDemoSnapshot } from "../demo.ts";
10
6
  import { builtinAuditRules } from "../rules.ts";
11
- import { sampleSnapshot } from "../sampleData.ts";
12
7
  import { detectProviderFromKey, resolveLlmCredential, validateLlmKey, type LlmProvider } from "../llm.ts";
13
8
  import { parseIcp, type Icp } from "../icp.ts";
14
9
  import { nearest } from "./suggest.ts";
15
10
  import type { FieldMappings } from "../mappings.ts";
16
- import type { CanonicalGtmSnapshot, GtmConnector, SnapshotProgress } from "../types.ts";
17
- import { createStatusLine, formatCount, formatDuration } from "./ui.ts";
11
+ import type { CanonicalGtmSnapshot, GtmConnector } from "../types.ts";
12
+ import {
13
+ composeListeners,
14
+ createProgressEmitter,
15
+ SNAPSHOT_PULL_STAGES,
16
+ STRIPE_SNAPSHOT_STAGES,
17
+ type ProgressEmitter,
18
+ } from "../progress.ts";
19
+ import { progressReporter } from "../runReport.ts";
20
+ import { createProgressRenderer } from "./ui.ts";
18
21
 
19
22
 
20
23
  export function option(args: string[], name: string) {
@@ -112,27 +115,32 @@ async function salesforceConnection() {
112
115
  export async function connectorFor(
113
116
  provider: string,
114
117
  args: string[],
115
- onProgress?: (progress: SnapshotProgress) => void,
118
+ progress?: ProgressEmitter,
116
119
  ): Promise<GtmConnector> {
120
+ // Connector modules are the two largest files in dist (~100KB together) and
121
+ // load lazily here: only live `--provider` runs pay for them, not --demo/
122
+ // --sample/--input runs or the always-eager help/version paths.
117
123
  if (provider === "hubspot") {
118
124
  const connection = await hubspotConnection(args);
125
+ const { createHubspotConnector } = await import("../connectors/hubspot.ts");
119
126
  return createHubspotConnector({
120
127
  getAccessToken: () => connection.accessToken,
121
128
  fieldMappings: (connection.fieldMappings as FieldMappings | undefined) ?? undefined,
122
129
  // Point at a mock/proxy HubSpot (tests, evals, request-recording).
123
130
  apiBaseUrl: process.env.HUBSPOT_API_BASE_URL,
124
- onProgress,
131
+ progress,
125
132
  });
126
133
  }
127
134
  if (provider === "salesforce") {
128
135
  const connection = await salesforceConnection();
136
+ const { createSalesforceConnector } = await import("../connectors/salesforce.ts");
129
137
  return createSalesforceConnector({
130
138
  getConnection: () => connection,
131
139
  fieldMappings:
132
140
  ((connection as { fieldMappings?: unknown }).fieldMappings as
133
141
  | FieldMappings
134
142
  | undefined) ?? undefined,
135
- onProgress,
143
+ progress,
136
144
  });
137
145
  }
138
146
  if (provider === "stripe") {
@@ -143,7 +151,8 @@ export async function connectorFor(
143
151
  "No Stripe credentials. Run `echo \"$STRIPE_KEY\" | fullstackgtm login stripe` or set STRIPE_SECRET_KEY. A restricted key with read access to Customers and Subscriptions is enough. (`fullstackgtm doctor` shows credential status.)",
144
152
  );
145
153
  }
146
- return createStripeConnector({ getApiKey: () => key });
154
+ const { createStripeConnector } = await import("../connectors/stripe.ts");
155
+ return createStripeConnector({ getApiKey: () => key, progress });
147
156
  }
148
157
  const providerSuggestion = nearest(provider.toLowerCase(), ["hubspot", "salesforce", "stripe"], 3);
149
158
  throw new Error(
@@ -152,37 +161,45 @@ export async function connectorFor(
152
161
  );
153
162
  }
154
163
 
155
- export async function readSnapshot(args: string[]): Promise<CanonicalGtmSnapshot> {
164
+ export async function readSnapshot(
165
+ args: string[],
166
+ progress?: ProgressEmitter,
167
+ ): Promise<CanonicalGtmSnapshot> {
156
168
  const provider = option(args, "--provider");
157
169
  if (provider) {
170
+ // A verb driving its own progress board (e.g. `backfill stripe`) passes
171
+ // its emitter through; the pull's stage/items events flow into it and no
172
+ // second board is painted.
173
+ if (progress) {
174
+ const connector = await connectorFor(provider, args, progress);
175
+ return connector.fetchSnapshot();
176
+ }
158
177
  // Live pulls can page for minutes with nothing on screen. On an
159
- // interactive terminal, a stderr status line shows the running tally;
160
- // piped/CI/agent runs get an inert no-op (zero bytes written).
161
- const status = createStatusLine();
162
- const startedAt = Date.now();
163
- const connector = await connectorFor(
164
- provider,
165
- args,
166
- status.active
167
- ? (progress) =>
168
- status.set(
169
- `Pulling ${progress.objectType}s from ${provider}… ${formatCount(progress.fetched)} fetched · ${formatDuration(Date.now() - startedAt)}`,
170
- )
171
- : undefined,
178
+ // interactive terminal, a stderr checklist ticks through the pull stages
179
+ // with running tallies; piped/CI/agent runs render nothing (zero bytes
180
+ // written). Either way the emitter feeds the broker heartbeat when the
181
+ // CLI is paired, so long pulls tick live on the hosted dashboard too.
182
+ const renderer = createProgressRenderer(
183
+ provider === "stripe" ? STRIPE_SNAPSHOT_STAGES : SNAPSHOT_PULL_STAGES,
184
+ );
185
+ const emitter = createProgressEmitter(
186
+ composeListeners(renderer.listener, progressReporter()),
172
187
  );
188
+ const connector = await connectorFor(provider, args, emitter);
173
189
  try {
174
190
  return await connector.fetchSnapshot();
175
191
  } finally {
176
- status.done();
192
+ renderer.done();
177
193
  }
178
194
  }
179
195
  if (args.includes("--demo")) {
196
+ const { generateDemoSnapshot } = await import("../demo.ts");
180
197
  return generateDemoSnapshot({
181
198
  seed: numericOption(args, "--seed"),
182
199
  today: option(args, "--today") ?? undefined,
183
200
  });
184
201
  }
185
- if (args.includes("--sample")) return sampleSnapshot;
202
+ if (args.includes("--sample")) return (await import("../sampleData.ts")).sampleSnapshot;
186
203
  const input = option(args, "--input");
187
204
  if (!input) {
188
205
  throw new Error(
package/src/cli/ui.ts CHANGED
@@ -11,6 +11,8 @@
11
11
  // - NO_COLOR (https://no-color.org), TERM=dumb, and CI disable styling;
12
12
  // FORCE_COLOR (non-"0") re-enables it, matching the wider ecosystem.
13
13
 
14
+ import type { ProgressEvent, ProgressListener, ProgressSnapshot } from "../progress.ts";
15
+
14
16
  type StreamLike = {
15
17
  isTTY?: boolean;
16
18
  columns?: number;
@@ -358,6 +360,76 @@ export function createChecklist(
358
360
  };
359
361
  }
360
362
 
363
+ export type ProgressRenderer = {
364
+ /**
365
+ * Feed to `createProgressEmitter` (compose with the broker streaming
366
+ * listener via `composeListeners` when the run should also heartbeat).
367
+ */
368
+ listener: ProgressListener;
369
+ /** Stop animating and erase the board (callers print their own summary). */
370
+ done(): void;
371
+ readonly active: boolean;
372
+ };
373
+
374
+ const NOOP_RENDERER: ProgressRenderer = { listener: () => {}, done() {}, active: false };
375
+
376
+ /**
377
+ * Adapter from the shared progress vocabulary (src/progress.ts) to the
378
+ * existing TTY primitives: `stage` events advance a live checklist (○ → ⠹ →
379
+ * ✓), while `items` / `note` / `opResult` / `meter` events annotate the
380
+ * running stage ("contacts 1,200/4,800", the apply ticker tallies, a fuel
381
+ * gauge). Outside an interactive TTY this is an inert no-op — piped/CI/agent
382
+ * output stays byte-identical (streaming listeners composed alongside still
383
+ * run; rendering is presentation only).
384
+ */
385
+ export function createProgressRenderer(
386
+ stages: readonly string[],
387
+ stream: StreamLike = process.stderr,
388
+ env: UiEnv = process.env,
389
+ ): ProgressRenderer {
390
+ const board = createChecklist(
391
+ stages.map((stage) => ({ id: stage, label: stage })),
392
+ stream,
393
+ env,
394
+ );
395
+ if (!board.active) return NOOP_RENDERER;
396
+ let current: string | null = null;
397
+ const noteFor = (event: ProgressEvent, snapshot: ProgressSnapshot): string | undefined => {
398
+ if (event.kind === "items") {
399
+ return event.total !== undefined
400
+ ? `${formatCount(event.done)}/${formatCount(event.total)}`
401
+ : formatCount(event.done);
402
+ }
403
+ if (event.kind === "note") return event.note;
404
+ if (event.kind === "opResult") {
405
+ return `${GLYPH.ok} ${formatCount(snapshot.opsApplied)} applied · ${formatCount(snapshot.opsFailed)} failed · ${formatCount(snapshot.opsSkipped)} skipped`;
406
+ }
407
+ if (event.kind === "meter") {
408
+ const fraction = event.budget > 0 ? event.spent / event.budget : 0;
409
+ return `${formatBar(fraction, 10)} ${formatCount(event.spent)}/${formatCount(event.budget)} ${event.unit}`;
410
+ }
411
+ return undefined;
412
+ };
413
+ return {
414
+ listener(event, snapshot) {
415
+ if (event.kind === "stage") {
416
+ // Advance the board: close out the previous stage, spin the new one.
417
+ if (current && current !== event.stage) board.update(current, "ok");
418
+ current = event.stage;
419
+ board.update(current, "running");
420
+ return;
421
+ }
422
+ // Events for a stage the board doesn't know (e.g. a nested pull inside a
423
+ // larger flow) are ignored by createChecklist's unknown-id no-op.
424
+ if (current) board.update(current, "running", noteFor(event, snapshot));
425
+ },
426
+ done() {
427
+ board.done();
428
+ },
429
+ active: true,
430
+ };
431
+ }
432
+
361
433
  /** Truncate to `max` display characters, ending in "…" when cut. */
362
434
  export function truncateToWidth(text: string, max: number): string {
363
435
  if (max <= 0) return "";
@@ -421,6 +493,5 @@ export function stylizePlanMarkdown(text: string, p: Paint): string {
421
493
 
422
494
  /** Strip ANSI escape sequences (test helper + safety net for width math). */
423
495
  export function stripAnsi(text: string): string {
424
- // eslint-disable-next-line no-control-regex
425
496
  return text.replace(/\u001b\[[0-9;]*[A-Za-z]/g, "");
426
497
  }
package/src/cli.ts CHANGED
@@ -1,22 +1,15 @@
1
1
  import { activeProfile, listProfiles, setActiveProfile } from "./credentials.ts";
2
- import { audit, healthCommand, reportCommand, rulesCommand, snapshotCommand } from "./cli/audit.ts";
3
- import { doctorCommand, login, logout } from "./cli/auth.ts";
4
2
  import { capabilitiesCommand, printCommandHelpJson, robotDocsCommand, unknownCommandEnvelope } from "./cli/capabilities.ts";
5
- import { callCommand } from "./cli/call.ts";
6
- import { draftCommand } from "./cli/draft.ts";
7
- import { enrichCommand } from "./cli/enrich.ts";
8
- import { bulkUpdateCommand, dedupeCommand, fixCommand, reassignCommand, resolveCommand, suggest } from "./cli/fix.ts";
9
3
  import { BESPOKE_HELP, commandHelp, HELP, shortUsage, stylizeShortUsage, usage } from "./cli/help.ts";
10
- import { icpCommand } from "./cli/icp.ts";
11
- import { initCommand } from "./cli/init.ts";
12
- import { marketCommand } from "./cli/market.ts";
13
- import { apply, auditLogCommand, diffCommand, mergeCommand, plansCommand } from "./cli/plans.ts";
14
- import { scheduleCommand } from "./cli/schedule.ts";
15
4
  import { readPackageInfo } from "./cli/shared.ts";
16
5
  import { correctedCommand, detectFlagTypo, suggestCommand, unknownFlagEnvelope } from "./cli/suggest.ts";
17
6
  import { colorEnabled, paint } from "./cli/ui.ts";
18
- import { signalsCommand } from "./cli/signals.ts";
19
- import { tamCommand } from "./cli/tam.ts";
7
+
8
+ // Verb modules load lazily inside their dispatch branch below. The dispatcher
9
+ // used to import all of them eagerly, so `--version` compiled the full
10
+ // 78-module graph (audit, market, enrich, signals, schedule, …) before
11
+ // printing one line. Each branch now `await import()`s only its own module;
12
+ // help/typo/error paths and the exports above stay eager and byte-identical.
20
13
 
21
14
 
22
15
  /**
@@ -129,95 +122,99 @@ export async function runCli(argv: string[]) {
129
122
  return;
130
123
  }
131
124
  if (command === "login") {
132
- await login(args);
125
+ await (await import("./cli/auth.ts")).login(args);
133
126
  return;
134
127
  }
135
128
  if (command === "logout") {
136
- logout(args);
129
+ (await import("./cli/auth.ts")).logout(args);
137
130
  return;
138
131
  }
139
132
  if (command === "snapshot") {
140
- await snapshotCommand(args);
133
+ await (await import("./cli/audit.ts")).snapshotCommand(args);
141
134
  return;
142
135
  }
143
136
  if (command === "audit") {
144
- await audit(args);
137
+ await (await import("./cli/audit.ts")).audit(args);
145
138
  return;
146
139
  }
147
140
  if (command === "report") {
148
- await reportCommand(args);
141
+ await (await import("./cli/audit.ts")).reportCommand(args);
149
142
  return;
150
143
  }
151
144
  if (command === "rules") {
152
- await rulesCommand(args);
145
+ await (await import("./cli/audit.ts")).rulesCommand(args);
153
146
  return;
154
147
  }
155
148
  if (command === "doctor") {
156
- await doctorCommand(args);
149
+ await (await import("./cli/auth.ts")).doctorCommand(args);
157
150
  return;
158
151
  }
159
152
  if (command === "health") {
160
- healthCommand(args);
153
+ (await import("./cli/audit.ts")).healthCommand(args);
161
154
  return;
162
155
  }
163
156
  if (command === "suggest") {
164
- await suggest(args);
157
+ await (await import("./cli/fix.ts")).suggest(args);
165
158
  return;
166
159
  }
167
160
  if (command === "call") {
168
- await callCommand(args);
161
+ await (await import("./cli/call.ts")).callCommand(args);
169
162
  return;
170
163
  }
171
164
  if (command === "resolve") {
172
- await resolveCommand(args);
165
+ await (await import("./cli/fix.ts")).resolveCommand(args);
173
166
  return;
174
167
  }
175
168
  if (command === "bulk-update") {
176
- await bulkUpdateCommand(args);
169
+ await (await import("./cli/fix.ts")).bulkUpdateCommand(args);
177
170
  return;
178
171
  }
179
172
  if (command === "dedupe") {
180
- await dedupeCommand(args);
173
+ await (await import("./cli/fix.ts")).dedupeCommand(args);
181
174
  return;
182
175
  }
183
176
  if (command === "reassign") {
184
- await reassignCommand(args);
177
+ await (await import("./cli/fix.ts")).reassignCommand(args);
185
178
  return;
186
179
  }
187
180
  if (command === "fix") {
188
- await fixCommand(args);
181
+ await (await import("./cli/fix.ts")).fixCommand(args);
189
182
  return;
190
183
  }
191
184
  if (command === "market") {
192
- await marketCommand(args);
185
+ await (await import("./cli/market.ts")).marketCommand(args);
193
186
  return;
194
187
  }
195
188
  if (command === "enrich") {
196
- await enrichCommand(args);
189
+ await (await import("./cli/enrich.ts")).enrichCommand(args);
190
+ return;
191
+ }
192
+ if (command === "backfill") {
193
+ await (await import("./cli/backfill.ts")).backfillCommand(args);
197
194
  return;
198
195
  }
199
196
  if (command === "init") {
200
- initCommand(args);
197
+ (await import("./cli/init.ts")).initCommand(args);
201
198
  return;
202
199
  }
203
200
  if (command === "tam") {
204
- await tamCommand(args);
201
+ await (await import("./cli/tam.ts")).tamCommand(args);
205
202
  return;
206
203
  }
207
204
  if (command === "icp") {
208
- await icpCommand(args);
205
+ await (await import("./cli/icp.ts")).icpCommand(args);
209
206
  return;
210
207
  }
211
208
  if (command === "signals") {
212
- await signalsCommand(args);
209
+ await (await import("./cli/signals.ts")).signalsCommand(args);
213
210
  return;
214
211
  }
215
212
  if (command === "draft") {
216
- await draftCommand(args);
213
+ await (await import("./cli/draft.ts")).draftCommand(args);
217
214
  return;
218
215
  }
219
216
  if (command === "schedule") {
220
- await scheduleCommand(args);
217
+ await (await import("./cli/schedule.ts")).scheduleCommand(args);
221
218
  return;
222
219
  }
223
220
  if (command === "profiles") {
@@ -225,23 +222,23 @@ export async function runCli(argv: string[]) {
225
222
  return;
226
223
  }
227
224
  if (command === "diff") {
228
- await diffCommand(args);
225
+ await (await import("./cli/plans.ts")).diffCommand(args);
229
226
  return;
230
227
  }
231
228
  if (command === "merge") {
232
- await mergeCommand(args);
229
+ await (await import("./cli/plans.ts")).mergeCommand(args);
233
230
  return;
234
231
  }
235
232
  if (command === "plans") {
236
- await plansCommand(args);
233
+ await (await import("./cli/plans.ts")).plansCommand(args);
237
234
  return;
238
235
  }
239
236
  if (command === "audit-log") {
240
- await auditLogCommand(args);
237
+ await (await import("./cli/plans.ts")).auditLogCommand(args);
241
238
  return;
242
239
  }
243
240
  if (command === "apply") {
244
- await apply(args);
241
+ await (await import("./cli/plans.ts")).apply(args);
245
242
  return;
246
243
  }
247
244