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 +60 -0
- package/INSTALL_FOR_AGENTS.md +14 -0
- package/README.md +33 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +244 -13
- package/dist/connectors/hubspot.js +21 -3
- package/dist/connectors/hubspotAuth.js +6 -2
- package/dist/connectors/salesforce.js +19 -1
- 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 +16 -0
- 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/package.json +1 -1
- package/src/cli.ts +264 -11
- package/src/connectors/hubspot.ts +21 -3
- package/src/connectors/hubspotAuth.ts +6 -2
- package/src/connectors/salesforce.ts +19 -1
- package/src/connectors/salesforceAuth.ts +19 -6
- package/src/credentials.ts +71 -6
- package/src/index.ts +7 -0
- package/src/mcp.ts +22 -0
- package/src/report.ts +502 -0
- package/src/rules.ts +50 -0
- package/src/suggest.ts +202 -0
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),
|
package/INSTALL_FOR_AGENTS.md
CHANGED
|
@@ -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
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
|
|
@@ -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,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
|
-
|
|
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:
|
|
294
|
-
|
|
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
|
-
|
|
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, {
|
|
@@ -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
|
-
|
|
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;
|