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/CHANGELOG.md +106 -0
- package/INSTALL_FOR_AGENTS.md +28 -2
- package/README.md +74 -6
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +251 -16
- package/dist/connectors/hubspot.js +36 -11
- package/dist/connectors/hubspotAuth.js +10 -2
- package/dist/connectors/salesforce.js +34 -9
- package/dist/connectors/salesforceAuth.js +17 -3
- package/dist/credentials.d.ts +19 -0
- package/dist/credentials.js +69 -8
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/mcp.js +53 -5
- package/dist/report.d.ts +61 -0
- package/dist/report.js +331 -0
- package/dist/rules.d.ts +1 -0
- package/dist/rules.js +47 -0
- package/dist/suggest.d.ts +31 -0
- package/dist/suggest.js +148 -0
- package/docs/api.md +13 -1
- package/docs/roadmap-to-1.0.md +31 -3
- package/package.json +1 -1
- package/src/cli.ts +271 -14
- package/src/connectors/hubspot.ts +35 -11
- package/src/connectors/hubspotAuth.ts +9 -2
- package/src/connectors/salesforce.ts +35 -9
- package/src/connectors/salesforceAuth.ts +19 -6
- package/src/credentials.ts +71 -6
- package/src/index.ts +7 -0
- package/src/mcp.ts +55 -5
- package/src/report.ts +502 -0
- package/src/rules.ts +50 -0
- package/src/suggest.ts +202 -0
package/src/cli.ts
CHANGED
|
@@ -19,11 +19,15 @@ import {
|
|
|
19
19
|
validateSalesforceToken,
|
|
20
20
|
} from "./connectors/salesforceAuth.ts";
|
|
21
21
|
import {
|
|
22
|
+
activeProfile,
|
|
22
23
|
credentialsPath,
|
|
24
|
+
DEFAULT_PROFILE,
|
|
23
25
|
deleteCredential,
|
|
24
26
|
getCredential,
|
|
27
|
+
listProfiles,
|
|
25
28
|
resolveHubspotConnection,
|
|
26
29
|
resolveSalesforceConnection,
|
|
30
|
+
setActiveProfile,
|
|
27
31
|
storeCredential,
|
|
28
32
|
type StoredCredential,
|
|
29
33
|
} from "./credentials.ts";
|
|
@@ -31,8 +35,10 @@ import { generateDemoSnapshot } from "./demo.ts";
|
|
|
31
35
|
import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
32
36
|
import { mergeSnapshots } from "./merge.ts";
|
|
33
37
|
import { createFilePlanStore } from "./planStore.ts";
|
|
38
|
+
import { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
|
|
34
39
|
import { builtinAuditRules } from "./rules.ts";
|
|
35
40
|
import { sampleSnapshot } from "./sampleData.ts";
|
|
41
|
+
import { suggestValues, type ValueSuggestion } from "./suggest.ts";
|
|
36
42
|
import type { FieldMappings } from "./mappings.ts";
|
|
37
43
|
import type {
|
|
38
44
|
AuditFindingSeverity,
|
|
@@ -60,14 +66,28 @@ Usage:
|
|
|
60
66
|
echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot
|
|
61
67
|
fullstackgtm snapshot [source options] [--since <iso>] [--out <path> | --archive <dir>]
|
|
62
68
|
fullstackgtm audit [source options] [audit options] [--save]
|
|
69
|
+
fullstackgtm report [source options] [audit options] [report options]
|
|
63
70
|
fullstackgtm diff --before <a.json> --after <b.json> [--json] [--fail-on-new-findings]
|
|
64
71
|
fullstackgtm merge --input <a.json> --input <b.json> [...] --out <merged.json> [--json]
|
|
65
|
-
fullstackgtm
|
|
72
|
+
fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
|
|
73
|
+
derive values for requires_human_* placeholders
|
|
74
|
+
from snapshot evidence, with confidence + reasons
|
|
75
|
+
fullstackgtm plans list [--status <s>] | show <id> | reject <id>
|
|
76
|
+
fullstackgtm plans approve <id> --operations <ids|all> [--value <opId>=<v>]
|
|
77
|
+
fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low] [--include-creates]
|
|
66
78
|
fullstackgtm apply --plan-id <id> --provider <name>
|
|
67
79
|
fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [options]
|
|
68
80
|
fullstackgtm rules [--json]
|
|
81
|
+
fullstackgtm profiles [--json] list credential profiles
|
|
69
82
|
fullstackgtm doctor [--json] check install, credentials, and next step
|
|
70
83
|
|
|
84
|
+
Profiles (multi-organization use):
|
|
85
|
+
--profile <name> Scope credentials AND stored plans to a named profile
|
|
86
|
+
(also: FULLSTACKGTM_PROFILE). One profile per client
|
|
87
|
+
org keeps logins isolated and prevents a plan proposed
|
|
88
|
+
against one CRM from being applied through another's
|
|
89
|
+
credentials. Omitted = the default profile.
|
|
90
|
+
|
|
71
91
|
Plan lifecycle:
|
|
72
92
|
audit --save persists the dry-run plan to ~/.fullstackgtm/plans. Approve
|
|
73
93
|
specific operations (optionally with --value <opId>=<v> for placeholders),
|
|
@@ -104,6 +124,17 @@ Audit options:
|
|
|
104
124
|
--stale-days <n> Days without activity before an open deal is stale
|
|
105
125
|
--fail-on <severity> Exit 2 if any finding is at or above info|warning|critical
|
|
106
126
|
|
|
127
|
+
Report options (report renders the audit as a client-ready deliverable):
|
|
128
|
+
--plan <path> Render an existing plan JSON instead of re-auditing
|
|
129
|
+
(add --input <snapshot.json> for record counts)
|
|
130
|
+
--client <name> Organization name shown in the heading and summary
|
|
131
|
+
--title <text> Report heading (default "GTM Data Health Report")
|
|
132
|
+
--prepared-by <name> Attribution shown in the footer
|
|
133
|
+
--format <fmt> markdown (default) or html (self-contained, printable;
|
|
134
|
+
inferred from an --out path ending in .html)
|
|
135
|
+
--max-examples <n> Example records listed per rule (default 10)
|
|
136
|
+
--out <path> Write the report to a file instead of stdout
|
|
137
|
+
|
|
107
138
|
Apply options:
|
|
108
139
|
--plan <path> Patch plan JSON produced by \`audit --out\`
|
|
109
140
|
--provider hubspot Connector to apply through
|
|
@@ -157,7 +188,7 @@ async function hubspotConnection(args: string[]) {
|
|
|
157
188
|
const stored = await resolveHubspotConnection();
|
|
158
189
|
if (stored) return stored;
|
|
159
190
|
throw new Error(
|
|
160
|
-
"No HubSpot credentials. Run `fullstackgtm login hubspot`, pair with a hosted deployment via `fullstackgtm login --via <url>`, or set HUBSPOT_ACCESS_TOKEN.",
|
|
191
|
+
"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.)",
|
|
161
192
|
);
|
|
162
193
|
}
|
|
163
194
|
|
|
@@ -171,7 +202,7 @@ async function salesforceConnection() {
|
|
|
171
202
|
const stored = await resolveSalesforceConnection();
|
|
172
203
|
if (stored) return stored;
|
|
173
204
|
throw new Error(
|
|
174
|
-
"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.",
|
|
205
|
+
"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.)",
|
|
175
206
|
);
|
|
176
207
|
}
|
|
177
208
|
|
|
@@ -198,7 +229,7 @@ async function connectorFor(provider: string, args: string[]): Promise<GtmConnec
|
|
|
198
229
|
process.env.STRIPE_SECRET_KEY ?? getCredential("stripe")?.accessToken;
|
|
199
230
|
if (!key) {
|
|
200
231
|
throw new Error(
|
|
201
|
-
"No Stripe credentials. Run `fullstackgtm login stripe
|
|
232
|
+
"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.)",
|
|
202
233
|
);
|
|
203
234
|
}
|
|
204
235
|
return createStripeConnector({ getApiKey: () => key });
|
|
@@ -334,6 +365,64 @@ async function audit(args: string[]) {
|
|
|
334
365
|
}
|
|
335
366
|
}
|
|
336
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Render an audit as a client-facing deliverable. Same sources and audit
|
|
370
|
+
* options as `audit`; `--plan` instead renders an existing plan JSON without
|
|
371
|
+
* re-fetching (useful for a plan produced earlier or by another machine).
|
|
372
|
+
*/
|
|
373
|
+
async function reportCommand(args: string[]) {
|
|
374
|
+
const loaded = loadConfig(option(args, "--config") ?? undefined);
|
|
375
|
+
const configuredRules = await resolveConfiguredRules(loaded);
|
|
376
|
+
|
|
377
|
+
let plan: PatchPlan;
|
|
378
|
+
let snapshot: CanonicalGtmSnapshot | undefined;
|
|
379
|
+
const planPath = option(args, "--plan");
|
|
380
|
+
if (planPath) {
|
|
381
|
+
plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8")) as PatchPlan;
|
|
382
|
+
const input = option(args, "--input");
|
|
383
|
+
if (input) {
|
|
384
|
+
snapshot = JSON.parse(
|
|
385
|
+
readFileSync(resolve(process.cwd(), input), "utf8"),
|
|
386
|
+
) as CanonicalGtmSnapshot;
|
|
387
|
+
}
|
|
388
|
+
} else {
|
|
389
|
+
snapshot = await readSnapshot(args);
|
|
390
|
+
const policy = mergePolicy(defaultPolicy(), loaded?.config);
|
|
391
|
+
const today = option(args, "--today");
|
|
392
|
+
if (today) policy.today = today;
|
|
393
|
+
const staleDealDays = numericOption(args, "--stale-days");
|
|
394
|
+
if (staleDealDays !== undefined) policy.staleDealDays = staleDealDays;
|
|
395
|
+
plan = auditSnapshot(snapshot, policy, selectedRules(args, configuredRules));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const reportOptions: ReportOptions = {
|
|
399
|
+
title: option(args, "--title") ?? undefined,
|
|
400
|
+
clientName: option(args, "--client") ?? undefined,
|
|
401
|
+
preparedBy: option(args, "--prepared-by") ?? undefined,
|
|
402
|
+
date: option(args, "--today") ?? undefined,
|
|
403
|
+
maxExamplesPerRule: numericOption(args, "--max-examples"),
|
|
404
|
+
rules: configuredRules,
|
|
405
|
+
snapshot,
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const out = option(args, "--out");
|
|
409
|
+
const format = option(args, "--format") ?? (out?.endsWith(".html") ? "html" : "markdown");
|
|
410
|
+
if (format !== "markdown" && format !== "html") {
|
|
411
|
+
throw new Error("--format must be markdown or html");
|
|
412
|
+
}
|
|
413
|
+
const rendered =
|
|
414
|
+
format === "html"
|
|
415
|
+
? auditReportToHtml(plan, reportOptions)
|
|
416
|
+
: auditReportToMarkdown(plan, reportOptions);
|
|
417
|
+
|
|
418
|
+
if (out) {
|
|
419
|
+
writeFileSync(resolve(process.cwd(), out), rendered);
|
|
420
|
+
console.log(`Wrote ${format} report (${plan.findings.length} findings) to ${out}`);
|
|
421
|
+
} else {
|
|
422
|
+
console.log(rendered.trimEnd());
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
337
426
|
async function rulesCommand(args: string[]) {
|
|
338
427
|
const loaded = loadConfig(option(args, "--config") ?? undefined);
|
|
339
428
|
const rules = await resolveConfiguredRules(loaded);
|
|
@@ -376,6 +465,83 @@ function parseValueOverrides(args: string[]) {
|
|
|
376
465
|
return valueOverrides;
|
|
377
466
|
}
|
|
378
467
|
|
|
468
|
+
async function suggest(args: string[]) {
|
|
469
|
+
const planId = option(args, "--plan-id");
|
|
470
|
+
const planPath = option(args, "--plan");
|
|
471
|
+
if (!planId && !planPath) throw new Error("suggest requires --plan <path> or --plan-id <id>");
|
|
472
|
+
let plan: PatchPlan;
|
|
473
|
+
if (planId) {
|
|
474
|
+
const stored = await createFilePlanStore().get(planId);
|
|
475
|
+
if (!stored) throw new Error(`No stored plan with id ${planId}.`);
|
|
476
|
+
plan = stored.plan;
|
|
477
|
+
} else {
|
|
478
|
+
plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath!), "utf8")) as PatchPlan;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const snapshot = await readSnapshot(args);
|
|
482
|
+
const suggestions = suggestValues(plan, snapshot);
|
|
483
|
+
const payload = { planId: planId ?? planPath, suggestions };
|
|
484
|
+
|
|
485
|
+
const outPath = option(args, "--out");
|
|
486
|
+
if (outPath) writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(payload, null, 2)}\n`);
|
|
487
|
+
|
|
488
|
+
if (args.includes("--json")) {
|
|
489
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (suggestions.length === 0) {
|
|
494
|
+
console.log("No requires_human_* placeholder operations in this plan — nothing to suggest.");
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const byConfidence: Record<string, number> = {};
|
|
498
|
+
for (const s of suggestions) byConfidence[s.confidence] = (byConfidence[s.confidence] ?? 0) + 1;
|
|
499
|
+
console.log(`Suggestions for ${suggestions.length} placeholder operation(s):\n`);
|
|
500
|
+
for (const s of suggestions) {
|
|
501
|
+
const marker =
|
|
502
|
+
s.confidence === "high" ? "✓" : s.confidence === "low" ? "~" : s.confidence === "create" ? "+" : "✗";
|
|
503
|
+
console.log(`${marker} [${s.confidence}] ${s.operationId} ${s.objectName ?? s.objectId}`);
|
|
504
|
+
console.log(` ${s.suggestedValue ? `→ ${s.suggestedValue}` : "(no suggestion)"} — ${s.reason}`);
|
|
505
|
+
}
|
|
506
|
+
console.log(`\n${Object.entries(byConfidence).map(([k, v]) => `${k}: ${v}`).join(" · ")}`);
|
|
507
|
+
if (planId && (byConfidence.high ?? 0) > 0 && !outPath) {
|
|
508
|
+
console.log(
|
|
509
|
+
`\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>`,
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function snapshotSourceHint(args: string[]) {
|
|
515
|
+
const provider = option(args, "--provider");
|
|
516
|
+
if (provider) return `--provider ${provider} `;
|
|
517
|
+
const input = option(args, "--input");
|
|
518
|
+
if (input) return `--input ${input} `;
|
|
519
|
+
return "";
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function readSuggestionValues(path: string, minConfidence: string, includeCreates: boolean) {
|
|
523
|
+
const raw = JSON.parse(readFileSync(resolve(process.cwd(), path), "utf8")) as {
|
|
524
|
+
suggestions?: ValueSuggestion[];
|
|
525
|
+
};
|
|
526
|
+
if (!Array.isArray(raw.suggestions)) {
|
|
527
|
+
throw new Error(
|
|
528
|
+
`${path} is not a suggestions file (expected { suggestions: [...] } from \`fullstackgtm suggest --out\`).`,
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
const accepted = new Set(minConfidence === "low" ? ["high", "low"] : ["high"]);
|
|
532
|
+
const overrides: Record<string, string> = {};
|
|
533
|
+
let skipped = 0;
|
|
534
|
+
for (const s of raw.suggestions) {
|
|
535
|
+
if (!s.suggestedValue) continue;
|
|
536
|
+
if (accepted.has(s.confidence) || (includeCreates && s.confidence === "create")) {
|
|
537
|
+
overrides[s.operationId] = s.suggestedValue;
|
|
538
|
+
} else {
|
|
539
|
+
skipped += 1;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return { overrides, skipped };
|
|
543
|
+
}
|
|
544
|
+
|
|
379
545
|
async function apply(args: string[]) {
|
|
380
546
|
const provider = option(args, "--provider");
|
|
381
547
|
if (!provider) throw new Error("apply requires --provider <name>");
|
|
@@ -565,16 +731,50 @@ async function plansCommand(args: string[]) {
|
|
|
565
731
|
|
|
566
732
|
if (subcommand === "approve") {
|
|
567
733
|
const planId = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
|
|
568
|
-
if (!planId) throw new Error("Usage: fullstackgtm plans approve <planId> --operations <ids|all>");
|
|
734
|
+
if (!planId) throw new Error("Usage: fullstackgtm plans approve <planId> --operations <ids|all> | --values-from <suggestions.json>");
|
|
569
735
|
const operations = option(rest, "--operations");
|
|
570
|
-
|
|
736
|
+
const valuesFrom = option(rest, "--values-from");
|
|
737
|
+
if (!operations && !valuesFrom) {
|
|
738
|
+
throw new Error("plans approve requires --operations <ids|all> and/or --values-from <suggestions.json>");
|
|
739
|
+
}
|
|
571
740
|
const stored = await store.get(planId);
|
|
572
741
|
if (!stored) throw new Error(`No stored plan with id ${planId}.`);
|
|
742
|
+
|
|
743
|
+
// Values from a `fullstackgtm suggest --out` file. High-confidence only by
|
|
744
|
+
// default; widen with --min-confidence low, opt into record-creating
|
|
745
|
+
// values (create:<Name>) with --include-creates. Explicit --value wins.
|
|
746
|
+
let fileOverrides: Record<string, string> = {};
|
|
747
|
+
if (valuesFrom) {
|
|
748
|
+
const minConfidence = option(rest, "--min-confidence") ?? "high";
|
|
749
|
+
if (!["high", "low"].includes(minConfidence)) {
|
|
750
|
+
throw new Error("--min-confidence must be high or low");
|
|
751
|
+
}
|
|
752
|
+
const { overrides, skipped } = readSuggestionValues(
|
|
753
|
+
valuesFrom,
|
|
754
|
+
minConfidence,
|
|
755
|
+
rest.includes("--include-creates"),
|
|
756
|
+
);
|
|
757
|
+
fileOverrides = overrides;
|
|
758
|
+
if (Object.keys(overrides).length === 0) {
|
|
759
|
+
throw new Error(
|
|
760
|
+
`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.`,
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
if (skipped > 0) {
|
|
764
|
+
console.log(`Skipped ${skipped} suggestion(s) below the confidence bar (widen with --min-confidence low / --include-creates).`);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const explicitOverrides = parseValueOverrides(rest);
|
|
573
768
|
const operationIds =
|
|
574
769
|
operations === "all"
|
|
575
770
|
? stored.plan.operations.map((operation) => operation.id)
|
|
576
|
-
: operations
|
|
577
|
-
|
|
771
|
+
: operations
|
|
772
|
+
? operations.split(",").map((id) => id.trim()).filter(Boolean)
|
|
773
|
+
: Object.keys(fileOverrides);
|
|
774
|
+
const updated = await store.approveOperations(planId, operationIds, {
|
|
775
|
+
...fileOverrides,
|
|
776
|
+
...explicitOverrides,
|
|
777
|
+
});
|
|
578
778
|
console.log(
|
|
579
779
|
`Approved ${updated.approvedOperationIds.length} operation(s) on ${planId}. Apply with \`fullstackgtm apply --plan-id ${planId} --provider <name>\`.`,
|
|
580
780
|
);
|
|
@@ -656,11 +856,17 @@ async function brokerLogin(baseUrl: string) {
|
|
|
656
856
|
// Self-reported, shown to the approver so they can recognize this request
|
|
657
857
|
// and refuse one they didn't initiate.
|
|
658
858
|
const requesterLabel = `${os.hostname()} (${process.platform}, ${os.userInfo().username})`;
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
859
|
+
let startResponse: Response;
|
|
860
|
+
try {
|
|
861
|
+
startResponse = await fetch(`${base}/api/cli/auth/start`, {
|
|
862
|
+
method: "POST",
|
|
863
|
+
headers: { "Content-Type": "application/json" },
|
|
864
|
+
body: JSON.stringify({ requesterLabel }),
|
|
865
|
+
});
|
|
866
|
+
} catch (error) {
|
|
867
|
+
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
868
|
+
throw new Error(`Cannot reach the hosted deployment at ${base}${cause}. Check the --via URL and network access.`);
|
|
869
|
+
}
|
|
664
870
|
if (!startResponse.ok) {
|
|
665
871
|
throw new Error(
|
|
666
872
|
`Could not start pairing with ${base} (${startResponse.status}). Is this a FullStackGTM deployment?`,
|
|
@@ -926,6 +1132,7 @@ export function doctorReport(env: Record<string, string | undefined> = process.e
|
|
|
926
1132
|
return {
|
|
927
1133
|
package: packageInfo,
|
|
928
1134
|
node: { version: process.versions.node, ok: nodeMajor >= 20, required: ">=20" },
|
|
1135
|
+
profile: activeProfile(),
|
|
929
1136
|
credentialStore: { path: storePath, exists: existsSync(storePath) },
|
|
930
1137
|
config: { path: configPath, exists: existsSync(configPath) },
|
|
931
1138
|
providers,
|
|
@@ -967,6 +1174,7 @@ function doctorCommand(args: string[]) {
|
|
|
967
1174
|
const lines = [
|
|
968
1175
|
`Package: ${report.package.name} ${report.package.version}`,
|
|
969
1176
|
`Node: v${report.node.version} (${report.node.required} required) ${mark(report.node.ok)}`,
|
|
1177
|
+
`Profile: ${report.profile}${report.profile === DEFAULT_PROFILE ? "" : " (named profile — credentials and plans are scoped to it)"}`,
|
|
970
1178
|
`Cred store: ${report.credentialStore.path} (${report.credentialStore.exists ? "present" : "not created yet — created on first login"})`,
|
|
971
1179
|
`Config: ${report.config.exists ? report.config.path : "none — defaults apply"}`,
|
|
972
1180
|
"",
|
|
@@ -987,12 +1195,49 @@ function doctorCommand(args: string[]) {
|
|
|
987
1195
|
if (!report.node.ok) process.exitCode = 1;
|
|
988
1196
|
}
|
|
989
1197
|
|
|
1198
|
+
/**
|
|
1199
|
+
* Pull the global `--profile <name>` flag out of argv (it may appear before
|
|
1200
|
+
* or after the command) and activate it. Stripping it keeps positional
|
|
1201
|
+
* detection in subcommands — `login <provider>`, `plans show <id>` — simple.
|
|
1202
|
+
*/
|
|
1203
|
+
function extractProfile(argv: string[]): string[] {
|
|
1204
|
+
const index = argv.indexOf("--profile");
|
|
1205
|
+
if (index === -1) return argv;
|
|
1206
|
+
const name = argv[index + 1];
|
|
1207
|
+
if (!name) throw new Error("--profile requires a name, e.g. --profile acme");
|
|
1208
|
+
setActiveProfile(name);
|
|
1209
|
+
return [...argv.slice(0, index), ...argv.slice(index + 2)];
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function profilesCommand(args: string[]) {
|
|
1213
|
+
const profiles = listProfiles();
|
|
1214
|
+
const current = activeProfile();
|
|
1215
|
+
if (args.includes("--json")) {
|
|
1216
|
+
console.log(JSON.stringify({ active: current, profiles }, null, 2));
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
for (const profile of profiles) {
|
|
1220
|
+
console.log(`${profile === current ? "*" : " "} ${profile}`);
|
|
1221
|
+
}
|
|
1222
|
+
if (!profiles.includes(current)) {
|
|
1223
|
+
console.log(`* ${current} (selected; created on first login)`);
|
|
1224
|
+
}
|
|
1225
|
+
console.log(
|
|
1226
|
+
"\nSelect with --profile <name> on any command, or set FULLSTACKGTM_PROFILE. " +
|
|
1227
|
+
"Each profile keeps its own credentials and stored plans.",
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
990
1231
|
export async function runCli(argv: string[]) {
|
|
991
|
-
const [command, ...args] = argv;
|
|
1232
|
+
const [command, ...args] = extractProfile(argv);
|
|
992
1233
|
if (!command || command === "--help" || command === "-h") {
|
|
993
1234
|
console.log(usage());
|
|
994
1235
|
return;
|
|
995
1236
|
}
|
|
1237
|
+
if (command === "--version" || command === "-v" || command === "version") {
|
|
1238
|
+
console.log(readPackageInfo().version);
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
996
1241
|
|
|
997
1242
|
if (command === "login") {
|
|
998
1243
|
await login(args);
|
|
@@ -1010,6 +1255,10 @@ export async function runCli(argv: string[]) {
|
|
|
1010
1255
|
await audit(args);
|
|
1011
1256
|
return;
|
|
1012
1257
|
}
|
|
1258
|
+
if (command === "report") {
|
|
1259
|
+
await reportCommand(args);
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1013
1262
|
if (command === "rules") {
|
|
1014
1263
|
await rulesCommand(args);
|
|
1015
1264
|
return;
|
|
@@ -1018,6 +1267,14 @@ export async function runCli(argv: string[]) {
|
|
|
1018
1267
|
doctorCommand(args);
|
|
1019
1268
|
return;
|
|
1020
1269
|
}
|
|
1270
|
+
if (command === "suggest") {
|
|
1271
|
+
await suggest(args);
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
if (command === "profiles") {
|
|
1275
|
+
profilesCommand(args);
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1021
1278
|
if (command === "diff") {
|
|
1022
1279
|
await diffCommand(args);
|
|
1023
1280
|
return;
|
|
@@ -57,14 +57,20 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
57
57
|
|
|
58
58
|
async function request(path: string, init: RequestInit = {}): Promise<any> {
|
|
59
59
|
const token = await options.getAccessToken();
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
60
|
+
let response: Response;
|
|
61
|
+
try {
|
|
62
|
+
response = await fetchImpl(`${baseUrl}${path}`, {
|
|
63
|
+
...init,
|
|
64
|
+
headers: {
|
|
65
|
+
Authorization: `Bearer ${token}`,
|
|
66
|
+
"Content-Type": "application/json",
|
|
67
|
+
...(init.headers ?? {}),
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
} catch (error) {
|
|
71
|
+
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
72
|
+
throw new Error(`Cannot reach HubSpot at ${baseUrl}${cause}. Check network access.`);
|
|
73
|
+
}
|
|
68
74
|
if (!response.ok) {
|
|
69
75
|
const body = await response.text();
|
|
70
76
|
throw new Error(`HubSpot API error ${response.status}: ${body}`);
|
|
@@ -374,10 +380,26 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
374
380
|
detail: "link_record is supported for deals and contacts (to a company).",
|
|
375
381
|
};
|
|
376
382
|
}
|
|
377
|
-
|
|
383
|
+
let companyId = String(operation.afterValue ?? "");
|
|
378
384
|
if (!companyId) {
|
|
379
385
|
return { operationId: operation.id, status: "skipped", detail: "link_record needs a target company id." };
|
|
380
386
|
}
|
|
387
|
+
// `create:<Name>` creates the company first, then links — the approved
|
|
388
|
+
// value spells out exactly what will happen, so creation stays inside
|
|
389
|
+
// the typed, human-approved operation model.
|
|
390
|
+
let createdCompanyName: string | null = null;
|
|
391
|
+
if (companyId.startsWith("create:")) {
|
|
392
|
+
const name = companyId.slice("create:".length).trim();
|
|
393
|
+
if (!name) {
|
|
394
|
+
return { operationId: operation.id, status: "skipped", detail: "create: needs a company name (create:<Name>)." };
|
|
395
|
+
}
|
|
396
|
+
const created = await request(`/crm/v3/objects/companies`, {
|
|
397
|
+
method: "POST",
|
|
398
|
+
body: JSON.stringify({ properties: { name } }),
|
|
399
|
+
});
|
|
400
|
+
companyId = String(created.id);
|
|
401
|
+
createdCompanyName = name;
|
|
402
|
+
}
|
|
381
403
|
await request(
|
|
382
404
|
`/crm/v4/objects/${fromPath}/${encodeURIComponent(operation.objectId)}/associations/default/companies/${encodeURIComponent(companyId)}`,
|
|
383
405
|
{ method: "PUT" },
|
|
@@ -385,8 +407,10 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
385
407
|
return {
|
|
386
408
|
operationId: operation.id,
|
|
387
409
|
status: "applied",
|
|
388
|
-
detail:
|
|
389
|
-
|
|
410
|
+
detail: createdCompanyName
|
|
411
|
+
? `Created company "${createdCompanyName}" (${companyId}) and linked ${fromPath}/${operation.objectId} to it.`
|
|
412
|
+
: `Linked ${fromPath}/${operation.objectId} to company ${companyId}.`,
|
|
413
|
+
providerData: { companyId, ...(createdCompanyName ? { createdCompany: true } : {}) },
|
|
390
414
|
};
|
|
391
415
|
}
|
|
392
416
|
|
|
@@ -63,8 +63,12 @@ export async function validateHubspotToken(
|
|
|
63
63
|
if (response.ok) {
|
|
64
64
|
return { ok: true, detail: "Token accepted by the HubSpot CRM API." };
|
|
65
65
|
}
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
// Never echo the response body: provider error payloads can reflect request
|
|
67
|
+
// details and end up in logs or shell scrollback.
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
detail: `HubSpot rejected the token: HTTP ${response.status} ${response.statusText}`.trim(),
|
|
71
|
+
};
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
async function tokenRequest(
|
|
@@ -212,6 +216,9 @@ export async function runHubspotLoopbackLogin(
|
|
|
212
216
|
}
|
|
213
217
|
|
|
214
218
|
export async function openInBrowser(url: string) {
|
|
219
|
+
// Headless contexts (agent sandboxes, CI, tests) suppress the OS browser;
|
|
220
|
+
// every flow that opens a URL also prints it for manual use.
|
|
221
|
+
if (process.env.FSGTM_NO_BROWSER) return;
|
|
215
222
|
// The URL may come from an external source (e.g. a broker deployment's
|
|
216
223
|
// verification URL). Only ever hand a well-formed http(s) URL to the OS
|
|
217
224
|
// opener — this prevents a leading `-` from being read as a flag by
|
|
@@ -69,14 +69,22 @@ export function createSalesforceConnector(
|
|
|
69
69
|
const url = path.startsWith("http")
|
|
70
70
|
? path
|
|
71
71
|
: `${connection.instanceUrl.replace(/\/$/, "")}${path}`;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
72
|
+
let response: Response;
|
|
73
|
+
try {
|
|
74
|
+
response = await fetchImpl(url, {
|
|
75
|
+
...init,
|
|
76
|
+
headers: {
|
|
77
|
+
Authorization: `Bearer ${connection.accessToken}`,
|
|
78
|
+
"Content-Type": "application/json",
|
|
79
|
+
...(init.headers ?? {}),
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
84
|
+
throw new Error(
|
|
85
|
+
`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.`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
80
88
|
if (!response.ok) {
|
|
81
89
|
const body = await response.text();
|
|
82
90
|
throw new Error(`Salesforce API error ${response.status}: ${body}`);
|
|
@@ -355,8 +363,26 @@ export function createSalesforceConnector(
|
|
|
355
363
|
case "clear_field":
|
|
356
364
|
// link_record on a deal is just setting AccountId in Salesforce.
|
|
357
365
|
return await setField(operation);
|
|
358
|
-
case "link_record":
|
|
366
|
+
case "link_record": {
|
|
367
|
+
// `create:<Name>` creates the Account first, then links — creation
|
|
368
|
+
// stays inside the typed, human-approved operation model.
|
|
369
|
+
const value = String(operation.afterValue ?? "");
|
|
370
|
+
if (value.startsWith("create:")) {
|
|
371
|
+
const name = value.slice("create:".length).trim();
|
|
372
|
+
if (!name) {
|
|
373
|
+
return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
|
|
374
|
+
}
|
|
375
|
+
const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
|
|
376
|
+
method: "POST",
|
|
377
|
+
body: JSON.stringify({ Name: name }),
|
|
378
|
+
});
|
|
379
|
+
const result = await setField({ ...operation, operation: "set_field", afterValue: String(created.id) });
|
|
380
|
+
return result.status === "applied"
|
|
381
|
+
? { ...result, detail: `Created account "${name}" (${created.id}) and linked ${operation.objectType}/${operation.objectId} to it.`, providerData: { accountId: String(created.id), createdAccount: true } }
|
|
382
|
+
: result;
|
|
383
|
+
}
|
|
359
384
|
return await setField({ ...operation, operation: "set_field" });
|
|
385
|
+
}
|
|
360
386
|
case "create_task":
|
|
361
387
|
return await createTask(operation);
|
|
362
388
|
case "archive_record":
|
|
@@ -155,13 +155,26 @@ export async function validateSalesforceToken(
|
|
|
155
155
|
instanceUrl: string,
|
|
156
156
|
fetchImpl: typeof fetch = fetch,
|
|
157
157
|
): Promise<{ ok: boolean; detail: string }> {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
158
|
+
let response: Response;
|
|
159
|
+
try {
|
|
160
|
+
response = await fetchImpl(
|
|
161
|
+
`${instanceUrl.replace(/\/$/, "")}/services/oauth2/userinfo`,
|
|
162
|
+
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
|
163
|
+
);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
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.`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
162
171
|
if (response.ok) {
|
|
163
172
|
return { ok: true, detail: "Token accepted by the Salesforce API." };
|
|
164
173
|
}
|
|
165
|
-
|
|
166
|
-
|
|
174
|
+
// Never echo the response body: provider error payloads can reflect request
|
|
175
|
+
// details and end up in logs or shell scrollback.
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
detail: `Salesforce rejected the token: HTTP ${response.status} ${response.statusText}`.trim(),
|
|
179
|
+
};
|
|
167
180
|
}
|