fullstackgtm 0.10.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 +381 -0
- package/INSTALL_FOR_AGENTS.md +87 -0
- package/LICENSE +202 -0
- package/README.md +230 -0
- package/dist/audit.d.ts +7 -0
- package/dist/audit.js +202 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +6 -0
- package/dist/cli.d.ts +38 -0
- package/dist/cli.js +915 -0
- package/dist/config.d.ts +36 -0
- package/dist/config.js +85 -0
- package/dist/connector.d.ts +30 -0
- package/dist/connector.js +94 -0
- package/dist/connectors/hubspot.d.ts +20 -0
- package/dist/connectors/hubspot.js +409 -0
- package/dist/connectors/hubspotAuth.d.ts +42 -0
- package/dist/connectors/hubspotAuth.js +189 -0
- package/dist/connectors/salesforce.d.ts +26 -0
- package/dist/connectors/salesforce.js +318 -0
- package/dist/connectors/salesforceAuth.d.ts +44 -0
- package/dist/connectors/salesforceAuth.js +120 -0
- package/dist/connectors/stripe.d.ts +27 -0
- package/dist/connectors/stripe.js +176 -0
- package/dist/credentials.d.ts +75 -0
- package/dist/credentials.js +197 -0
- package/dist/demo.d.ts +20 -0
- package/dist/demo.js +169 -0
- package/dist/diff.d.ts +46 -0
- package/dist/diff.js +107 -0
- package/dist/format.d.ts +3 -0
- package/dist/format.js +109 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +17 -0
- package/dist/mappings.d.ts +8 -0
- package/dist/mappings.js +123 -0
- package/dist/mcp-bin.d.ts +2 -0
- package/dist/mcp-bin.js +33 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +140 -0
- package/dist/merge.d.ts +48 -0
- package/dist/merge.js +145 -0
- package/dist/planStore.d.ts +31 -0
- package/dist/planStore.js +116 -0
- package/dist/rules.d.ts +24 -0
- package/dist/rules.js +512 -0
- package/dist/sampleData.d.ts +2 -0
- package/dist/sampleData.js +115 -0
- package/dist/types.d.ts +294 -0
- package/dist/types.js +8 -0
- package/docs/api.md +72 -0
- package/docs/roadmap-to-1.0.md +121 -0
- package/llms.txt +25 -0
- package/package.json +76 -0
- package/src/audit.ts +242 -0
- package/src/bin.ts +7 -0
- package/src/cli.ts +1042 -0
- package/src/config.ts +113 -0
- package/src/connector.ts +140 -0
- package/src/connectors/hubspot.ts +528 -0
- package/src/connectors/hubspotAuth.ts +246 -0
- package/src/connectors/salesforce.ts +420 -0
- package/src/connectors/salesforceAuth.ts +167 -0
- package/src/connectors/stripe.ts +215 -0
- package/src/credentials.ts +282 -0
- package/src/demo.ts +200 -0
- package/src/diff.ts +158 -0
- package/src/format.ts +162 -0
- package/src/index.ts +129 -0
- package/src/mappings.ts +157 -0
- package/src/mcp-bin.ts +32 -0
- package/src/mcp.ts +185 -0
- package/src/merge.ts +235 -0
- package/src/planStore.ts +155 -0
- package/src/rules.ts +539 -0
- package/src/sampleData.ts +117 -0
- package/src/types.ts +372 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,915 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { auditSnapshot, defaultPolicy } from "./audit.js";
|
|
4
|
+
import { loadConfig, mergePolicy, resolveConfiguredRules } from "./config.js";
|
|
5
|
+
import { applyPatchPlan } from "./connector.js";
|
|
6
|
+
import { diffFindings, diffSnapshots, diffToMarkdown } from "./diff.js";
|
|
7
|
+
import { createHubspotConnector } from "./connectors/hubspot.js";
|
|
8
|
+
import { DEFAULT_LOOPBACK_PORT, openInBrowser, runHubspotLoopbackLogin, validateHubspotToken, } from "./connectors/hubspotAuth.js";
|
|
9
|
+
import { createSalesforceConnector } from "./connectors/salesforce.js";
|
|
10
|
+
import { createStripeConnector } from "./connectors/stripe.js";
|
|
11
|
+
import { pollSalesforceDeviceLogin, startSalesforceDeviceLogin, validateSalesforceToken, } from "./connectors/salesforceAuth.js";
|
|
12
|
+
import { credentialsPath, deleteCredential, getCredential, resolveHubspotConnection, resolveSalesforceConnection, storeCredential, } from "./credentials.js";
|
|
13
|
+
import { generateDemoSnapshot } from "./demo.js";
|
|
14
|
+
import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
|
|
15
|
+
import { mergeSnapshots } from "./merge.js";
|
|
16
|
+
import { createFilePlanStore } from "./planStore.js";
|
|
17
|
+
import { builtinAuditRules } from "./rules.js";
|
|
18
|
+
import { sampleSnapshot } from "./sampleData.js";
|
|
19
|
+
function usage() {
|
|
20
|
+
return `FullStackGTM — audit GTM data across providers, propose reviewable patch plans,
|
|
21
|
+
and apply only explicitly approved operations.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
fullstackgtm login --via <hosted url> pair with a team deployment (recommended)
|
|
25
|
+
fullstackgtm login hubspot [--no-validate]
|
|
26
|
+
fullstackgtm login hubspot --oauth --client-id <id> [--port ${DEFAULT_LOOPBACK_PORT}] [--scopes a,b]
|
|
27
|
+
fullstackgtm login salesforce --device --client-id <consumer key> [--login-url <url>]
|
|
28
|
+
fullstackgtm login salesforce --instance-url <url> [--no-validate]
|
|
29
|
+
fullstackgtm login stripe [--no-validate]
|
|
30
|
+
fullstackgtm logout <hubspot|salesforce|stripe|broker>
|
|
31
|
+
|
|
32
|
+
Secrets (tokens, client secrets) are NEVER passed as flags — they leak via
|
|
33
|
+
the process list and shell history. Pipe them on stdin or enter them at the
|
|
34
|
+
interactive prompt:
|
|
35
|
+
echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot
|
|
36
|
+
fullstackgtm snapshot [source options] [--since <iso>] [--out <path> | --archive <dir>]
|
|
37
|
+
fullstackgtm audit [source options] [audit options] [--save]
|
|
38
|
+
fullstackgtm diff --before <a.json> --after <b.json> [--json] [--fail-on-new-findings]
|
|
39
|
+
fullstackgtm merge --input <a.json> --input <b.json> [...] --out <merged.json> [--json]
|
|
40
|
+
fullstackgtm plans list [--status <s>] | show <id> | approve <id> --operations <ids|all> | reject <id>
|
|
41
|
+
fullstackgtm apply --plan-id <id> --provider <name>
|
|
42
|
+
fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [options]
|
|
43
|
+
fullstackgtm rules [--json]
|
|
44
|
+
fullstackgtm doctor [--json] check install, credentials, and next step
|
|
45
|
+
|
|
46
|
+
Plan lifecycle:
|
|
47
|
+
audit --save persists the dry-run plan to ~/.fullstackgtm/plans. Approve
|
|
48
|
+
specific operations (optionally with --value <opId>=<v> for placeholders),
|
|
49
|
+
then apply by id — the store enforces approval and records every run.
|
|
50
|
+
|
|
51
|
+
Authentication (checked in order):
|
|
52
|
+
1. --token-env <name> explicit env var for this invocation (hubspot)
|
|
53
|
+
2. ambient env HUBSPOT_ACCESS_TOKEN, or SALESFORCE_ACCESS_TOKEN +
|
|
54
|
+
SALESFORCE_INSTANCE_URL (CI, agent sandboxes)
|
|
55
|
+
3. stored provider login fullstackgtm login <provider>; kept in ~/.fullstackgtm
|
|
56
|
+
(hubspot: private app token or loopback OAuth;
|
|
57
|
+
salesforce: device flow — code on any device, no
|
|
58
|
+
secret, silent refresh)
|
|
59
|
+
4. broker pairing fullstackgtm login --via <url>: the team connects the
|
|
60
|
+
CRM once in the hosted dashboard; every paired CLI
|
|
61
|
+
uses those stored sync credentials from then on
|
|
62
|
+
|
|
63
|
+
Source options (snapshot and audit):
|
|
64
|
+
--sample Built-in minimal mock CRM data (default)
|
|
65
|
+
--demo Realistic generated mid-market CRM with injected hygiene issues
|
|
66
|
+
--seed <n> Demo PRNG seed (default 7; same seed, same data)
|
|
67
|
+
--input <path> Canonical GTM snapshot JSON file
|
|
68
|
+
--provider <name> Live provider snapshot: hubspot | salesforce | stripe (read-only)
|
|
69
|
+
--token-env <name> Env var holding the provider access token
|
|
70
|
+
|
|
71
|
+
Audit options:
|
|
72
|
+
--config <path> Config file (default: ./fullstackgtm.config.json if present)
|
|
73
|
+
{ "policy": {...}, "rules": {"enabled":[],"disabled":[]},
|
|
74
|
+
"rulePackages": ["./team-rules.mjs"] }
|
|
75
|
+
--rules <ids> Comma-separated rule ids to run (default: all; see \`rules\`)
|
|
76
|
+
--json Print the JSON patch plan instead of markdown
|
|
77
|
+
--out <path> Also write the JSON patch plan to a file
|
|
78
|
+
--today <date> Override today's date for deterministic audits
|
|
79
|
+
--stale-days <n> Days without activity before an open deal is stale
|
|
80
|
+
--fail-on <severity> Exit 2 if any finding is at or above info|warning|critical
|
|
81
|
+
|
|
82
|
+
Apply options:
|
|
83
|
+
--plan <path> Patch plan JSON produced by \`audit --out\`
|
|
84
|
+
--provider hubspot Connector to apply through
|
|
85
|
+
--token-env <name> Env var holding the provider access token
|
|
86
|
+
--approve <ids|all> Comma-separated operation ids to apply, or "all"
|
|
87
|
+
--value <opId>=<v> Concrete value for a requires_human_* placeholder (repeatable)
|
|
88
|
+
--json Print the JSON run record instead of markdown
|
|
89
|
+
|
|
90
|
+
Exit codes:
|
|
91
|
+
0 success · 1 error · 2 audit findings at/above the --fail-on threshold
|
|
92
|
+
|
|
93
|
+
Safety:
|
|
94
|
+
Audits are read-only. Apply writes only operations you explicitly approve,
|
|
95
|
+
and never writes requires_human_* placeholders without a --value override.`;
|
|
96
|
+
}
|
|
97
|
+
function option(args, name) {
|
|
98
|
+
const index = args.indexOf(name);
|
|
99
|
+
if (index === -1)
|
|
100
|
+
return null;
|
|
101
|
+
return args[index + 1] ?? null;
|
|
102
|
+
}
|
|
103
|
+
function repeatedOption(args, name) {
|
|
104
|
+
const values = [];
|
|
105
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
106
|
+
if (args[index] === name && args[index + 1] !== undefined) {
|
|
107
|
+
values.push(args[index + 1]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return values;
|
|
111
|
+
}
|
|
112
|
+
function numericOption(args, name) {
|
|
113
|
+
const value = option(args, name);
|
|
114
|
+
if (value === null)
|
|
115
|
+
return undefined;
|
|
116
|
+
const parsed = Number(value);
|
|
117
|
+
if (!Number.isFinite(parsed))
|
|
118
|
+
throw new Error(`${name} must be a number`);
|
|
119
|
+
return parsed;
|
|
120
|
+
}
|
|
121
|
+
async function hubspotConnection(args) {
|
|
122
|
+
const tokenEnv = option(args, "--token-env");
|
|
123
|
+
if (tokenEnv) {
|
|
124
|
+
const token = process.env[tokenEnv];
|
|
125
|
+
if (!token)
|
|
126
|
+
throw new Error(`${tokenEnv} is not set.`);
|
|
127
|
+
return { accessToken: token };
|
|
128
|
+
}
|
|
129
|
+
if (process.env.HUBSPOT_ACCESS_TOKEN) {
|
|
130
|
+
return { accessToken: process.env.HUBSPOT_ACCESS_TOKEN };
|
|
131
|
+
}
|
|
132
|
+
const stored = await resolveHubspotConnection();
|
|
133
|
+
if (stored)
|
|
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.");
|
|
136
|
+
}
|
|
137
|
+
async function salesforceConnection() {
|
|
138
|
+
if (process.env.SALESFORCE_ACCESS_TOKEN && process.env.SALESFORCE_INSTANCE_URL) {
|
|
139
|
+
return {
|
|
140
|
+
accessToken: process.env.SALESFORCE_ACCESS_TOKEN,
|
|
141
|
+
instanceUrl: process.env.SALESFORCE_INSTANCE_URL,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const stored = await resolveSalesforceConnection();
|
|
145
|
+
if (stored)
|
|
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.");
|
|
148
|
+
}
|
|
149
|
+
async function connectorFor(provider, args) {
|
|
150
|
+
if (provider === "hubspot") {
|
|
151
|
+
const connection = await hubspotConnection(args);
|
|
152
|
+
return createHubspotConnector({
|
|
153
|
+
getAccessToken: () => connection.accessToken,
|
|
154
|
+
fieldMappings: connection.fieldMappings ?? undefined,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (provider === "salesforce") {
|
|
158
|
+
const connection = await salesforceConnection();
|
|
159
|
+
return createSalesforceConnector({
|
|
160
|
+
getConnection: () => connection,
|
|
161
|
+
fieldMappings: connection.fieldMappings ?? undefined,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (provider === "stripe") {
|
|
165
|
+
const key = process.env.STRIPE_SECRET_KEY ?? getCredential("stripe")?.accessToken;
|
|
166
|
+
if (!key) {
|
|
167
|
+
throw new Error("No Stripe credentials. Run `fullstackgtm login stripe --token sk_...` or set STRIPE_SECRET_KEY.");
|
|
168
|
+
}
|
|
169
|
+
return createStripeConnector({ getApiKey: () => key });
|
|
170
|
+
}
|
|
171
|
+
throw new Error(`Unknown provider: ${provider}. Supported providers: hubspot, salesforce, stripe`);
|
|
172
|
+
}
|
|
173
|
+
async function readSnapshot(args) {
|
|
174
|
+
const provider = option(args, "--provider");
|
|
175
|
+
if (provider) {
|
|
176
|
+
const connector = await connectorFor(provider, args);
|
|
177
|
+
return connector.fetchSnapshot();
|
|
178
|
+
}
|
|
179
|
+
if (args.includes("--demo")) {
|
|
180
|
+
return generateDemoSnapshot({
|
|
181
|
+
seed: numericOption(args, "--seed"),
|
|
182
|
+
today: option(args, "--today") ?? undefined,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
const input = option(args, "--input");
|
|
186
|
+
if (!input || args.includes("--sample"))
|
|
187
|
+
return sampleSnapshot;
|
|
188
|
+
const path = resolve(process.cwd(), input);
|
|
189
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
190
|
+
}
|
|
191
|
+
function selectedRules(args, baseRules = builtinAuditRules) {
|
|
192
|
+
const requested = option(args, "--rules");
|
|
193
|
+
if (!requested)
|
|
194
|
+
return baseRules;
|
|
195
|
+
const ids = requested.split(",").map((id) => id.trim()).filter(Boolean);
|
|
196
|
+
const known = new Map(baseRules.map((rule) => [rule.id, rule]));
|
|
197
|
+
const rules = ids.map((id) => {
|
|
198
|
+
const rule = known.get(id);
|
|
199
|
+
if (!rule) {
|
|
200
|
+
throw new Error(`Unknown rule: ${id}. Available rules: ${baseRules.map((r) => r.id).join(", ")}`);
|
|
201
|
+
}
|
|
202
|
+
return rule;
|
|
203
|
+
});
|
|
204
|
+
return rules;
|
|
205
|
+
}
|
|
206
|
+
const SEVERITY_RANK = {
|
|
207
|
+
info: 0,
|
|
208
|
+
warning: 1,
|
|
209
|
+
critical: 2,
|
|
210
|
+
};
|
|
211
|
+
function failOnThreshold(args) {
|
|
212
|
+
const value = option(args, "--fail-on");
|
|
213
|
+
if (!value)
|
|
214
|
+
return null;
|
|
215
|
+
if (value !== "info" && value !== "warning" && value !== "critical") {
|
|
216
|
+
throw new Error("--fail-on must be one of: info, warning, critical");
|
|
217
|
+
}
|
|
218
|
+
return value;
|
|
219
|
+
}
|
|
220
|
+
async function snapshotCommand(args) {
|
|
221
|
+
const since = option(args, "--since");
|
|
222
|
+
let snapshot;
|
|
223
|
+
if (since) {
|
|
224
|
+
const provider = option(args, "--provider");
|
|
225
|
+
if (!provider)
|
|
226
|
+
throw new Error("--since requires --provider <name>");
|
|
227
|
+
const connector = await connectorFor(provider, args);
|
|
228
|
+
if (!connector.fetchChanges) {
|
|
229
|
+
throw new Error(`The ${provider} connector does not support incremental fetch.`);
|
|
230
|
+
}
|
|
231
|
+
snapshot = await connector.fetchChanges(since);
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
snapshot = await readSnapshot(args);
|
|
235
|
+
}
|
|
236
|
+
const serialized = `${JSON.stringify(snapshot, null, 2)}\n`;
|
|
237
|
+
const archive = option(args, "--archive");
|
|
238
|
+
if (archive) {
|
|
239
|
+
const dir = resolve(process.cwd(), archive);
|
|
240
|
+
const { mkdirSync } = await import("node:fs");
|
|
241
|
+
mkdirSync(dir, { recursive: true });
|
|
242
|
+
const fileName = `${snapshot.provider}-${snapshot.generatedAt.replace(/[:.]/g, "-")}.json`;
|
|
243
|
+
const path = resolve(dir, fileName);
|
|
244
|
+
writeFileSync(path, serialized);
|
|
245
|
+
console.log(`Archived snapshot to ${path}`);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const out = option(args, "--out");
|
|
249
|
+
if (out) {
|
|
250
|
+
writeFileSync(resolve(process.cwd(), out), serialized);
|
|
251
|
+
console.log(`Wrote snapshot (${snapshot.users.length} users, ${snapshot.accounts.length} accounts, ` +
|
|
252
|
+
`${snapshot.contacts.length} contacts, ${snapshot.deals.length} deals, ` +
|
|
253
|
+
`${snapshot.activities.length} activities) to ${out}`);
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
console.log(serialized.trimEnd());
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async function audit(args) {
|
|
260
|
+
const threshold = failOnThreshold(args);
|
|
261
|
+
const loaded = loadConfig(option(args, "--config") ?? undefined);
|
|
262
|
+
const rules = selectedRules(args, await resolveConfiguredRules(loaded));
|
|
263
|
+
const snapshot = await readSnapshot(args);
|
|
264
|
+
const policy = mergePolicy(defaultPolicy(), loaded?.config);
|
|
265
|
+
const today = option(args, "--today");
|
|
266
|
+
if (today)
|
|
267
|
+
policy.today = today;
|
|
268
|
+
const staleDealDays = numericOption(args, "--stale-days");
|
|
269
|
+
if (staleDealDays !== undefined)
|
|
270
|
+
policy.staleDealDays = staleDealDays;
|
|
271
|
+
const plan = auditSnapshot(snapshot, policy, rules);
|
|
272
|
+
const out = option(args, "--out");
|
|
273
|
+
if (out) {
|
|
274
|
+
writeFileSync(resolve(process.cwd(), out), `${JSON.stringify(plan, null, 2)}\n`);
|
|
275
|
+
}
|
|
276
|
+
if (args.includes("--save")) {
|
|
277
|
+
await createFilePlanStore().save(plan);
|
|
278
|
+
console.error(`Saved plan ${plan.id}. Review with \`fullstackgtm plans show ${plan.id}\`, approve with \`fullstackgtm plans approve ${plan.id} --operations <ids|all>\`.`);
|
|
279
|
+
}
|
|
280
|
+
if (args.includes("--json")) {
|
|
281
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
console.log(patchPlanToMarkdown(plan));
|
|
285
|
+
}
|
|
286
|
+
if (threshold &&
|
|
287
|
+
plan.findings.some((finding) => SEVERITY_RANK[finding.severity] >= SEVERITY_RANK[threshold])) {
|
|
288
|
+
process.exitCode = 2;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
async function rulesCommand(args) {
|
|
292
|
+
const loaded = loadConfig(option(args, "--config") ?? undefined);
|
|
293
|
+
const rules = await resolveConfiguredRules(loaded);
|
|
294
|
+
if (args.includes("--json")) {
|
|
295
|
+
console.log(JSON.stringify(rules.map(({ id, title, description, category }) => ({
|
|
296
|
+
id,
|
|
297
|
+
title,
|
|
298
|
+
description,
|
|
299
|
+
category: category ?? "uncategorized",
|
|
300
|
+
})), null, 2));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
for (const rule of rules) {
|
|
304
|
+
console.log(`${rule.id} [${rule.category ?? "uncategorized"}]\n ${rule.title}\n ${rule.description}\n`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Provider error bodies can echo request parameters (including secrets) and
|
|
309
|
+
* land in logs. Surface only the status and, when present, a known-safe error
|
|
310
|
+
* description field — never the raw body.
|
|
311
|
+
*/
|
|
312
|
+
function safeStatus(response) {
|
|
313
|
+
return `HTTP ${response.status} ${response.statusText}`.trim();
|
|
314
|
+
}
|
|
315
|
+
function parseValueOverrides(args) {
|
|
316
|
+
const valueOverrides = {};
|
|
317
|
+
for (const pair of repeatedOption(args, "--value")) {
|
|
318
|
+
const separator = pair.indexOf("=");
|
|
319
|
+
if (separator === -1)
|
|
320
|
+
throw new Error(`--value must look like <operationId>=<value>`);
|
|
321
|
+
valueOverrides[pair.slice(0, separator)] = pair.slice(separator + 1);
|
|
322
|
+
}
|
|
323
|
+
return valueOverrides;
|
|
324
|
+
}
|
|
325
|
+
async function apply(args) {
|
|
326
|
+
const provider = option(args, "--provider");
|
|
327
|
+
if (!provider)
|
|
328
|
+
throw new Error("apply requires --provider <name>");
|
|
329
|
+
const planId = option(args, "--plan-id");
|
|
330
|
+
const planPath = option(args, "--plan");
|
|
331
|
+
if (!planId && !planPath)
|
|
332
|
+
throw new Error("apply requires --plan <path> or --plan-id <id>");
|
|
333
|
+
let plan;
|
|
334
|
+
let approvedOperationIds;
|
|
335
|
+
let valueOverrides;
|
|
336
|
+
const store = planId ? createFilePlanStore() : null;
|
|
337
|
+
if (planId && store) {
|
|
338
|
+
const stored = await store.get(planId);
|
|
339
|
+
if (!stored)
|
|
340
|
+
throw new Error(`No stored plan with id ${planId}.`);
|
|
341
|
+
if (stored.status !== "approved") {
|
|
342
|
+
throw new Error(`Plan ${planId} is ${stored.status}; approve operations first with \`fullstackgtm plans approve ${planId} --operations <ids|all>\`.`);
|
|
343
|
+
}
|
|
344
|
+
plan = stored.plan;
|
|
345
|
+
approvedOperationIds = stored.approvedOperationIds;
|
|
346
|
+
valueOverrides = { ...stored.valueOverrides, ...parseValueOverrides(args) };
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
const approve = option(args, "--approve");
|
|
350
|
+
if (!approve) {
|
|
351
|
+
throw new Error('apply requires --approve <ids|all>; nothing is written without approval');
|
|
352
|
+
}
|
|
353
|
+
plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8"));
|
|
354
|
+
approvedOperationIds =
|
|
355
|
+
approve === "all"
|
|
356
|
+
? plan.operations.map((operation) => operation.id)
|
|
357
|
+
: approve.split(",").map((id) => id.trim()).filter(Boolean);
|
|
358
|
+
valueOverrides = parseValueOverrides(args);
|
|
359
|
+
}
|
|
360
|
+
const connector = await connectorFor(provider, args);
|
|
361
|
+
const run = await applyPatchPlan(connector, plan, {
|
|
362
|
+
approvedOperationIds,
|
|
363
|
+
valueOverrides,
|
|
364
|
+
});
|
|
365
|
+
if (planId && store) {
|
|
366
|
+
await store.recordRun(planId, run);
|
|
367
|
+
}
|
|
368
|
+
if (args.includes("--json")) {
|
|
369
|
+
console.log(JSON.stringify(run, null, 2));
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
console.log(formatPatchPlanRun(run));
|
|
373
|
+
}
|
|
374
|
+
if (run.status === "failed")
|
|
375
|
+
process.exitCode = 1;
|
|
376
|
+
}
|
|
377
|
+
async function diffCommand(args) {
|
|
378
|
+
const beforePath = option(args, "--before");
|
|
379
|
+
const afterPath = option(args, "--after");
|
|
380
|
+
if (!beforePath || !afterPath) {
|
|
381
|
+
throw new Error("diff requires --before <snapshot.json> and --after <snapshot.json>");
|
|
382
|
+
}
|
|
383
|
+
const before = JSON.parse(readFileSync(resolve(process.cwd(), beforePath), "utf8"));
|
|
384
|
+
const after = JSON.parse(readFileSync(resolve(process.cwd(), afterPath), "utf8"));
|
|
385
|
+
const loaded = loadConfig(option(args, "--config") ?? undefined);
|
|
386
|
+
const rules = selectedRules(args, await resolveConfiguredRules(loaded));
|
|
387
|
+
const policy = mergePolicy(defaultPolicy(), loaded?.config);
|
|
388
|
+
const today = option(args, "--today");
|
|
389
|
+
if (today)
|
|
390
|
+
policy.today = today;
|
|
391
|
+
const staleDealDays = numericOption(args, "--stale-days");
|
|
392
|
+
if (staleDealDays !== undefined)
|
|
393
|
+
policy.staleDealDays = staleDealDays;
|
|
394
|
+
const diff = diffSnapshots(before, after);
|
|
395
|
+
const drift = diffFindings(auditSnapshot(before, policy, rules), auditSnapshot(after, policy, rules));
|
|
396
|
+
if (args.includes("--json")) {
|
|
397
|
+
console.log(JSON.stringify({ diff, drift }, null, 2));
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
console.log(diffToMarkdown(diff, drift));
|
|
401
|
+
}
|
|
402
|
+
if (args.includes("--fail-on-new-findings") && drift.newFindings.length > 0) {
|
|
403
|
+
process.exitCode = 2;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
async function mergeCommand(args) {
|
|
407
|
+
const inputs = repeatedOption(args, "--input");
|
|
408
|
+
if (inputs.length < 2) {
|
|
409
|
+
throw new Error("merge requires at least two --input <snapshot.json> sources");
|
|
410
|
+
}
|
|
411
|
+
const out = option(args, "--out");
|
|
412
|
+
if (!out)
|
|
413
|
+
throw new Error("merge requires --out <merged.json>");
|
|
414
|
+
const snapshots = inputs.map((input) => JSON.parse(readFileSync(resolve(process.cwd(), input), "utf8")));
|
|
415
|
+
const { snapshot, report } = mergeSnapshots(snapshots);
|
|
416
|
+
writeFileSync(resolve(process.cwd(), out), `${JSON.stringify(snapshot, null, 2)}\n`);
|
|
417
|
+
if (args.includes("--json")) {
|
|
418
|
+
console.log(JSON.stringify(report, null, 2));
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
console.log(`Merged ${report.sources.join(" + ")} into ${out}.`);
|
|
422
|
+
for (const type of ["user", "account", "contact"]) {
|
|
423
|
+
const count = report.matches.filter((match) => match.type === type).length;
|
|
424
|
+
console.log(` ${type} merges: ${count}`);
|
|
425
|
+
}
|
|
426
|
+
console.log(` conflicts: ${report.conflicts.length}`);
|
|
427
|
+
for (const conflict of report.conflicts) {
|
|
428
|
+
console.log(` ${conflict.type}/${conflict.recordId} ${conflict.field}: ${conflict.values
|
|
429
|
+
.map((entry) => `${entry.provider}=${JSON.stringify(entry.value)}`)
|
|
430
|
+
.join(" vs ")}`);
|
|
431
|
+
}
|
|
432
|
+
if (report.suggestions.length > 0) {
|
|
433
|
+
console.log(` review suggestions (same name, not auto-merged): ${report.suggestions.length}`);
|
|
434
|
+
for (const suggestion of report.suggestions) {
|
|
435
|
+
console.log(` "${suggestion.name}": ${suggestion.recordIds.join(", ")}`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
console.log(`Audit the merged view with \`fullstackgtm audit --input ${out}\`.`);
|
|
439
|
+
}
|
|
440
|
+
async function plansCommand(args) {
|
|
441
|
+
const store = createFilePlanStore();
|
|
442
|
+
const [subcommand, ...rest] = args;
|
|
443
|
+
if (subcommand === "list" || subcommand === undefined) {
|
|
444
|
+
const status = option(rest, "--status");
|
|
445
|
+
const plans = await store.list(status ?? undefined);
|
|
446
|
+
if (rest.includes("--json") || args.includes("--json")) {
|
|
447
|
+
console.log(JSON.stringify(plans.map((stored) => ({
|
|
448
|
+
id: stored.plan.id,
|
|
449
|
+
status: stored.status,
|
|
450
|
+
summary: stored.plan.summary,
|
|
451
|
+
approvedOperations: stored.approvedOperationIds.length,
|
|
452
|
+
runs: stored.runs.length,
|
|
453
|
+
createdAt: stored.createdAt,
|
|
454
|
+
})), null, 2));
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (plans.length === 0) {
|
|
458
|
+
console.log("No stored plans. Create one with `fullstackgtm audit ... --save`.");
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
for (const stored of plans) {
|
|
462
|
+
console.log(`${stored.plan.id} ${stored.status.padEnd(14)} ${stored.plan.summary} (${stored.approvedOperationIds.length} approved, ${stored.runs.length} runs)`);
|
|
463
|
+
}
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (subcommand === "show") {
|
|
467
|
+
const planId = rest.find((arg) => !arg.startsWith("--"));
|
|
468
|
+
if (!planId)
|
|
469
|
+
throw new Error("Usage: fullstackgtm plans show <planId>");
|
|
470
|
+
const stored = await store.get(planId);
|
|
471
|
+
if (!stored)
|
|
472
|
+
throw new Error(`No stored plan with id ${planId}.`);
|
|
473
|
+
if (rest.includes("--json")) {
|
|
474
|
+
console.log(JSON.stringify(stored, null, 2));
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
console.log(`Status: ${stored.status}`);
|
|
478
|
+
console.log(`Approved operations: ${stored.approvedOperationIds.join(", ") || "none"}`);
|
|
479
|
+
console.log(`Runs: ${stored.runs.length}`);
|
|
480
|
+
console.log("");
|
|
481
|
+
console.log(patchPlanToMarkdown(stored.plan));
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (subcommand === "approve") {
|
|
485
|
+
const planId = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
|
|
486
|
+
if (!planId)
|
|
487
|
+
throw new Error("Usage: fullstackgtm plans approve <planId> --operations <ids|all>");
|
|
488
|
+
const operations = option(rest, "--operations");
|
|
489
|
+
if (!operations)
|
|
490
|
+
throw new Error("plans approve requires --operations <ids|all>");
|
|
491
|
+
const stored = await store.get(planId);
|
|
492
|
+
if (!stored)
|
|
493
|
+
throw new Error(`No stored plan with id ${planId}.`);
|
|
494
|
+
const operationIds = operations === "all"
|
|
495
|
+
? stored.plan.operations.map((operation) => operation.id)
|
|
496
|
+
: operations.split(",").map((id) => id.trim()).filter(Boolean);
|
|
497
|
+
const updated = await store.approveOperations(planId, operationIds, parseValueOverrides(rest));
|
|
498
|
+
console.log(`Approved ${updated.approvedOperationIds.length} operation(s) on ${planId}. Apply with \`fullstackgtm apply --plan-id ${planId} --provider <name>\`.`);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (subcommand === "reject") {
|
|
502
|
+
const planId = rest.find((arg) => !arg.startsWith("--"));
|
|
503
|
+
if (!planId)
|
|
504
|
+
throw new Error("Usage: fullstackgtm plans reject <planId>");
|
|
505
|
+
await store.reject(planId);
|
|
506
|
+
console.log(`Rejected ${planId}.`);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
throw new Error(`Unknown plans subcommand: ${subcommand}. Use list, show, approve, or reject.`);
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Read a secret without exposing it on the command line. Secrets passed as
|
|
513
|
+
* argv leak through the process list (`ps`), `/proc`, and shell history, so
|
|
514
|
+
* the CLI never accepts them as flags. Instead:
|
|
515
|
+
* - non-interactive: pipe it on stdin (docker `--password-stdin` style),
|
|
516
|
+
* e.g. `echo "$TOKEN" | fullstackgtm login hubspot`
|
|
517
|
+
* - interactive: prompted on the TTY with the input muted
|
|
518
|
+
*/
|
|
519
|
+
async function readSecret(label) {
|
|
520
|
+
if (!process.stdin.isTTY) {
|
|
521
|
+
const chunks = [];
|
|
522
|
+
for await (const chunk of process.stdin) {
|
|
523
|
+
chunks.push(chunk);
|
|
524
|
+
}
|
|
525
|
+
const piped = Buffer.concat(chunks).toString("utf8").trim();
|
|
526
|
+
if (!piped) {
|
|
527
|
+
throw new Error(`No secret provided. Pipe it on stdin (e.g. \`echo "$SECRET" | fullstackgtm login ...\`) ` +
|
|
528
|
+
`or run interactively. Secrets are never accepted as CLI arguments — they leak via the ` +
|
|
529
|
+
`process list and shell history.`);
|
|
530
|
+
}
|
|
531
|
+
// Multi-line stdin (rare) — take the first non-empty line.
|
|
532
|
+
return piped.split(/\r?\n/)[0].trim();
|
|
533
|
+
}
|
|
534
|
+
const readline = await import("node:readline");
|
|
535
|
+
return new Promise((resolveSecret) => {
|
|
536
|
+
const rl = readline.createInterface({
|
|
537
|
+
input: process.stdin,
|
|
538
|
+
output: process.stderr,
|
|
539
|
+
terminal: true,
|
|
540
|
+
});
|
|
541
|
+
let muted = false;
|
|
542
|
+
const mutable = rl;
|
|
543
|
+
mutable._writeToOutput = (value) => {
|
|
544
|
+
if (!muted)
|
|
545
|
+
process.stderr.write(value);
|
|
546
|
+
};
|
|
547
|
+
rl.question(`${label}: `, (answer) => {
|
|
548
|
+
rl.close();
|
|
549
|
+
process.stderr.write("\n");
|
|
550
|
+
resolveSecret(answer.trim());
|
|
551
|
+
});
|
|
552
|
+
muted = true;
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
function rejectArgvSecret(args, ...flags) {
|
|
556
|
+
for (const flag of flags) {
|
|
557
|
+
if (args.includes(flag)) {
|
|
558
|
+
throw new Error(`${flag} no longer accepts a value on the command line (argv secrets leak via \`ps\` and ` +
|
|
559
|
+
`shell history). Pipe the secret on stdin instead, e.g. \`echo "$SECRET" | fullstackgtm login ...\`.`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
async function brokerLogin(baseUrl) {
|
|
564
|
+
const base = baseUrl.replace(/\/$/, "");
|
|
565
|
+
const os = await import("node:os");
|
|
566
|
+
// Self-reported, shown to the approver so they can recognize this request
|
|
567
|
+
// and refuse one they didn't initiate.
|
|
568
|
+
const requesterLabel = `${os.hostname()} (${process.platform}, ${os.userInfo().username})`;
|
|
569
|
+
const startResponse = await fetch(`${base}/api/cli/auth/start`, {
|
|
570
|
+
method: "POST",
|
|
571
|
+
headers: { "Content-Type": "application/json" },
|
|
572
|
+
body: JSON.stringify({ requesterLabel }),
|
|
573
|
+
});
|
|
574
|
+
if (!startResponse.ok) {
|
|
575
|
+
throw new Error(`Could not start pairing with ${base} (${startResponse.status}). Is this a FullStackGTM deployment?`);
|
|
576
|
+
}
|
|
577
|
+
const start = await startResponse.json();
|
|
578
|
+
console.error(`\nPairing code: ${start.userCode}\n\nApprove this CLI ("${requesterLabel}") in your dashboard:\n\n ${start.verificationUrl}\n`);
|
|
579
|
+
void openInBrowser(start.verificationUrl);
|
|
580
|
+
const deadline = Date.now() + (start.expiresInSeconds ?? 600) * 1000;
|
|
581
|
+
const intervalMs = Math.max(0, (start.intervalSeconds ?? 3) * 1000);
|
|
582
|
+
while (Date.now() < deadline) {
|
|
583
|
+
await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
|
|
584
|
+
const pollResponse = await fetch(`${base}/api/cli/auth/poll`, {
|
|
585
|
+
method: "POST",
|
|
586
|
+
headers: { "Content-Type": "application/json" },
|
|
587
|
+
body: JSON.stringify({ deviceCode: start.deviceCode }),
|
|
588
|
+
});
|
|
589
|
+
if (!pollResponse.ok) {
|
|
590
|
+
throw new Error(`Pairing poll failed (${pollResponse.status}).`);
|
|
591
|
+
}
|
|
592
|
+
const poll = await pollResponse.json();
|
|
593
|
+
if (poll.status === "pending")
|
|
594
|
+
continue;
|
|
595
|
+
if (poll.status === "approved" && poll.cliToken) {
|
|
596
|
+
const now = new Date().toISOString();
|
|
597
|
+
storeCredential("broker", {
|
|
598
|
+
kind: "broker",
|
|
599
|
+
accessToken: poll.cliToken,
|
|
600
|
+
baseUrl: base,
|
|
601
|
+
createdAt: now,
|
|
602
|
+
updatedAt: now,
|
|
603
|
+
});
|
|
604
|
+
console.log(`Paired with ${base}. Credentials stored in ${credentialsPath()}.`);
|
|
605
|
+
console.log("Provider commands now use the organization's stored sync credentials via the deployment.");
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
throw new Error(`Pairing was ${poll.status}.`);
|
|
609
|
+
}
|
|
610
|
+
throw new Error("Pairing timed out before it was approved.");
|
|
611
|
+
}
|
|
612
|
+
function isOptionValue(args, arg) {
|
|
613
|
+
const index = args.indexOf(arg);
|
|
614
|
+
return index > 0 && args[index - 1].startsWith("--");
|
|
615
|
+
}
|
|
616
|
+
async function salesforceLogin(args) {
|
|
617
|
+
const now = new Date().toISOString();
|
|
618
|
+
if (args.includes("--device")) {
|
|
619
|
+
const clientId = option(args, "--client-id");
|
|
620
|
+
if (!clientId) {
|
|
621
|
+
throw new Error("--device requires --client-id (the consumer key of a Connected App with device flow enabled).");
|
|
622
|
+
}
|
|
623
|
+
const loginUrl = option(args, "--login-url") ?? undefined;
|
|
624
|
+
const authorization = await startSalesforceDeviceLogin({ clientId, loginUrl });
|
|
625
|
+
console.error(`\nPairing code: ${authorization.userCode}\n\nConfirm this code at:\n\n ${authorization.verificationUri}\n`);
|
|
626
|
+
void openInBrowser(authorization.verificationUri);
|
|
627
|
+
const tokens = await pollSalesforceDeviceLogin({
|
|
628
|
+
clientId,
|
|
629
|
+
deviceCode: authorization.deviceCode,
|
|
630
|
+
intervalSeconds: authorization.intervalSeconds,
|
|
631
|
+
loginUrl,
|
|
632
|
+
});
|
|
633
|
+
storeCredential("salesforce", {
|
|
634
|
+
kind: "oauth",
|
|
635
|
+
accessToken: tokens.accessToken,
|
|
636
|
+
refreshToken: tokens.refreshToken,
|
|
637
|
+
instanceUrl: tokens.instanceUrl,
|
|
638
|
+
expiresAt: tokens.expiresAt,
|
|
639
|
+
clientId,
|
|
640
|
+
loginUrl,
|
|
641
|
+
createdAt: now,
|
|
642
|
+
updatedAt: now,
|
|
643
|
+
});
|
|
644
|
+
console.log(`Logged in to Salesforce (${tokens.instanceUrl}). Credentials stored in ${credentialsPath()}.`);
|
|
645
|
+
console.log("Tokens refresh silently; no further browser interaction is needed.");
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
rejectArgvSecret(args, "--token");
|
|
649
|
+
const instanceUrl = option(args, "--instance-url");
|
|
650
|
+
if (!instanceUrl) {
|
|
651
|
+
throw new Error("Salesforce login needs --device --client-id <consumer key>, or --instance-url " +
|
|
652
|
+
"<https://yourorg.my.salesforce.com> with the access token piped on stdin.");
|
|
653
|
+
}
|
|
654
|
+
const token = await readSecret("Salesforce access token");
|
|
655
|
+
if (!token)
|
|
656
|
+
throw new Error("No access token provided.");
|
|
657
|
+
if (!args.includes("--no-validate")) {
|
|
658
|
+
const result = await validateSalesforceToken(token, instanceUrl);
|
|
659
|
+
if (!result.ok)
|
|
660
|
+
throw new Error(result.detail);
|
|
661
|
+
console.log(result.detail);
|
|
662
|
+
}
|
|
663
|
+
storeCredential("salesforce", {
|
|
664
|
+
kind: "private_app",
|
|
665
|
+
accessToken: token,
|
|
666
|
+
instanceUrl,
|
|
667
|
+
createdAt: now,
|
|
668
|
+
updatedAt: now,
|
|
669
|
+
});
|
|
670
|
+
console.log(`Logged in to Salesforce. Credentials stored in ${credentialsPath()}.`);
|
|
671
|
+
}
|
|
672
|
+
async function login(args) {
|
|
673
|
+
const via = option(args, "--via");
|
|
674
|
+
if (via) {
|
|
675
|
+
await brokerLogin(via);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const provider = args.find((arg) => !arg.startsWith("--") && !isOptionValue(args, arg));
|
|
679
|
+
if (provider === "salesforce") {
|
|
680
|
+
await salesforceLogin(args);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (provider === "stripe") {
|
|
684
|
+
rejectArgvSecret(args, "--token");
|
|
685
|
+
const key = await readSecret("Stripe secret key (sk_...)");
|
|
686
|
+
if (!key)
|
|
687
|
+
throw new Error("No Stripe key provided.");
|
|
688
|
+
if (!args.includes("--no-validate")) {
|
|
689
|
+
const response = await fetch("https://api.stripe.com/v1/customers?limit=1", {
|
|
690
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
691
|
+
});
|
|
692
|
+
if (!response.ok) {
|
|
693
|
+
throw new Error(`Stripe rejected the key (${response.status}): ${safeStatus(response)}`);
|
|
694
|
+
}
|
|
695
|
+
console.log("Key accepted by the Stripe API.");
|
|
696
|
+
}
|
|
697
|
+
const stamp = new Date().toISOString();
|
|
698
|
+
storeCredential("stripe", {
|
|
699
|
+
kind: "private_app",
|
|
700
|
+
accessToken: key,
|
|
701
|
+
createdAt: stamp,
|
|
702
|
+
updatedAt: stamp,
|
|
703
|
+
});
|
|
704
|
+
console.log(`Logged in to Stripe. Credentials stored in ${credentialsPath()}.`);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
if (provider !== "hubspot") {
|
|
708
|
+
throw new Error("login supports: hubspot, salesforce, stripe, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com");
|
|
709
|
+
}
|
|
710
|
+
const now = new Date().toISOString();
|
|
711
|
+
if (args.includes("--oauth")) {
|
|
712
|
+
rejectArgvSecret(args, "--client-secret");
|
|
713
|
+
const clientId = option(args, "--client-id");
|
|
714
|
+
if (!clientId) {
|
|
715
|
+
throw new Error("--oauth requires --client-id from your own HubSpot app " +
|
|
716
|
+
`(register http://localhost:${numericOption(args, "--port") ?? DEFAULT_LOOPBACK_PORT}/callback as a redirect URL). ` +
|
|
717
|
+
"The client secret is read from stdin or an interactive prompt.");
|
|
718
|
+
}
|
|
719
|
+
const clientSecret = await readSecret("HubSpot app client secret");
|
|
720
|
+
if (!clientSecret)
|
|
721
|
+
throw new Error("No client secret provided.");
|
|
722
|
+
const scopes = option(args, "--scopes")?.split(",").map((scope) => scope.trim());
|
|
723
|
+
const tokens = await runHubspotLoopbackLogin({
|
|
724
|
+
clientId,
|
|
725
|
+
clientSecret,
|
|
726
|
+
port: numericOption(args, "--port"),
|
|
727
|
+
scopes,
|
|
728
|
+
});
|
|
729
|
+
storeCredential("hubspot", {
|
|
730
|
+
kind: "oauth",
|
|
731
|
+
accessToken: tokens.accessToken,
|
|
732
|
+
refreshToken: tokens.refreshToken,
|
|
733
|
+
expiresAt: tokens.expiresAt,
|
|
734
|
+
clientId,
|
|
735
|
+
clientSecret,
|
|
736
|
+
scopes,
|
|
737
|
+
createdAt: now,
|
|
738
|
+
updatedAt: now,
|
|
739
|
+
});
|
|
740
|
+
console.log(`Logged in to HubSpot via OAuth. Credentials stored in ${credentialsPath()}.`);
|
|
741
|
+
console.log("Tokens refresh silently; no further browser interaction is needed.");
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
rejectArgvSecret(args, "--token");
|
|
745
|
+
const token = await readSecret("HubSpot private app token");
|
|
746
|
+
if (!token)
|
|
747
|
+
throw new Error("No token provided.");
|
|
748
|
+
if (!args.includes("--no-validate")) {
|
|
749
|
+
const result = await validateHubspotToken(token);
|
|
750
|
+
if (!result.ok)
|
|
751
|
+
throw new Error(result.detail);
|
|
752
|
+
console.log(result.detail);
|
|
753
|
+
}
|
|
754
|
+
storeCredential("hubspot", {
|
|
755
|
+
kind: "private_app",
|
|
756
|
+
accessToken: token,
|
|
757
|
+
createdAt: now,
|
|
758
|
+
updatedAt: now,
|
|
759
|
+
});
|
|
760
|
+
console.log(`Logged in to HubSpot. Credentials stored in ${credentialsPath()}.`);
|
|
761
|
+
}
|
|
762
|
+
function logout(args) {
|
|
763
|
+
const provider = args.find((arg) => !arg.startsWith("--"));
|
|
764
|
+
if (!provider)
|
|
765
|
+
throw new Error("Usage: fullstackgtm logout hubspot");
|
|
766
|
+
if (!getCredential(provider)) {
|
|
767
|
+
console.log(`No stored credentials for ${provider}.`);
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
deleteCredential(provider);
|
|
771
|
+
console.log(`Removed stored ${provider} credentials.`);
|
|
772
|
+
}
|
|
773
|
+
export function doctorReport(env = process.env) {
|
|
774
|
+
const packageInfo = readPackageInfo();
|
|
775
|
+
const nodeMajor = Number(process.versions.node.split(".")[0]);
|
|
776
|
+
const storePath = credentialsPath();
|
|
777
|
+
const configPath = resolve("fullstackgtm.config.json");
|
|
778
|
+
const broker = getCredential("broker");
|
|
779
|
+
const providers = {
|
|
780
|
+
hubspot: env.HUBSPOT_ACCESS_TOKEN
|
|
781
|
+
? { source: "env", detail: "HUBSPOT_ACCESS_TOKEN" }
|
|
782
|
+
: providerStatus("hubspot", broker),
|
|
783
|
+
salesforce: env.SALESFORCE_ACCESS_TOKEN && env.SALESFORCE_INSTANCE_URL
|
|
784
|
+
? { source: "env", detail: "SALESFORCE_ACCESS_TOKEN + SALESFORCE_INSTANCE_URL" }
|
|
785
|
+
: providerStatus("salesforce", broker),
|
|
786
|
+
stripe: env.STRIPE_SECRET_KEY
|
|
787
|
+
? { source: "env", detail: "STRIPE_SECRET_KEY" }
|
|
788
|
+
: providerStatus("stripe", broker),
|
|
789
|
+
};
|
|
790
|
+
const missingPeers = ["@modelcontextprotocol/sdk", "zod"].filter((name) => {
|
|
791
|
+
try {
|
|
792
|
+
import.meta.resolve(name);
|
|
793
|
+
return false;
|
|
794
|
+
}
|
|
795
|
+
catch {
|
|
796
|
+
return true;
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
const connected = Object.entries(providers).filter(([, status]) => status.source !== "none");
|
|
800
|
+
const nextSteps = connected.length === 0
|
|
801
|
+
? [
|
|
802
|
+
"fullstackgtm audit --demo # no credentials needed",
|
|
803
|
+
"fullstackgtm login hubspot # connect your CRM (or: login --via <hosted url>)",
|
|
804
|
+
]
|
|
805
|
+
: [`fullstackgtm audit --provider ${connected[0][0]}`];
|
|
806
|
+
return {
|
|
807
|
+
package: packageInfo,
|
|
808
|
+
node: { version: process.versions.node, ok: nodeMajor >= 20, required: ">=20" },
|
|
809
|
+
credentialStore: { path: storePath, exists: existsSync(storePath) },
|
|
810
|
+
config: { path: configPath, exists: existsSync(configPath) },
|
|
811
|
+
providers,
|
|
812
|
+
broker: broker ? { paired: true, baseUrl: broker.baseUrl ?? "unknown" } : { paired: false },
|
|
813
|
+
mcp: { peersInstalled: missingPeers.length === 0, missing: missingPeers },
|
|
814
|
+
nextSteps,
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
function providerStatus(provider, broker) {
|
|
818
|
+
const stored = getCredential(provider);
|
|
819
|
+
if (stored) {
|
|
820
|
+
return { source: "stored", detail: `${stored.kind} login, updated ${stored.updatedAt}` };
|
|
821
|
+
}
|
|
822
|
+
if (broker) {
|
|
823
|
+
return { source: "broker", detail: `via ${broker.baseUrl ?? "hosted deployment"}` };
|
|
824
|
+
}
|
|
825
|
+
return { source: "none", detail: `fullstackgtm login ${provider}` };
|
|
826
|
+
}
|
|
827
|
+
function readPackageInfo() {
|
|
828
|
+
try {
|
|
829
|
+
const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8");
|
|
830
|
+
const parsed = JSON.parse(raw);
|
|
831
|
+
return { name: parsed.name ?? "fullstackgtm", version: parsed.version ?? "unknown" };
|
|
832
|
+
}
|
|
833
|
+
catch {
|
|
834
|
+
return { name: "fullstackgtm", version: "unknown" };
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
function doctorCommand(args) {
|
|
838
|
+
const report = doctorReport();
|
|
839
|
+
if (args.includes("--json")) {
|
|
840
|
+
console.log(JSON.stringify(report, null, 2));
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
const mark = (ok) => (ok ? "ok" : "MISSING");
|
|
844
|
+
const lines = [
|
|
845
|
+
`Package: ${report.package.name} ${report.package.version}`,
|
|
846
|
+
`Node: v${report.node.version} (${report.node.required} required) ${mark(report.node.ok)}`,
|
|
847
|
+
`Cred store: ${report.credentialStore.path} (${report.credentialStore.exists ? "present" : "not created yet — created on first login"})`,
|
|
848
|
+
`Config: ${report.config.exists ? report.config.path : "none — defaults apply"}`,
|
|
849
|
+
"",
|
|
850
|
+
"Providers:",
|
|
851
|
+
...Object.entries(report.providers).map(([provider, status]) => ` ${provider.padEnd(11)} ${status.source === "none" ? `not connected (${status.detail})` : `${status.source}: ${status.detail}`}`),
|
|
852
|
+
` ${"broker".padEnd(11)} ${report.broker.paired ? `paired with ${report.broker.baseUrl}` : "not paired (fullstackgtm login --via <hosted url>)"}`,
|
|
853
|
+
"",
|
|
854
|
+
report.mcp.peersInstalled
|
|
855
|
+
? "MCP: peers installed — `fullstackgtm-mcp` is ready"
|
|
856
|
+
: `MCP: optional peers missing (${report.mcp.missing.join(", ")}) — needed only for \`fullstackgtm-mcp\``,
|
|
857
|
+
"",
|
|
858
|
+
"Next step:",
|
|
859
|
+
...report.nextSteps.map((step) => ` ${step}`),
|
|
860
|
+
];
|
|
861
|
+
console.log(lines.join("\n"));
|
|
862
|
+
if (!report.node.ok)
|
|
863
|
+
process.exitCode = 1;
|
|
864
|
+
}
|
|
865
|
+
export async function runCli(argv) {
|
|
866
|
+
const [command, ...args] = argv;
|
|
867
|
+
if (!command || command === "--help" || command === "-h") {
|
|
868
|
+
console.log(usage());
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
if (command === "login") {
|
|
872
|
+
await login(args);
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
if (command === "logout") {
|
|
876
|
+
logout(args);
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
if (command === "snapshot") {
|
|
880
|
+
await snapshotCommand(args);
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
if (command === "audit") {
|
|
884
|
+
await audit(args);
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
if (command === "rules") {
|
|
888
|
+
await rulesCommand(args);
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
if (command === "doctor") {
|
|
892
|
+
doctorCommand(args);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (command === "diff") {
|
|
896
|
+
await diffCommand(args);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
if (command === "merge") {
|
|
900
|
+
await mergeCommand(args);
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
if (command === "plans") {
|
|
904
|
+
await plansCommand(args);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
if (command === "apply") {
|
|
908
|
+
await apply(args);
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
console.error(`Unknown command: ${command}`);
|
|
912
|
+
console.error("");
|
|
913
|
+
console.error(usage());
|
|
914
|
+
process.exitCode = 1;
|
|
915
|
+
}
|