fullstackgtm 0.10.0 → 0.10.1
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 +46 -0
- package/INSTALL_FOR_AGENTS.md +14 -2
- package/README.md +41 -6
- package/dist/cli.js +7 -3
- package/dist/connectors/hubspot.js +15 -8
- package/dist/connectors/hubspotAuth.js +4 -0
- package/dist/connectors/salesforce.js +15 -8
- package/dist/mcp.js +37 -5
- package/docs/roadmap-to-1.0.md +31 -3
- package/package.json +1 -1
- package/src/cli.ts +7 -3
- package/src/connectors/hubspot.ts +14 -8
- package/src/connectors/hubspotAuth.ts +3 -0
- package/src/connectors/salesforce.ts +16 -8
- package/src/mcp.ts +33 -5
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,52 @@ 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.10.1] — 2026-06-11
|
|
9
|
+
|
|
10
|
+
Fixes from a full fresh-user journey audit (install → demo → MCP → real CRM),
|
|
11
|
+
focused on the first-contact experience.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`fullstackgtm --version`** (also `-v` / `version`) — previously exited 1
|
|
16
|
+
with a usage dump.
|
|
17
|
+
- **README "Connect your CRM" section**: HubSpot private-app walkthrough with
|
|
18
|
+
the exact read scopes the audit needs (`crm.objects.{owners,companies,contacts,deals}.read`)
|
|
19
|
+
and the write scopes `apply` needs; Salesforce Connected App prerequisites
|
|
20
|
+
(admin-created, device flow enabled, `api` + `refresh_token` scopes,
|
|
21
|
+
propagation delay); Stripe restricted-key guidance. Previously the word
|
|
22
|
+
"scope" appeared nowhere in the docs.
|
|
23
|
+
- Roadmap: documented the known real-portal gaps to close before 1.0
|
|
24
|
+
(pipeline-aware closed-deal detection, 429/retry, fetchChanges 10k
|
|
25
|
+
truncation, MCP plan hand-off, scope-complete login validation).
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **`FSGTM_NO_BROWSER=1` suppresses OS browser opens** in all login flows
|
|
30
|
+
(broker pairing, Salesforce device flow, HubSpot OAuth) — for agent
|
|
31
|
+
sandboxes, CI, and headless use; verification URLs are always printed.
|
|
32
|
+
The broker test suite sets it, so running `npm test` no longer pops a
|
|
33
|
+
real browser tab at a mock pairing URL on every run.
|
|
34
|
+
- **MCP server now works from inside existing projects.** When launched via
|
|
35
|
+
`npx -p ... fullstackgtm-mcp` from a directory whose node_modules already
|
|
36
|
+
contains the peers (but not fullstackgtm), npx skips installing them into
|
|
37
|
+
its cache and module resolution failed; the server now falls back to
|
|
38
|
+
resolving the peers from the working directory — peer-dependency semantics.
|
|
39
|
+
Previously this made the README's `claude mcp add` setup produce a dead
|
|
40
|
+
server in most agent-tooling repos.
|
|
41
|
+
- **README auth examples matched the CLI again**: `login salesforce --token ...`
|
|
42
|
+
and `login hubspot --oauth ... --client-secret ...` showed argv-secret forms
|
|
43
|
+
the CLI deliberately rejects; both now use stdin.
|
|
44
|
+
- The no-credentials Stripe error suggested `login stripe --token sk_...`,
|
|
45
|
+
which the CLI itself refuses — it now shows the stdin form and mentions
|
|
46
|
+
restricted keys.
|
|
47
|
+
- Unreachable hosts no longer surface as a bare `fetch failed`: HubSpot and
|
|
48
|
+
Salesforce connection errors name the target URL and (for Salesforce) point
|
|
49
|
+
at SALESFORCE_INSTANCE_URL.
|
|
50
|
+
- Credential errors now point at `fullstackgtm doctor` and the README scope
|
|
51
|
+
guide; the MCP audit tool description now mentions the `demo` source;
|
|
52
|
+
README rule count corrected (11 built-ins, not five).
|
|
53
|
+
|
|
8
54
|
## [0.10.0] — 2026-06-10
|
|
9
55
|
|
|
10
56
|
**Versioning reset to reflect beta status.** The 1.x numbering below
|
package/INSTALL_FOR_AGENTS.md
CHANGED
|
@@ -45,7 +45,15 @@ Credential resolution ladder, first match wins:
|
|
|
45
45
|
4. Broker pairing: `fullstackgtm login --via <hosted url>` (a human approves the pairing code)
|
|
46
46
|
|
|
47
47
|
In an agent sandbox, prefer rung 1 or 2. Never echo tokens into argv —
|
|
48
|
-
`login` reads secrets from stdin only.
|
|
48
|
+
`login` reads secrets from stdin only. Set `FSGTM_NO_BROWSER=1` in headless
|
|
49
|
+
environments — login flows then print verification URLs instead of opening
|
|
50
|
+
the OS browser.
|
|
51
|
+
|
|
52
|
+
Provider prerequisites (what the human must create, and which scopes) are in
|
|
53
|
+
the README's **"Connect your CRM"** section: HubSpot needs a private app with
|
|
54
|
+
four `crm.objects.*.read` scopes (plus write scopes only for `apply`);
|
|
55
|
+
Salesforce needs an admin-created Connected App with device flow enabled;
|
|
56
|
+
Stripe works with a restricted key (Customers + Subscriptions read).
|
|
49
57
|
|
|
50
58
|
```bash
|
|
51
59
|
HUBSPOT_ACCESS_TOKEN=$TOKEN fullstackgtm audit --provider hubspot --json --out plan.json
|
|
@@ -71,9 +79,13 @@ The MCP entrypoint needs optional peers that plain `npx fullstackgtm-mcp`
|
|
|
71
79
|
does not install:
|
|
72
80
|
|
|
73
81
|
```bash
|
|
74
|
-
npx -p fullstackgtm -p @modelcontextprotocol/sdk -p zod fullstackgtm-mcp
|
|
82
|
+
npx -y -p fullstackgtm -p @modelcontextprotocol/sdk -p zod fullstackgtm-mcp
|
|
75
83
|
```
|
|
76
84
|
|
|
85
|
+
If the working directory's project already has the peers in its node_modules,
|
|
86
|
+
the server resolves them from there (peer-dependency semantics) — so this
|
|
87
|
+
works from inside existing projects too.
|
|
88
|
+
|
|
77
89
|
Tools exposed over stdio: `fullstackgtm_audit` (read-only),
|
|
78
90
|
`fullstackgtm_rules`, `fullstackgtm_apply` (requires `approvedOperationIds`).
|
|
79
91
|
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# fullstackgtm
|
|
2
2
|
|
|
3
|
+
[](https://github.com/fullstackgtm/core/actions/workflows/ci.yml) [](https://www.npmjs.com/package/fullstackgtm) [](./LICENSE)
|
|
4
|
+
|
|
3
5
|
**Plan/apply for your GTM stack.** An open-source framework for managing disparate go-to-market data spread across third-party systems: a canonical CRM/GTM data model, deterministic hygiene audits, reviewable dry-run patch plans, and approval-gated write-back to providers.
|
|
4
6
|
|
|
5
7
|
Think `terraform plan` for your CRM: agents and scripts may *read* everything, but every proposed change becomes a typed patch operation — object, field, before, after, reason, risk — that a human approves before any provider write happens.
|
|
@@ -93,28 +95,61 @@ fullstackgtm login hubspot
|
|
|
93
95
|
|
|
94
96
|
# HubSpot, bring-your-own-app OAuth. The browser is used exactly once — the
|
|
95
97
|
# consent grant — captured on a 127.0.0.1 loopback (RFC 8252); the CLI
|
|
96
|
-
# exchanges the code itself and refreshes silently from then on.
|
|
97
|
-
|
|
98
|
+
# exchanges the code itself and refreshes silently from then on. The client
|
|
99
|
+
# secret is read from stdin or an interactive prompt — never as a flag.
|
|
100
|
+
echo "$CLIENT_SECRET" | fullstackgtm login hubspot --oauth --client-id <id>
|
|
98
101
|
# (register http://localhost:8763/callback as a redirect URL on your app)
|
|
99
102
|
|
|
100
103
|
# Salesforce: native device flow — confirm a code on any device, no localhost
|
|
101
104
|
# server, no client secret, silent refresh. Needs a Connected App consumer key
|
|
102
|
-
# with device flow enabled.
|
|
105
|
+
# with device flow enabled (see "Connect your CRM" below).
|
|
103
106
|
fullstackgtm login salesforce --device --client-id <consumer key>
|
|
104
|
-
# ...or a session token directly:
|
|
105
|
-
fullstackgtm login salesforce --
|
|
107
|
+
# ...or a session token directly (token on stdin, never as a flag):
|
|
108
|
+
echo "$SF_SESSION_TOKEN" | fullstackgtm login salesforce --instance-url https://yourorg.my.salesforce.com
|
|
106
109
|
|
|
107
110
|
fullstackgtm logout hubspot # or: salesforce | broker
|
|
108
111
|
```
|
|
109
112
|
|
|
110
113
|
A direct `login hubspot` always wins over a broker pairing, so an operator can override the team default. HubSpot does not support the device-authorization grant or secretless public clients, which is why the bring-your-own-app OAuth path requires client credentials; they are stored locally for silent refresh, the same model as `gcloud` and `aws` CLI profiles.
|
|
111
114
|
|
|
115
|
+
## Connect your CRM
|
|
116
|
+
|
|
117
|
+
What each provider actually requires before `audit --provider <name>` works on your data.
|
|
118
|
+
|
|
119
|
+
### HubSpot: create a private app (~2 minutes, needs super-admin)
|
|
120
|
+
|
|
121
|
+
1. In HubSpot: **Settings → Integrations → Private Apps → Create a private app.**
|
|
122
|
+
2. On the **Scopes** tab, grant the read scopes the audit needs:
|
|
123
|
+
- `crm.objects.owners.read`
|
|
124
|
+
- `crm.objects.companies.read`
|
|
125
|
+
- `crm.objects.contacts.read`
|
|
126
|
+
- `crm.objects.deals.read`
|
|
127
|
+
3. If you plan to **apply** approved operations (not just audit), also grant write scopes for the objects you'll let it touch: `crm.objects.deals.write` (covers `deal.next_step` and other deal fields), plus `crm.objects.contacts.write` / `crm.objects.companies.write` for contact/company patches, and the **Tasks** write scope for `create_task` operations (search "tasks" in the scope picker; naming varies by portal).
|
|
128
|
+
4. Create the app, copy the token (`pat-...`), then: `echo "$TOKEN" | fullstackgtm login hubspot`.
|
|
129
|
+
|
|
130
|
+
If a scope is missing you'll see a `403` mid-run whose body names the exact missing scope (`requiredGranularScopes`) — add it to the private app and re-run. Note that `login` only validates the token itself; it can't tell whether every scope you'll need is granted.
|
|
131
|
+
|
|
132
|
+
### Salesforce: a Connected App (one-time, usually needs an admin)
|
|
133
|
+
|
|
134
|
+
Device-flow login requires a Connected App in your org — if you're not an admin, this is the step to ask one for:
|
|
135
|
+
|
|
136
|
+
1. **Setup → App Manager → New Connected App**, enable OAuth settings.
|
|
137
|
+
2. Check **Enable Device Flow**.
|
|
138
|
+
3. OAuth scopes: **Manage user data via APIs (`api`)** and **Perform requests at any time (`refresh_token`)** — the CLI requests exactly these.
|
|
139
|
+
4. Save (Salesforce can take ~2–10 minutes to propagate a new Connected App), copy the **Consumer Key**, then: `fullstackgtm login salesforce --device --client-id <consumer key>`.
|
|
140
|
+
|
|
141
|
+
Writeback needs no extra OAuth scope — applies are gated by the logged-in user's normal object/field permissions.
|
|
142
|
+
|
|
143
|
+
### Stripe: a restricted key is enough (read-only connector)
|
|
144
|
+
|
|
145
|
+
The Stripe connector only reads customers and subscriptions, and `apply` is read-only by construction. Create a **restricted key** with just **Customers: Read** and **Subscriptions: Read** (Developers → API keys → Create restricted key) instead of pasting a full-access secret key: `echo "$KEY" | fullstackgtm login stripe`.
|
|
146
|
+
|
|
112
147
|
## Concepts
|
|
113
148
|
|
|
114
149
|
| Concept | What it is |
|
|
115
150
|
|---|---|
|
|
116
151
|
| **Canonical snapshot** | Provider-independent view of users, accounts, contacts, deals, activities. Records carry `identities` — `(provider, externalId)` claims — so the same real-world entity can be tracked across several systems. |
|
|
117
|
-
| **Audit rule** | A deterministic function `(context) => { findings, operations }`.
|
|
152
|
+
| **Audit rule** | A deterministic function `(context) => { findings, operations }`. Eleven built-ins cover orphan accounts, ownerless/unlinked/amount-less deals, past close dates, stale pipeline, duplicates, and more — `fullstackgtm rules` lists them all. Write your own in ~10 lines. |
|
|
118
153
|
| **Patch plan** | The dry-run output of an audit: findings plus typed patch operations with before/after values, reasons, risk levels, and approval flags. Always a proposal, never a mutation. |
|
|
119
154
|
| **Connector** | A provider adapter: `fetchSnapshot()` for reads, optional `applyOperation()` for writes. HubSpot and Salesforce reference connectors ship in the package; connectors never drop records they can't fully resolve — the audit flags them instead. |
|
|
120
155
|
| **Patch plan run** | The audit record of one apply attempt: per-operation applied/failed/skipped results. |
|
package/dist/cli.js
CHANGED
|
@@ -132,7 +132,7 @@ async function hubspotConnection(args) {
|
|
|
132
132
|
const stored = await resolveHubspotConnection();
|
|
133
133
|
if (stored)
|
|
134
134
|
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.");
|
|
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. (`fullstackgtm doctor` shows credential status; see the README's \"Connect your CRM\" section for the private-app scopes to grant.)");
|
|
136
136
|
}
|
|
137
137
|
async function salesforceConnection() {
|
|
138
138
|
if (process.env.SALESFORCE_ACCESS_TOKEN && process.env.SALESFORCE_INSTANCE_URL) {
|
|
@@ -144,7 +144,7 @@ async function salesforceConnection() {
|
|
|
144
144
|
const stored = await resolveSalesforceConnection();
|
|
145
145
|
if (stored)
|
|
146
146
|
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.");
|
|
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. (`fullstackgtm doctor` shows credential status; device-flow login needs an admin-created Connected App — see the README's \"Connect your CRM\" section.)");
|
|
148
148
|
}
|
|
149
149
|
async function connectorFor(provider, args) {
|
|
150
150
|
if (provider === "hubspot") {
|
|
@@ -164,7 +164,7 @@ async function connectorFor(provider, args) {
|
|
|
164
164
|
if (provider === "stripe") {
|
|
165
165
|
const key = process.env.STRIPE_SECRET_KEY ?? getCredential("stripe")?.accessToken;
|
|
166
166
|
if (!key) {
|
|
167
|
-
throw new Error("No Stripe credentials. Run `fullstackgtm login stripe
|
|
167
|
+
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
168
|
}
|
|
169
169
|
return createStripeConnector({ getApiKey: () => key });
|
|
170
170
|
}
|
|
@@ -868,6 +868,10 @@ export async function runCli(argv) {
|
|
|
868
868
|
console.log(usage());
|
|
869
869
|
return;
|
|
870
870
|
}
|
|
871
|
+
if (command === "--version" || command === "-v" || command === "version") {
|
|
872
|
+
console.log(readPackageInfo().version);
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
871
875
|
if (command === "login") {
|
|
872
876
|
await login(args);
|
|
873
877
|
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}`);
|
|
@@ -154,6 +154,10 @@ export async function runHubspotLoopbackLogin(options) {
|
|
|
154
154
|
});
|
|
155
155
|
}
|
|
156
156
|
export async function openInBrowser(url) {
|
|
157
|
+
// Headless contexts (agent sandboxes, CI, tests) suppress the OS browser;
|
|
158
|
+
// every flow that opens a URL also prints it for manual use.
|
|
159
|
+
if (process.env.FSGTM_NO_BROWSER)
|
|
160
|
+
return;
|
|
157
161
|
// The URL may come from an external source (e.g. a broker deployment's
|
|
158
162
|
// verification URL). Only ever hand a well-formed http(s) URL to the OS
|
|
159
163
|
// 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}`);
|
package/dist/mcp.js
CHANGED
|
@@ -1,8 +1,39 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
1
9
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import { join, resolve } from "node:path";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
/**
|
|
14
|
+
* The MCP peers resolve normally when installed alongside this package, but
|
|
15
|
+
* `npx -p fullstackgtm -p @modelcontextprotocol/sdk -p zod` skips installing
|
|
16
|
+
* peers into the npx cache when the invoking project's node_modules already
|
|
17
|
+
* satisfies them — and the cache can't reach the project's tree. Fall back to
|
|
18
|
+
* resolving from the working directory: peer dependencies' natural home.
|
|
19
|
+
*/
|
|
20
|
+
async function importPeer(specifier) {
|
|
21
|
+
try {
|
|
22
|
+
return (await import(__rewriteRelativeImportExtension(specifier)));
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
try {
|
|
26
|
+
const projectRequire = createRequire(join(process.cwd(), "package.json"));
|
|
27
|
+
return (await import(__rewriteRelativeImportExtension(pathToFileURL(projectRequire.resolve(specifier)).href)));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw error; // the original error carries the missing-peer signal mcp-bin reports on
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const { McpServer } = await importPeer("@modelcontextprotocol/sdk/server/mcp.js");
|
|
35
|
+
const { StdioServerTransport } = await importPeer("@modelcontextprotocol/sdk/server/stdio.js");
|
|
36
|
+
const { z } = await importPeer("zod/v4");
|
|
6
37
|
import { auditSnapshot, defaultPolicy } from "./audit.js";
|
|
7
38
|
import { loadConfig, mergePolicy, resolveConfiguredRules } from "./config.js";
|
|
8
39
|
import { applyPatchPlan } from "./connector.js";
|
|
@@ -84,7 +115,8 @@ export async function startMcpServer() {
|
|
|
84
115
|
server.registerTool("fullstackgtm_audit", {
|
|
85
116
|
title: "GTM Ops Audit",
|
|
86
117
|
description: "Run a dry-run GTM hygiene audit and return a reviewable patch plan. " +
|
|
87
|
-
"
|
|
118
|
+
"Sources: the realistic zero-credential demo CRM (provider: \"demo\" — richest test data), " +
|
|
119
|
+
"the minimal sample dataset, a snapshot file, or a live provider.",
|
|
88
120
|
inputSchema: {
|
|
89
121
|
provider: z.enum(["sample", "demo", "hubspot", "salesforce", "stripe"]).optional(),
|
|
90
122
|
inputPath: z.string().optional(),
|
package/docs/roadmap-to-1.0.md
CHANGED
|
@@ -106,11 +106,39 @@ The original thesis: GTM data disagrees across systems.
|
|
|
106
106
|
- Docs site with the operating-model registry as browsable reference.
|
|
107
107
|
- Performance pass: streaming snapshots for very large orgs.
|
|
108
108
|
|
|
109
|
-
##
|
|
109
|
+
## Known real-portal gaps to close before 1.0
|
|
110
|
+
|
|
111
|
+
Found by exercising the published package as a fresh RevOps user with a real
|
|
112
|
+
CRM (2026-06):
|
|
113
|
+
|
|
114
|
+
- **Pipeline-aware closed-deal detection (HubSpot).** `isClosed`/`isWon` are
|
|
115
|
+
derived by substring-matching the raw `dealstage` value against
|
|
116
|
+
`closedwon`/`closedlost`. Custom pipelines use opaque stage ids, so closed
|
|
117
|
+
deals read as open and flood stale-deal/past-close findings. Fix: resolve
|
|
118
|
+
stage metadata from `/crm/v3/pipelines` once per snapshot.
|
|
119
|
+
- **Rate-limit resilience.** No 429/retry/backoff handling anywhere; a
|
|
120
|
+
mid-size portal snapshot is ~1,000+ sequential page requests and one
|
|
121
|
+
transient failure aborts the run. Fix: honor `Retry-After`, retry
|
|
122
|
+
idempotent reads with backoff.
|
|
123
|
+
- **`fetchChanges` 10k truncation.** The HubSpot search API caps at 10,000
|
|
124
|
+
results; the connector stops silently at MAX_PAGES. Fix: detect the cap
|
|
125
|
+
and fall back to (or instruct) a full snapshot, loudly.
|
|
126
|
+
- **MCP plan hand-off.** `fullstackgtm_audit` can only return the full plan
|
|
127
|
+
inline (200KB+ on real data) while `fullstackgtm_apply` requires a file
|
|
128
|
+
path. Fix: an `outPath` option on the audit tool plus a summary-only
|
|
129
|
+
output mode.
|
|
130
|
+
- **Scope-complete login validation.** `login hubspot` validates against the
|
|
131
|
+
owners endpoint only, so an under-scoped token passes login and fails
|
|
132
|
+
mid-audit. Fix: probe each required object endpoint at login and report
|
|
133
|
+
missing scopes up front.
|
|
134
|
+
|
|
135
|
+
## 1.0.0 — The contract (not yet declared)
|
|
110
136
|
|
|
111
137
|
Semver stability commitment on the canonical model, rule interface, connector
|
|
112
|
-
contract, plan format, CLI, and MCP tools.
|
|
113
|
-
|
|
138
|
+
contract, plan format, CLI, and MCP tools. Declared only after the API
|
|
139
|
+
surface has survived external usage and the real-portal gaps above are
|
|
140
|
+
closed. From there, new providers and new rules are minor releases; the
|
|
141
|
+
model only breaks at 2.0.
|
|
114
142
|
|
|
115
143
|
## Deliberately out of scope until after 1.0
|
|
116
144
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Full Stack GTM",
|
package/src/cli.ts
CHANGED
|
@@ -157,7 +157,7 @@ async function hubspotConnection(args: string[]) {
|
|
|
157
157
|
const stored = await resolveHubspotConnection();
|
|
158
158
|
if (stored) return stored;
|
|
159
159
|
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.",
|
|
160
|
+
"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
161
|
);
|
|
162
162
|
}
|
|
163
163
|
|
|
@@ -171,7 +171,7 @@ async function salesforceConnection() {
|
|
|
171
171
|
const stored = await resolveSalesforceConnection();
|
|
172
172
|
if (stored) return stored;
|
|
173
173
|
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.",
|
|
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. (`fullstackgtm doctor` shows credential status; device-flow login needs an admin-created Connected App — see the README's \"Connect your CRM\" section.)",
|
|
175
175
|
);
|
|
176
176
|
}
|
|
177
177
|
|
|
@@ -198,7 +198,7 @@ async function connectorFor(provider: string, args: string[]): Promise<GtmConnec
|
|
|
198
198
|
process.env.STRIPE_SECRET_KEY ?? getCredential("stripe")?.accessToken;
|
|
199
199
|
if (!key) {
|
|
200
200
|
throw new Error(
|
|
201
|
-
"No Stripe credentials. Run `fullstackgtm login stripe
|
|
201
|
+
"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
202
|
);
|
|
203
203
|
}
|
|
204
204
|
return createStripeConnector({ getApiKey: () => key });
|
|
@@ -993,6 +993,10 @@ export async function runCli(argv: string[]) {
|
|
|
993
993
|
console.log(usage());
|
|
994
994
|
return;
|
|
995
995
|
}
|
|
996
|
+
if (command === "--version" || command === "-v" || command === "version") {
|
|
997
|
+
console.log(readPackageInfo().version);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
996
1000
|
|
|
997
1001
|
if (command === "login") {
|
|
998
1002
|
await login(args);
|
|
@@ -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}`);
|
|
@@ -212,6 +212,9 @@ export async function runHubspotLoopbackLogin(
|
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
export async function openInBrowser(url: string) {
|
|
215
|
+
// Headless contexts (agent sandboxes, CI, tests) suppress the OS browser;
|
|
216
|
+
// every flow that opens a URL also prints it for manual use.
|
|
217
|
+
if (process.env.FSGTM_NO_BROWSER) return;
|
|
215
218
|
// The URL may come from an external source (e.g. a broker deployment's
|
|
216
219
|
// verification URL). Only ever hand a well-formed http(s) URL to the OS
|
|
217
220
|
// 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}`);
|
package/src/mcp.ts
CHANGED
|
@@ -1,8 +1,35 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The MCP peers resolve normally when installed alongside this package, but
|
|
8
|
+
* `npx -p fullstackgtm -p @modelcontextprotocol/sdk -p zod` skips installing
|
|
9
|
+
* peers into the npx cache when the invoking project's node_modules already
|
|
10
|
+
* satisfies them — and the cache can't reach the project's tree. Fall back to
|
|
11
|
+
* resolving from the working directory: peer dependencies' natural home.
|
|
12
|
+
*/
|
|
13
|
+
async function importPeer<T>(specifier: string): Promise<T> {
|
|
14
|
+
try {
|
|
15
|
+
return (await import(specifier)) as T;
|
|
16
|
+
} catch (error) {
|
|
17
|
+
try {
|
|
18
|
+
const projectRequire = createRequire(join(process.cwd(), "package.json"));
|
|
19
|
+
return (await import(pathToFileURL(projectRequire.resolve(specifier)).href)) as T;
|
|
20
|
+
} catch {
|
|
21
|
+
throw error; // the original error carries the missing-peer signal mcp-bin reports on
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { McpServer } = await importPeer<typeof import("@modelcontextprotocol/sdk/server/mcp.js")>(
|
|
27
|
+
"@modelcontextprotocol/sdk/server/mcp.js",
|
|
28
|
+
);
|
|
29
|
+
const { StdioServerTransport } = await importPeer<typeof import("@modelcontextprotocol/sdk/server/stdio.js")>(
|
|
30
|
+
"@modelcontextprotocol/sdk/server/stdio.js",
|
|
31
|
+
);
|
|
32
|
+
const { z } = await importPeer<typeof import("zod/v4")>("zod/v4");
|
|
6
33
|
import { auditSnapshot, defaultPolicy } from "./audit.ts";
|
|
7
34
|
import { loadConfig, mergePolicy, resolveConfiguredRules } from "./config.ts";
|
|
8
35
|
import { applyPatchPlan } from "./connector.ts";
|
|
@@ -113,7 +140,8 @@ export async function startMcpServer() {
|
|
|
113
140
|
title: "GTM Ops Audit",
|
|
114
141
|
description:
|
|
115
142
|
"Run a dry-run GTM hygiene audit and return a reviewable patch plan. " +
|
|
116
|
-
"
|
|
143
|
+
"Sources: the realistic zero-credential demo CRM (provider: \"demo\" — richest test data), " +
|
|
144
|
+
"the minimal sample dataset, a snapshot file, or a live provider.",
|
|
117
145
|
inputSchema: {
|
|
118
146
|
provider: z.enum(["sample", "demo", "hubspot", "salesforce", "stripe"]).optional(),
|
|
119
147
|
inputPath: z.string().optional(),
|