fullstackgtm 0.10.0 → 0.11.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/dist/cli.js CHANGED
@@ -9,13 +9,15 @@ import { DEFAULT_LOOPBACK_PORT, openInBrowser, runHubspotLoopbackLogin, validate
9
9
  import { createSalesforceConnector } from "./connectors/salesforce.js";
10
10
  import { createStripeConnector } from "./connectors/stripe.js";
11
11
  import { pollSalesforceDeviceLogin, startSalesforceDeviceLogin, validateSalesforceToken, } from "./connectors/salesforceAuth.js";
12
- import { credentialsPath, deleteCredential, getCredential, resolveHubspotConnection, resolveSalesforceConnection, storeCredential, } from "./credentials.js";
12
+ import { activeProfile, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, listProfiles, resolveHubspotConnection, resolveSalesforceConnection, setActiveProfile, storeCredential, } from "./credentials.js";
13
13
  import { generateDemoSnapshot } from "./demo.js";
14
14
  import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
15
15
  import { mergeSnapshots } from "./merge.js";
16
16
  import { createFilePlanStore } from "./planStore.js";
17
+ import { auditReportToHtml, auditReportToMarkdown } from "./report.js";
17
18
  import { builtinAuditRules } from "./rules.js";
18
19
  import { sampleSnapshot } from "./sampleData.js";
20
+ import { suggestValues } from "./suggest.js";
19
21
  function usage() {
20
22
  return `FullStackGTM — audit GTM data across providers, propose reviewable patch plans,
21
23
  and apply only explicitly approved operations.
@@ -35,14 +37,28 @@ Usage:
35
37
  echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot
36
38
  fullstackgtm snapshot [source options] [--since <iso>] [--out <path> | --archive <dir>]
37
39
  fullstackgtm audit [source options] [audit options] [--save]
40
+ fullstackgtm report [source options] [audit options] [report options]
38
41
  fullstackgtm diff --before <a.json> --after <b.json> [--json] [--fail-on-new-findings]
39
42
  fullstackgtm merge --input <a.json> --input <b.json> [...] --out <merged.json> [--json]
40
- fullstackgtm plans list [--status <s>] | show <id> | approve <id> --operations <ids|all> | reject <id>
43
+ fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
44
+ derive values for requires_human_* placeholders
45
+ from snapshot evidence, with confidence + reasons
46
+ fullstackgtm plans list [--status <s>] | show <id> | reject <id>
47
+ fullstackgtm plans approve <id> --operations <ids|all> [--value <opId>=<v>]
48
+ fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low] [--include-creates]
41
49
  fullstackgtm apply --plan-id <id> --provider <name>
42
50
  fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [options]
43
51
  fullstackgtm rules [--json]
52
+ fullstackgtm profiles [--json] list credential profiles
44
53
  fullstackgtm doctor [--json] check install, credentials, and next step
45
54
 
55
+ Profiles (multi-organization use):
56
+ --profile <name> Scope credentials AND stored plans to a named profile
57
+ (also: FULLSTACKGTM_PROFILE). One profile per client
58
+ org keeps logins isolated and prevents a plan proposed
59
+ against one CRM from being applied through another's
60
+ credentials. Omitted = the default profile.
61
+
46
62
  Plan lifecycle:
47
63
  audit --save persists the dry-run plan to ~/.fullstackgtm/plans. Approve
48
64
  specific operations (optionally with --value <opId>=<v> for placeholders),
@@ -79,6 +95,17 @@ Audit options:
79
95
  --stale-days <n> Days without activity before an open deal is stale
80
96
  --fail-on <severity> Exit 2 if any finding is at or above info|warning|critical
81
97
 
98
+ Report options (report renders the audit as a client-ready deliverable):
99
+ --plan <path> Render an existing plan JSON instead of re-auditing
100
+ (add --input <snapshot.json> for record counts)
101
+ --client <name> Organization name shown in the heading and summary
102
+ --title <text> Report heading (default "GTM Data Health Report")
103
+ --prepared-by <name> Attribution shown in the footer
104
+ --format <fmt> markdown (default) or html (self-contained, printable;
105
+ inferred from an --out path ending in .html)
106
+ --max-examples <n> Example records listed per rule (default 10)
107
+ --out <path> Write the report to a file instead of stdout
108
+
82
109
  Apply options:
83
110
  --plan <path> Patch plan JSON produced by \`audit --out\`
84
111
  --provider hubspot Connector to apply through
@@ -132,7 +159,7 @@ async function hubspotConnection(args) {
132
159
  const stored = await resolveHubspotConnection();
133
160
  if (stored)
134
161
  return stored;
135
- throw new Error("No HubSpot credentials. Run `fullstackgtm login hubspot`, pair with a hosted deployment via `fullstackgtm login --via <url>`, or set HUBSPOT_ACCESS_TOKEN.");
162
+ throw new Error("No HubSpot credentials. Run `fullstackgtm login hubspot`, pair with a hosted deployment via `fullstackgtm login --via <url>`, or set HUBSPOT_ACCESS_TOKEN. (`fullstackgtm doctor` shows credential status; see the README's \"Connect your CRM\" section for the private-app scopes to grant.)");
136
163
  }
137
164
  async function salesforceConnection() {
138
165
  if (process.env.SALESFORCE_ACCESS_TOKEN && process.env.SALESFORCE_INSTANCE_URL) {
@@ -144,7 +171,7 @@ async function salesforceConnection() {
144
171
  const stored = await resolveSalesforceConnection();
145
172
  if (stored)
146
173
  return stored;
147
- throw new Error("No Salesforce credentials. Run `fullstackgtm login salesforce`, pair with a hosted deployment via `fullstackgtm login --via <url>`, or set SALESFORCE_ACCESS_TOKEN and SALESFORCE_INSTANCE_URL.");
174
+ throw new Error("No Salesforce credentials. Run `fullstackgtm login salesforce`, pair with a hosted deployment via `fullstackgtm login --via <url>`, or set SALESFORCE_ACCESS_TOKEN and SALESFORCE_INSTANCE_URL. (`fullstackgtm doctor` shows credential status; device-flow login needs an admin-created Connected App — see the README's \"Connect your CRM\" section.)");
148
175
  }
149
176
  async function connectorFor(provider, args) {
150
177
  if (provider === "hubspot") {
@@ -164,7 +191,7 @@ async function connectorFor(provider, args) {
164
191
  if (provider === "stripe") {
165
192
  const key = process.env.STRIPE_SECRET_KEY ?? getCredential("stripe")?.accessToken;
166
193
  if (!key) {
167
- throw new Error("No Stripe credentials. Run `fullstackgtm login stripe --token sk_...` or set STRIPE_SECRET_KEY.");
194
+ throw new Error("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.)");
168
195
  }
169
196
  return createStripeConnector({ getApiKey: () => key });
170
197
  }
@@ -288,6 +315,60 @@ async function audit(args) {
288
315
  process.exitCode = 2;
289
316
  }
290
317
  }
318
+ /**
319
+ * Render an audit as a client-facing deliverable. Same sources and audit
320
+ * options as `audit`; `--plan` instead renders an existing plan JSON without
321
+ * re-fetching (useful for a plan produced earlier or by another machine).
322
+ */
323
+ async function reportCommand(args) {
324
+ const loaded = loadConfig(option(args, "--config") ?? undefined);
325
+ const configuredRules = await resolveConfiguredRules(loaded);
326
+ let plan;
327
+ let snapshot;
328
+ const planPath = option(args, "--plan");
329
+ if (planPath) {
330
+ plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8"));
331
+ const input = option(args, "--input");
332
+ if (input) {
333
+ snapshot = JSON.parse(readFileSync(resolve(process.cwd(), input), "utf8"));
334
+ }
335
+ }
336
+ else {
337
+ snapshot = await readSnapshot(args);
338
+ const policy = mergePolicy(defaultPolicy(), loaded?.config);
339
+ const today = option(args, "--today");
340
+ if (today)
341
+ policy.today = today;
342
+ const staleDealDays = numericOption(args, "--stale-days");
343
+ if (staleDealDays !== undefined)
344
+ policy.staleDealDays = staleDealDays;
345
+ plan = auditSnapshot(snapshot, policy, selectedRules(args, configuredRules));
346
+ }
347
+ const reportOptions = {
348
+ title: option(args, "--title") ?? undefined,
349
+ clientName: option(args, "--client") ?? undefined,
350
+ preparedBy: option(args, "--prepared-by") ?? undefined,
351
+ date: option(args, "--today") ?? undefined,
352
+ maxExamplesPerRule: numericOption(args, "--max-examples"),
353
+ rules: configuredRules,
354
+ snapshot,
355
+ };
356
+ const out = option(args, "--out");
357
+ const format = option(args, "--format") ?? (out?.endsWith(".html") ? "html" : "markdown");
358
+ if (format !== "markdown" && format !== "html") {
359
+ throw new Error("--format must be markdown or html");
360
+ }
361
+ const rendered = format === "html"
362
+ ? auditReportToHtml(plan, reportOptions)
363
+ : auditReportToMarkdown(plan, reportOptions);
364
+ if (out) {
365
+ writeFileSync(resolve(process.cwd(), out), rendered);
366
+ console.log(`Wrote ${format} report (${plan.findings.length} findings) to ${out}`);
367
+ }
368
+ else {
369
+ console.log(rendered.trimEnd());
370
+ }
371
+ }
291
372
  async function rulesCommand(args) {
292
373
  const loaded = loadConfig(option(args, "--config") ?? undefined);
293
374
  const rules = await resolveConfiguredRules(loaded);
@@ -322,6 +403,78 @@ function parseValueOverrides(args) {
322
403
  }
323
404
  return valueOverrides;
324
405
  }
406
+ async function suggest(args) {
407
+ const planId = option(args, "--plan-id");
408
+ const planPath = option(args, "--plan");
409
+ if (!planId && !planPath)
410
+ throw new Error("suggest requires --plan <path> or --plan-id <id>");
411
+ let plan;
412
+ if (planId) {
413
+ const stored = await createFilePlanStore().get(planId);
414
+ if (!stored)
415
+ throw new Error(`No stored plan with id ${planId}.`);
416
+ plan = stored.plan;
417
+ }
418
+ else {
419
+ plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8"));
420
+ }
421
+ const snapshot = await readSnapshot(args);
422
+ const suggestions = suggestValues(plan, snapshot);
423
+ const payload = { planId: planId ?? planPath, suggestions };
424
+ const outPath = option(args, "--out");
425
+ if (outPath)
426
+ writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(payload, null, 2)}\n`);
427
+ if (args.includes("--json")) {
428
+ console.log(JSON.stringify(payload, null, 2));
429
+ return;
430
+ }
431
+ if (suggestions.length === 0) {
432
+ console.log("No requires_human_* placeholder operations in this plan — nothing to suggest.");
433
+ return;
434
+ }
435
+ const byConfidence = {};
436
+ for (const s of suggestions)
437
+ byConfidence[s.confidence] = (byConfidence[s.confidence] ?? 0) + 1;
438
+ console.log(`Suggestions for ${suggestions.length} placeholder operation(s):\n`);
439
+ for (const s of suggestions) {
440
+ const marker = s.confidence === "high" ? "✓" : s.confidence === "low" ? "~" : s.confidence === "create" ? "+" : "✗";
441
+ console.log(`${marker} [${s.confidence}] ${s.operationId} ${s.objectName ?? s.objectId}`);
442
+ console.log(` ${s.suggestedValue ? `→ ${s.suggestedValue}` : "(no suggestion)"} — ${s.reason}`);
443
+ }
444
+ console.log(`\n${Object.entries(byConfidence).map(([k, v]) => `${k}: ${v}`).join(" · ")}`);
445
+ if (planId && (byConfidence.high ?? 0) > 0 && !outPath) {
446
+ console.log(`\nChain it:\n fullstackgtm suggest --plan-id ${planId} ${snapshotSourceHint(args)}--out suggestions.json\n fullstackgtm plans approve ${planId} --values-from suggestions.json\n fullstackgtm apply --plan-id ${planId} --provider <name>`);
447
+ }
448
+ }
449
+ function snapshotSourceHint(args) {
450
+ const provider = option(args, "--provider");
451
+ if (provider)
452
+ return `--provider ${provider} `;
453
+ const input = option(args, "--input");
454
+ if (input)
455
+ return `--input ${input} `;
456
+ return "";
457
+ }
458
+ function readSuggestionValues(path, minConfidence, includeCreates) {
459
+ const raw = JSON.parse(readFileSync(resolve(process.cwd(), path), "utf8"));
460
+ if (!Array.isArray(raw.suggestions)) {
461
+ throw new Error(`${path} is not a suggestions file (expected { suggestions: [...] } from \`fullstackgtm suggest --out\`).`);
462
+ }
463
+ const accepted = new Set(minConfidence === "low" ? ["high", "low"] : ["high"]);
464
+ const overrides = {};
465
+ let skipped = 0;
466
+ for (const s of raw.suggestions) {
467
+ if (!s.suggestedValue)
468
+ continue;
469
+ if (accepted.has(s.confidence) || (includeCreates && s.confidence === "create")) {
470
+ overrides[s.operationId] = s.suggestedValue;
471
+ }
472
+ else {
473
+ skipped += 1;
474
+ }
475
+ }
476
+ return { overrides, skipped };
477
+ }
325
478
  async function apply(args) {
326
479
  const provider = option(args, "--provider");
327
480
  if (!provider)
@@ -484,17 +637,43 @@ async function plansCommand(args) {
484
637
  if (subcommand === "approve") {
485
638
  const planId = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
486
639
  if (!planId)
487
- throw new Error("Usage: fullstackgtm plans approve <planId> --operations <ids|all>");
640
+ throw new Error("Usage: fullstackgtm plans approve <planId> --operations <ids|all> | --values-from <suggestions.json>");
488
641
  const operations = option(rest, "--operations");
489
- if (!operations)
490
- throw new Error("plans approve requires --operations <ids|all>");
642
+ const valuesFrom = option(rest, "--values-from");
643
+ if (!operations && !valuesFrom) {
644
+ throw new Error("plans approve requires --operations <ids|all> and/or --values-from <suggestions.json>");
645
+ }
491
646
  const stored = await store.get(planId);
492
647
  if (!stored)
493
648
  throw new Error(`No stored plan with id ${planId}.`);
649
+ // Values from a `fullstackgtm suggest --out` file. High-confidence only by
650
+ // default; widen with --min-confidence low, opt into record-creating
651
+ // values (create:<Name>) with --include-creates. Explicit --value wins.
652
+ let fileOverrides = {};
653
+ if (valuesFrom) {
654
+ const minConfidence = option(rest, "--min-confidence") ?? "high";
655
+ if (!["high", "low"].includes(minConfidence)) {
656
+ throw new Error("--min-confidence must be high or low");
657
+ }
658
+ const { overrides, skipped } = readSuggestionValues(valuesFrom, minConfidence, rest.includes("--include-creates"));
659
+ fileOverrides = overrides;
660
+ if (Object.keys(overrides).length === 0) {
661
+ throw new Error(`No suggestions in ${valuesFrom} meet the confidence bar (${skipped} below it). Re-run with --min-confidence low or --include-creates, or pass explicit --value overrides.`);
662
+ }
663
+ if (skipped > 0) {
664
+ console.log(`Skipped ${skipped} suggestion(s) below the confidence bar (widen with --min-confidence low / --include-creates).`);
665
+ }
666
+ }
667
+ const explicitOverrides = parseValueOverrides(rest);
494
668
  const operationIds = operations === "all"
495
669
  ? stored.plan.operations.map((operation) => operation.id)
496
- : operations.split(",").map((id) => id.trim()).filter(Boolean);
497
- const updated = await store.approveOperations(planId, operationIds, parseValueOverrides(rest));
670
+ : operations
671
+ ? operations.split(",").map((id) => id.trim()).filter(Boolean)
672
+ : Object.keys(fileOverrides);
673
+ const updated = await store.approveOperations(planId, operationIds, {
674
+ ...fileOverrides,
675
+ ...explicitOverrides,
676
+ });
498
677
  console.log(`Approved ${updated.approvedOperationIds.length} operation(s) on ${planId}. Apply with \`fullstackgtm apply --plan-id ${planId} --provider <name>\`.`);
499
678
  return;
500
679
  }
@@ -566,11 +745,18 @@ async function brokerLogin(baseUrl) {
566
745
  // Self-reported, shown to the approver so they can recognize this request
567
746
  // and refuse one they didn't initiate.
568
747
  const requesterLabel = `${os.hostname()} (${process.platform}, ${os.userInfo().username})`;
569
- const startResponse = await fetch(`${base}/api/cli/auth/start`, {
570
- method: "POST",
571
- headers: { "Content-Type": "application/json" },
572
- body: JSON.stringify({ requesterLabel }),
573
- });
748
+ let startResponse;
749
+ try {
750
+ startResponse = await fetch(`${base}/api/cli/auth/start`, {
751
+ method: "POST",
752
+ headers: { "Content-Type": "application/json" },
753
+ body: JSON.stringify({ requesterLabel }),
754
+ });
755
+ }
756
+ catch (error) {
757
+ const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
758
+ throw new Error(`Cannot reach the hosted deployment at ${base}${cause}. Check the --via URL and network access.`);
759
+ }
574
760
  if (!startResponse.ok) {
575
761
  throw new Error(`Could not start pairing with ${base} (${startResponse.status}). Is this a FullStackGTM deployment?`);
576
762
  }
@@ -806,6 +992,7 @@ export function doctorReport(env = process.env) {
806
992
  return {
807
993
  package: packageInfo,
808
994
  node: { version: process.versions.node, ok: nodeMajor >= 20, required: ">=20" },
995
+ profile: activeProfile(),
809
996
  credentialStore: { path: storePath, exists: existsSync(storePath) },
810
997
  config: { path: configPath, exists: existsSync(configPath) },
811
998
  providers,
@@ -844,6 +1031,7 @@ function doctorCommand(args) {
844
1031
  const lines = [
845
1032
  `Package: ${report.package.name} ${report.package.version}`,
846
1033
  `Node: v${report.node.version} (${report.node.required} required) ${mark(report.node.ok)}`,
1034
+ `Profile: ${report.profile}${report.profile === DEFAULT_PROFILE ? "" : " (named profile — credentials and plans are scoped to it)"}`,
847
1035
  `Cred store: ${report.credentialStore.path} (${report.credentialStore.exists ? "present" : "not created yet — created on first login"})`,
848
1036
  `Config: ${report.config.exists ? report.config.path : "none — defaults apply"}`,
849
1037
  "",
@@ -862,12 +1050,47 @@ function doctorCommand(args) {
862
1050
  if (!report.node.ok)
863
1051
  process.exitCode = 1;
864
1052
  }
1053
+ /**
1054
+ * Pull the global `--profile <name>` flag out of argv (it may appear before
1055
+ * or after the command) and activate it. Stripping it keeps positional
1056
+ * detection in subcommands — `login <provider>`, `plans show <id>` — simple.
1057
+ */
1058
+ function extractProfile(argv) {
1059
+ const index = argv.indexOf("--profile");
1060
+ if (index === -1)
1061
+ return argv;
1062
+ const name = argv[index + 1];
1063
+ if (!name)
1064
+ throw new Error("--profile requires a name, e.g. --profile acme");
1065
+ setActiveProfile(name);
1066
+ return [...argv.slice(0, index), ...argv.slice(index + 2)];
1067
+ }
1068
+ function profilesCommand(args) {
1069
+ const profiles = listProfiles();
1070
+ const current = activeProfile();
1071
+ if (args.includes("--json")) {
1072
+ console.log(JSON.stringify({ active: current, profiles }, null, 2));
1073
+ return;
1074
+ }
1075
+ for (const profile of profiles) {
1076
+ console.log(`${profile === current ? "*" : " "} ${profile}`);
1077
+ }
1078
+ if (!profiles.includes(current)) {
1079
+ console.log(`* ${current} (selected; created on first login)`);
1080
+ }
1081
+ console.log("\nSelect with --profile <name> on any command, or set FULLSTACKGTM_PROFILE. " +
1082
+ "Each profile keeps its own credentials and stored plans.");
1083
+ }
865
1084
  export async function runCli(argv) {
866
- const [command, ...args] = argv;
1085
+ const [command, ...args] = extractProfile(argv);
867
1086
  if (!command || command === "--help" || command === "-h") {
868
1087
  console.log(usage());
869
1088
  return;
870
1089
  }
1090
+ if (command === "--version" || command === "-v" || command === "version") {
1091
+ console.log(readPackageInfo().version);
1092
+ return;
1093
+ }
871
1094
  if (command === "login") {
872
1095
  await login(args);
873
1096
  return;
@@ -884,6 +1107,10 @@ export async function runCli(argv) {
884
1107
  await audit(args);
885
1108
  return;
886
1109
  }
1110
+ if (command === "report") {
1111
+ await reportCommand(args);
1112
+ return;
1113
+ }
887
1114
  if (command === "rules") {
888
1115
  await rulesCommand(args);
889
1116
  return;
@@ -892,6 +1119,14 @@ export async function runCli(argv) {
892
1119
  doctorCommand(args);
893
1120
  return;
894
1121
  }
1122
+ if (command === "suggest") {
1123
+ await suggest(args);
1124
+ return;
1125
+ }
1126
+ if (command === "profiles") {
1127
+ profilesCommand(args);
1128
+ return;
1129
+ }
895
1130
  if (command === "diff") {
896
1131
  await diffCommand(args);
897
1132
  return;
@@ -24,14 +24,21 @@ export function createHubspotConnector(options) {
24
24
  const mappings = options.fieldMappings;
25
25
  async function request(path, init = {}) {
26
26
  const token = await options.getAccessToken();
27
- const response = await fetchImpl(`${baseUrl}${path}`, {
28
- ...init,
29
- headers: {
30
- Authorization: `Bearer ${token}`,
31
- "Content-Type": "application/json",
32
- ...(init.headers ?? {}),
33
- },
34
- });
27
+ let response;
28
+ try {
29
+ response = await fetchImpl(`${baseUrl}${path}`, {
30
+ ...init,
31
+ headers: {
32
+ Authorization: `Bearer ${token}`,
33
+ "Content-Type": "application/json",
34
+ ...(init.headers ?? {}),
35
+ },
36
+ });
37
+ }
38
+ catch (error) {
39
+ const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
40
+ throw new Error(`Cannot reach HubSpot at ${baseUrl}${cause}. Check network access.`);
41
+ }
35
42
  if (!response.ok) {
36
43
  const body = await response.text();
37
44
  throw new Error(`HubSpot API error ${response.status}: ${body}`);
@@ -275,16 +282,34 @@ export function createHubspotConnector(options) {
275
282
  detail: "link_record is supported for deals and contacts (to a company).",
276
283
  };
277
284
  }
278
- const companyId = String(operation.afterValue ?? "");
285
+ let companyId = String(operation.afterValue ?? "");
279
286
  if (!companyId) {
280
287
  return { operationId: operation.id, status: "skipped", detail: "link_record needs a target company id." };
281
288
  }
289
+ // `create:<Name>` creates the company first, then links — the approved
290
+ // value spells out exactly what will happen, so creation stays inside
291
+ // the typed, human-approved operation model.
292
+ let createdCompanyName = null;
293
+ if (companyId.startsWith("create:")) {
294
+ const name = companyId.slice("create:".length).trim();
295
+ if (!name) {
296
+ return { operationId: operation.id, status: "skipped", detail: "create: needs a company name (create:<Name>)." };
297
+ }
298
+ const created = await request(`/crm/v3/objects/companies`, {
299
+ method: "POST",
300
+ body: JSON.stringify({ properties: { name } }),
301
+ });
302
+ companyId = String(created.id);
303
+ createdCompanyName = name;
304
+ }
282
305
  await request(`/crm/v4/objects/${fromPath}/${encodeURIComponent(operation.objectId)}/associations/default/companies/${encodeURIComponent(companyId)}`, { method: "PUT" });
283
306
  return {
284
307
  operationId: operation.id,
285
308
  status: "applied",
286
- detail: `Linked ${fromPath}/${operation.objectId} to company ${companyId}.`,
287
- providerData: { companyId },
309
+ detail: createdCompanyName
310
+ ? `Created company "${createdCompanyName}" (${companyId}) and linked ${fromPath}/${operation.objectId} to it.`
311
+ : `Linked ${fromPath}/${operation.objectId} to company ${companyId}.`,
312
+ providerData: { companyId, ...(createdCompanyName ? { createdCompany: true } : {}) },
288
313
  };
289
314
  }
290
315
  async function createTask(operation) {
@@ -49,8 +49,12 @@ export async function validateHubspotToken(token, fetchImpl = fetch) {
49
49
  if (response.ok) {
50
50
  return { ok: true, detail: "Token accepted by the HubSpot CRM API." };
51
51
  }
52
- const body = await response.text();
53
- return { ok: false, detail: `HubSpot rejected the token (${response.status}): ${body}` };
52
+ // Never echo the response body: provider error payloads can reflect request
53
+ // details and end up in logs or shell scrollback.
54
+ return {
55
+ ok: false,
56
+ detail: `HubSpot rejected the token: HTTP ${response.status} ${response.statusText}`.trim(),
57
+ };
54
58
  }
55
59
  async function tokenRequest(params, fetchImpl) {
56
60
  const response = await fetchImpl(HS_TOKEN_URL, {
@@ -154,6 +158,10 @@ export async function runHubspotLoopbackLogin(options) {
154
158
  });
155
159
  }
156
160
  export async function openInBrowser(url) {
161
+ // Headless contexts (agent sandboxes, CI, tests) suppress the OS browser;
162
+ // every flow that opens a URL also prints it for manual use.
163
+ if (process.env.FSGTM_NO_BROWSER)
164
+ return;
157
165
  // The URL may come from an external source (e.g. a broker deployment's
158
166
  // verification URL). Only ever hand a well-formed http(s) URL to the OS
159
167
  // opener — this prevents a leading `-` from being read as a flag by
@@ -28,14 +28,21 @@ export function createSalesforceConnector(options) {
28
28
  const url = path.startsWith("http")
29
29
  ? path
30
30
  : `${connection.instanceUrl.replace(/\/$/, "")}${path}`;
31
- const response = await fetchImpl(url, {
32
- ...init,
33
- headers: {
34
- Authorization: `Bearer ${connection.accessToken}`,
35
- "Content-Type": "application/json",
36
- ...(init.headers ?? {}),
37
- },
38
- });
31
+ let response;
32
+ try {
33
+ response = await fetchImpl(url, {
34
+ ...init,
35
+ headers: {
36
+ Authorization: `Bearer ${connection.accessToken}`,
37
+ "Content-Type": "application/json",
38
+ ...(init.headers ?? {}),
39
+ },
40
+ });
41
+ }
42
+ catch (error) {
43
+ const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
44
+ throw new Error(`Cannot reach Salesforce at ${connection.instanceUrl}${cause}. Check SALESFORCE_INSTANCE_URL (your My Domain URL, e.g. https://yourco.my.salesforce.com) and network access.`);
45
+ }
39
46
  if (!response.ok) {
40
47
  const body = await response.text();
41
48
  throw new Error(`Salesforce API error ${response.status}: ${body}`);
@@ -261,8 +268,26 @@ export function createSalesforceConnector(options) {
261
268
  case "clear_field":
262
269
  // link_record on a deal is just setting AccountId in Salesforce.
263
270
  return await setField(operation);
264
- case "link_record":
271
+ case "link_record": {
272
+ // `create:<Name>` creates the Account first, then links — creation
273
+ // stays inside the typed, human-approved operation model.
274
+ const value = String(operation.afterValue ?? "");
275
+ if (value.startsWith("create:")) {
276
+ const name = value.slice("create:".length).trim();
277
+ if (!name) {
278
+ return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
279
+ }
280
+ const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
281
+ method: "POST",
282
+ body: JSON.stringify({ Name: name }),
283
+ });
284
+ const result = await setField({ ...operation, operation: "set_field", afterValue: String(created.id) });
285
+ return result.status === "applied"
286
+ ? { ...result, detail: `Created account "${name}" (${created.id}) and linked ${operation.objectType}/${operation.objectId} to it.`, providerData: { accountId: String(created.id), createdAccount: true } }
287
+ : result;
288
+ }
265
289
  return await setField({ ...operation, operation: "set_field" });
290
+ }
266
291
  case "create_task":
267
292
  return await createTask(operation);
268
293
  case "archive_record":
@@ -111,10 +111,24 @@ export async function refreshSalesforceToken(options) {
111
111
  };
112
112
  }
113
113
  export async function validateSalesforceToken(accessToken, instanceUrl, fetchImpl = fetch) {
114
- const response = await fetchImpl(`${instanceUrl.replace(/\/$/, "")}/services/oauth2/userinfo`, { headers: { Authorization: `Bearer ${accessToken}` } });
114
+ let response;
115
+ try {
116
+ response = await fetchImpl(`${instanceUrl.replace(/\/$/, "")}/services/oauth2/userinfo`, { headers: { Authorization: `Bearer ${accessToken}` } });
117
+ }
118
+ catch (error) {
119
+ const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
120
+ return {
121
+ ok: false,
122
+ detail: `Cannot reach Salesforce at ${instanceUrl}${cause}. Check the --instance-url (your My Domain URL, e.g. https://yourco.my.salesforce.com) and network access.`,
123
+ };
124
+ }
115
125
  if (response.ok) {
116
126
  return { ok: true, detail: "Token accepted by the Salesforce API." };
117
127
  }
118
- const body = await response.text();
119
- return { ok: false, detail: `Salesforce rejected the token (${response.status}): ${body}` };
128
+ // Never echo the response body: provider error payloads can reflect request
129
+ // details and end up in logs or shell scrollback.
130
+ return {
131
+ ok: false,
132
+ detail: `Salesforce rejected the token: HTTP ${response.status} ${response.statusText}`.trim(),
133
+ };
120
134
  }
@@ -2,7 +2,26 @@
2
2
  * Local CLI credential store: ~/.fullstackgtm/credentials.json (0600), or
3
3
  * $FSGTM_HOME/credentials.json when set. Environment tokens always win over
4
4
  * stored credentials so CI and agent sandboxes never touch the filesystem.
5
+ *
6
+ * Profiles let one operator hold credentials for several organizations at
7
+ * once (a consultant working across client CRMs). The default profile keeps
8
+ * the historical layout; a named profile scopes the entire home — credentials
9
+ * AND stored plans — under `profiles/<name>/`, so a patch plan proposed
10
+ * against one client's CRM can never be applied through another client's
11
+ * credentials.
5
12
  */
13
+ export declare const DEFAULT_PROFILE = "default";
14
+ export declare function validateProfileName(name: string): string;
15
+ /** Select the profile for this process; wins over $FULLSTACKGTM_PROFILE. */
16
+ export declare function setActiveProfile(name: string): void;
17
+ export declare function activeProfile(): string;
18
+ /** Base home directory, shared by every profile. */
19
+ export declare function baseHomeDir(): string;
20
+ /**
21
+ * Profiles that exist on disk (have a directory), always including the
22
+ * default profile. Existence does not imply stored credentials.
23
+ */
24
+ export declare function listProfiles(): string[];
6
25
  export type StoredCredential = {
7
26
  kind: "private_app" | "oauth" | "broker";
8
27
  accessToken: string;