fullstackgtm 0.10.1 → 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/CHANGELOG.md CHANGED
@@ -5,6 +5,66 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
  The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
7
7
 
8
+ ## [0.11.0] — 2026-06-11
9
+
10
+ Canonicalizes the paths discovered dogfooding against a real portal: the
11
+ suggest chain (every `requires_human_account_selection` answer was derivable
12
+ from the snapshot but required ad-hoc scripting), governed record creation,
13
+ client-ready reports, and multi-org profiles.
14
+
15
+ ### Added
16
+
17
+ - **`fullstackgtm suggest --plan-id <id> | --plan <path> [source options]`**:
18
+ deterministic value suggestions for `requires_human_*` placeholder
19
+ operations, derived from snapshot evidence — account-name matching
20
+ cross-checked against contact→account associations, plus the
21
+ only-active-user case for owner selection. Every suggestion carries a
22
+ confidence level (`high`/`low`/`create`/`none`) and a written reason;
23
+ conflicting or ambiguous evidence yields no suggestion, never a guess.
24
+ `--out` writes a suggestions file; `--json` for agents. Exposed
25
+ programmatically as `suggestValues` and over MCP as `fullstackgtm_suggest`.
26
+ - **`plans approve <id> --values-from <suggestions.json>`**: bulk-approve
27
+ with suggested values — high-confidence only by default,
28
+ `--min-confidence low` and `--include-creates` widen the bar explicitly.
29
+ Explicit `--value` flags still win.
30
+ - **`create:<Name>` link values**: approving a `link_record` operation with
31
+ `--value <op>=create:Acme` creates the company (HubSpot) / account
32
+ (Salesforce) and links to it in one audited operation — record creation
33
+ stays inside the typed, human-approved model instead of a side channel.
34
+ - New builtin rule `duplicate-open-deal` (data-quality): flags multiple open
35
+ deals sharing a normalized name, scoped to the account when linked —
36
+ typically an integration re-creating deals instead of upserting, which
37
+ counts the same revenue several times in pipeline and forecast. Emits one
38
+ finding and one approval-gated merge-review task per duplicate group.
39
+ Found dogfooding: an outreach-tool sync had tripled five open deals in our
40
+ own portal and no existing rule caught it.
41
+ - `fullstackgtm report` — render an audit (or an existing plan via `--plan`)
42
+ as a client-ready deliverable in markdown or self-contained HTML:
43
+ at-a-glance metrics, prose summary, per-rule detail with capped example
44
+ records (`--max-examples`), and recommended next steps. `--client`,
45
+ `--title`, `--prepared-by`, and `--format` customize the output;
46
+ `--out report.html` infers HTML. Exposed programmatically as
47
+ `auditReportToMarkdown` / `auditReportToHtml`.
48
+ - Credential profiles for multi-organization use: the global
49
+ `--profile <name>` flag (or `FULLSTACKGTM_PROFILE`) scopes stored logins
50
+ AND stored plans to `profiles/<name>/` under the fullstackgtm home, so one
51
+ operator can hold several clients' credentials without mixing them — and a
52
+ plan proposed against one org's CRM can never be applied through
53
+ another's. New `profiles` command lists them; `doctor` reports the active
54
+ profile. The default profile keeps the existing flat layout, so current
55
+ installs are unaffected. Exposed programmatically as `setActiveProfile`,
56
+ `activeProfile`, `listProfiles`, and `DEFAULT_PROFILE`.
57
+
58
+ ### Fixed
59
+
60
+ - `validateHubspotToken` / `validateSalesforceToken` no longer echo the
61
+ provider's raw error body into the login failure message (observed live: a
62
+ HubSpot 401 body printed to the terminal). Status line only, matching the
63
+ no-body-interpolation rule applied elsewhere in 1.0.1.
64
+ - Unreachable hosts during `login salesforce --instance-url` and
65
+ `login --via <url>` now name the target and what to check, completing the
66
+ 0.10.1 fix that only covered the audit/connector path.
67
+
8
68
  ## [0.10.1] — 2026-06-11
9
69
 
10
70
  Fixes from a full fresh-user journey audit (install → demo → MCP → real CRM),
@@ -73,6 +73,20 @@ fullstackgtm audit --provider hubspot --save # persists plan to ~/.fullstack
73
73
  fullstackgtm plans list # a human reviews and approves
