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
@@ -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
- throw new Error(
140
- "--device requires --client-id (the consumer key of a Connected App with device flow enabled).",
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
- throw new Error(
177
- "Salesforce login needs --device --client-id <consumer key>, or --instance-url " +
178
- "<https://yourorg.my.salesforce.com> with the access token piped on stdin.",
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
- throw new Error(
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 # connect your CRM (or: login --via <hosted url>)",
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 = {