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/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
|
|
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
|
|
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
|
-
|
|
490
|
-
|
|
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
|
|
497
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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:
|
|
287
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
}
|
package/dist/credentials.d.ts
CHANGED
|
@@ -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;
|