74
74
  ```
75
75
 
76
+ For `requires_human_*` placeholders, chain the deterministic suggestion engine
77
+ instead of guessing values yourself:
78
+
79
+ ```bash
80
+ fullstackgtm suggest --plan-id <id> --provider hubspot --out suggestions.json # read-only
81
+ fullstackgtm plans approve <id> --values-from suggestions.json # high-confidence only
82
+ fullstackgtm apply --plan-id <id> --provider hubspot
83
+ ```
84
+
85
+ Every suggestion carries a confidence (`high`/`low`/`create`/`none`) and a
86
+ reason derived from snapshot evidence. Surface `low`/`create`/`none` entries
87
+ to your human rather than widening the bar unilaterally — `create:<Name>`
88
+ values create a new CRM record when applied.
89
+
76
90
  ## 6. MCP server (optional)
77
91
 
78
92
  The MCP entrypoint needs optional peers that plain `npx fullstackgtm-mcp`
package/README.md CHANGED
@@ -47,6 +47,39 @@ HUBSPOT_ACCESS_TOKEN=pat-... npx fullstackgtm apply \
47
47
 
48
48
  Nothing is ever written without an explicit `--approve`. Operations whose value is a human decision (`requires_human_*` placeholders, e.g. which owner to assign) are refused unless you supply a concrete `--value` override.
49
49
 
50
+ ## From findings to fixes: the suggest chain
51
+
52
+ Most placeholder answers are already derivable from your own CRM data. `suggest` computes them deterministically — account-name matching cross-checked against contact associations — with a confidence level and a written reason per operation, so you (or an agent) approve evidence, not guesses:
53
+
54
+ ```bash
55
+ fullstackgtm audit --provider hubspot --save # → Saved plan patch_plan_abc123
56
+ fullstackgtm suggest --plan-id patch_plan_abc123 --provider hubspot --out suggestions.json
57
+ # review suggestions.json: every value carries confidence (high/low/create/none) + a reason
58
+ fullstackgtm plans approve patch_plan_abc123 --values-from suggestions.json # high-confidence only by default
59
+ fullstackgtm apply --plan-id patch_plan_abc123 --provider hubspot
60
+ ```
61
+
62
+ Widen the bar deliberately: `--min-confidence low` accepts single-signal matches; `--include-creates` accepts `create:<Name>` values — approving one **creates the missing company/account record and links to it** in a single audited operation, so even record creation stays inside the typed, human-approved model. Conflicting or ambiguous evidence always yields *no* suggestion with an explanation, never a guess.
63
+
64
+ ```bash
65
+ # 3. Hand the findings to whoever owns the CRM: a client-ready report
66
+ npx fullstackgtm report --provider hubspot --client "Acme" --out acme-health.html
67
+ ```
68
+
69
+ `report` renders the same audit as a deliverable — severity counts up front, a prose summary, per-rule detail with example records, and next steps — as markdown or self-contained HTML (printable, emailable, no external assets).
70
+
71
+ ### Working across organizations
72
+
73
+ Consultants and fractional operators hold credentials for several CRMs at once. A profile scopes stored logins *and* stored plans to one organization:
74
+
75
+ ```bash
76
+ fullstackgtm --profile acme login hubspot
77
+ fullstackgtm --profile acme audit --provider hubspot --save
78
+ fullstackgtm profiles # list profiles, * marks the active one
79
+ ```
80
+
81
+ Set `FULLSTACKGTM_PROFILE=acme` to pin a shell (or agent sandbox) to one client. Plans saved under a profile are invisible to every other profile, so a patch plan proposed against one client's CRM can never be applied through another client's credentials.
82
+
50
83
  ## Built for agents (and the RevOps humans they work for)
51
84
 
52
85
  Every command is designed to compose in an agent loop — deterministic output, machine-readable everywhere, meaningful exit codes:
package/dist/cli.d.ts CHANGED
@@ -12,6 +12,7 @@ export declare function doctorReport(env?: Record<string, string | undefined>):
12
12
  ok: boolean;
13
13
  required: string;
14
14
  };
15
+ profile: string;
15
16
  credentialStore: {
16
17
  path: string;
17
18
  exists: boolean;
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
@@ -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,8 +1050,39 @@ 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;
@@ -888,6 +1107,10 @@ export async function runCli(argv) {
888
1107
  await audit(args);
889
1108
  return;
890
1109
  }
1110
+ if (command === "report") {
1111
+ await reportCommand(args);
1112
+ return;
1113
+ }
891
1114
  if (command === "rules") {
892
1115
  await rulesCommand(args);
893
1116
  return;
@@ -896,6 +1119,14 @@ export async function runCli(argv) {
896
1119
  doctorCommand(args);
897
1120
  return;
898
1121
  }
1122
+ if (command === "suggest") {
1123
+ await suggest(args);
1124
+ return;
1125
+ }
1126
+ if (command === "profiles") {
1127
+ profilesCommand(args);
1128
+ return;
1129
+ }
899
1130
  if (command === "diff") {
900
1131
  await diffCommand(args);
901
1132
  return;
@@ -282,16 +282,34 @@ export function createHubspotConnector(options) {
282
282
  detail: "link_record is supported for deals and contacts (to a company).",
283
283
  };
284
284
  }
285
- const companyId = String(operation.afterValue ?? "");
285
+ let companyId = String(operation.afterValue ?? "");
286
286
  if (!companyId) {
287
287
  return { operationId: operation.id, status: "skipped", detail: "link_record needs a target company id." };
288
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
+ }
289
305
  await request(`/crm/v4/objects/${fromPath}/${encodeURIComponent(operation.objectId)}/associations/default/companies/${encodeURIComponent(companyId)}`, { method: "PUT" });
290
306
  return {
291
307
  operationId: operation.id,
292
308
  status: "applied",
293
- detail: `Linked ${fromPath}/${operation.objectId} to company ${companyId}.`,
294
- 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 } : {}) },
295
313
  };
296
314
  }
297
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, {
@@ -268,8 +268,26 @@ export function createSalesforceConnector(options) {
268
268
  case "clear_field":
269
269
  // link_record on a deal is just setting AccountId in Salesforce.
270
270
  return await setField(operation);
271
- 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
+ }
272
289
  return await setField({ ...operation, operation: "set_field" });
290
+ }
273
291
  case "create_task":
274
292
  return await createTask(operation);
275
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;