fullstackgtm 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +106 -0
- package/INSTALL_FOR_AGENTS.md +28 -2
- package/README.md +74 -6
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +251 -16
- package/dist/connectors/hubspot.js +36 -11
- package/dist/connectors/hubspotAuth.js +10 -2
- package/dist/connectors/salesforce.js +34 -9
- package/dist/connectors/salesforceAuth.js +17 -3
- package/dist/credentials.d.ts +19 -0
- package/dist/credentials.js +69 -8
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/mcp.js +53 -5
- package/dist/report.d.ts +61 -0
- package/dist/report.js +331 -0
- package/dist/rules.d.ts +1 -0
- package/dist/rules.js +47 -0
- package/dist/suggest.d.ts +31 -0
- package/dist/suggest.js +148 -0
- package/docs/api.md +13 -1
- package/docs/roadmap-to-1.0.md +31 -3
- package/package.json +1 -1
- package/src/cli.ts +271 -14
- package/src/connectors/hubspot.ts +35 -11
- package/src/connectors/hubspotAuth.ts +9 -2
- package/src/connectors/salesforce.ts +35 -9
- package/src/connectors/salesforceAuth.ts +19 -6
- package/src/credentials.ts +71 -6
- package/src/index.ts +7 -0
- package/src/mcp.ts +55 -5
- package/src/report.ts +502 -0
- package/src/rules.ts +50 -0
- package/src/suggest.ts +202 -0
package/dist/credentials.js
CHANGED
|
@@ -1,11 +1,66 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { refreshHubspotToken } from "./connectors/hubspotAuth.js";
|
|
5
5
|
import { refreshSalesforceToken } from "./connectors/salesforceAuth.js";
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Local CLI credential store: ~/.fullstackgtm/credentials.json (0600), or
|
|
8
|
+
* $FSGTM_HOME/credentials.json when set. Environment tokens always win over
|
|
9
|
+
* stored credentials so CI and agent sandboxes never touch the filesystem.
|
|
10
|
+
*
|
|
11
|
+
* Profiles let one operator hold credentials for several organizations at
|
|
12
|
+
* once (a consultant working across client CRMs). The default profile keeps
|
|
13
|
+
* the historical layout; a named profile scopes the entire home — credentials
|
|
14
|
+
* AND stored plans — under `profiles/<name>/`, so a patch plan proposed
|
|
15
|
+
* against one client's CRM can never be applied through another client's
|
|
16
|
+
* credentials.
|
|
17
|
+
*/
|
|
18
|
+
export const DEFAULT_PROFILE = "default";
|
|
19
|
+
const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
20
|
+
let explicitProfile = null;
|
|
21
|
+
export function validateProfileName(name) {
|
|
22
|
+
if (!PROFILE_NAME_PATTERN.test(name) || name === "." || name === "..") {
|
|
23
|
+
throw new Error(`Invalid profile name: ${JSON.stringify(name)}. Use letters, numbers, dots, dashes, ` +
|
|
24
|
+
"or underscores (must start with a letter or number, max 64 characters).");
|
|
25
|
+
}
|
|
26
|
+
return name;
|
|
27
|
+
}
|
|
28
|
+
/** Select the profile for this process; wins over $FULLSTACKGTM_PROFILE. */
|
|
29
|
+
export function setActiveProfile(name) {
|
|
30
|
+
explicitProfile = validateProfileName(name);
|
|
31
|
+
}
|
|
32
|
+
export function activeProfile() {
|
|
33
|
+
if (explicitProfile)
|
|
34
|
+
return explicitProfile;
|
|
35
|
+
const fromEnv = process.env.FULLSTACKGTM_PROFILE;
|
|
36
|
+
return fromEnv ? validateProfileName(fromEnv) : DEFAULT_PROFILE;
|
|
37
|
+
}
|
|
38
|
+
/** Base home directory, shared by every profile. */
|
|
39
|
+
export function baseHomeDir() {
|
|
7
40
|
return process.env.FSGTM_HOME ?? join(homedir(), ".fullstackgtm");
|
|
8
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Profiles that exist on disk (have a directory), always including the
|
|
44
|
+
* default profile. Existence does not imply stored credentials.
|
|
45
|
+
*/
|
|
46
|
+
export function listProfiles() {
|
|
47
|
+
const names = new Set([DEFAULT_PROFILE]);
|
|
48
|
+
try {
|
|
49
|
+
for (const entry of readdirSync(join(baseHomeDir(), "profiles"), { withFileTypes: true })) {
|
|
50
|
+
if (entry.isDirectory() && PROFILE_NAME_PATTERN.test(entry.name))
|
|
51
|
+
names.add(entry.name);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// No profiles directory yet.
|
|
56
|
+
}
|
|
57
|
+
return Array.from(names).sort();
|
|
58
|
+
}
|
|
59
|
+
export function credentialsDir() {
|
|
60
|
+
const base = baseHomeDir();
|
|
61
|
+
const profile = activeProfile();
|
|
62
|
+
return profile === DEFAULT_PROFILE ? base : join(base, "profiles", profile);
|
|
63
|
+
}
|
|
9
64
|
export function credentialsPath() {
|
|
10
65
|
return join(credentialsDir(), "credentials.json");
|
|
11
66
|
}
|
|
@@ -18,12 +73,18 @@ export function credentialsPath() {
|
|
|
18
73
|
*/
|
|
19
74
|
export function ensureSecureHomeDir() {
|
|
20
75
|
const dir = credentialsDir();
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
76
|
+
// A named profile nests under base/profiles/<name>; lock down every level
|
|
77
|
+
// we create, not just the leaf — recursive mkdir applies `mode` (less
|
|
78
|
+
// umask) only to directories it creates, and never to pre-existing ones.
|
|
79
|
+
const levels = dir === baseHomeDir() ? [dir] : [baseHomeDir(), join(baseHomeDir(), "profiles"), dir];
|
|
80
|
+
for (const level of levels) {
|
|
81
|
+
mkdirSync(level, { recursive: true, mode: 0o700 });
|
|
82
|
+
try {
|
|
83
|
+
chmodSync(level, 0o700);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Non-POSIX filesystems (e.g. Windows) ignore chmod; nothing to enforce.
|
|
87
|
+
}
|
|
27
88
|
}
|
|
28
89
|
return dir;
|
|
29
90
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,13 +6,15 @@ export { DEFAULT_LOOPBACK_PORT, DEFAULT_OAUTH_SCOPES, exchangeHubspotCode, refre
|
|
|
6
6
|
export { createSalesforceConnector, type SalesforceConnection, type SalesforceConnectorOptions, } from "./connectors/salesforce.ts";
|
|
7
7
|
export { pollSalesforceDeviceLogin, refreshSalesforceToken, startSalesforceDeviceLogin, validateSalesforceToken, type SalesforceDeviceAuthorization, type SalesforceTokenSet, } from "./connectors/salesforceAuth.ts";
|
|
8
8
|
export { createStripeConnector, type StripeConnectorOptions } from "./connectors/stripe.ts";
|
|
9
|
-
export { credentialsDir, credentialsPath, deleteCredential, getCredential, resolveHubspotAccessToken, resolveHubspotConnection, storeCredential, type HubspotConnection, type StoredCredential, } from "./credentials.ts";
|
|
9
|
+
export { activeProfile, credentialsDir, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, listProfiles, resolveHubspotAccessToken, resolveHubspotConnection, setActiveProfile, storeCredential, type HubspotConnection, type StoredCredential, } from "./credentials.ts";
|
|
10
10
|
export { generateDemoSnapshot, type DemoSnapshotOptions } from "./demo.ts";
|
|
11
11
|
export { diffFindings, diffSnapshots, diffToMarkdown, type CollectionDiff, type FieldChange, type FindingsDrift, type RecordChange, type SnapshotDiff, } from "./diff.ts";
|
|
12
12
|
export { mergeSnapshots, type MergeConflict, type MergeMatch, type MergeReport, type MergeSuggestion, } from "./merge.ts";
|
|
13
13
|
export { createFilePlanStore, type PlanStore, type StoredPlan } from "./planStore.ts";
|
|
14
14
|
export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
15
|
+
export { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
|
|
15
16
|
export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, type CrmObjectType, type FieldMappings, } from "./mappings.ts";
|
|
16
|
-
export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, requiresHumanInput, staleDealRule, } from "./rules.ts";
|
|
17
|
+
export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, requiresHumanInput, staleDealRule, } from "./rules.ts";
|
|
17
18
|
export { sampleSnapshot } from "./sampleData.ts";
|
|
19
|
+
export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
|
|
18
20
|
export type { ApprovalStatus, AuditFinding, AuditFindingSeverity, CanonicalAccount, CanonicalActivity, CanonicalContact, CanonicalDeal, CanonicalGtmSnapshot, CanonicalUser, CrmProvider, GtmAuditRule, GtmConnector, GtmEvidence, GtmEvidenceSourceSystem, GtmObjectType, GtmPolicy, GtmRuleContext, GtmRuleResult, GtmSnapshotIndex, PatchOperation, PatchOperationResult, PatchOperationType, PatchPlan, PatchPlanRun, PatchPlanRunStatus, PatchVerification, PipelineFinding, PipelineFindingStatus, PipelineFindingType, ProviderIdentity, RiskLevel, SourceFreshness, } from "./types.ts";
|
package/dist/index.js
CHANGED
|
@@ -6,12 +6,14 @@ export { DEFAULT_LOOPBACK_PORT, DEFAULT_OAUTH_SCOPES, exchangeHubspotCode, refre
|
|
|
6
6
|
export { createSalesforceConnector, } from "./connectors/salesforce.js";
|
|
7
7
|
export { pollSalesforceDeviceLogin, refreshSalesforceToken, startSalesforceDeviceLogin, validateSalesforceToken, } from "./connectors/salesforceAuth.js";
|
|
8
8
|
export { createStripeConnector } from "./connectors/stripe.js";
|
|
9
|
-
export { credentialsDir, credentialsPath, deleteCredential, getCredential, resolveHubspotAccessToken, resolveHubspotConnection, storeCredential, } from "./credentials.js";
|
|
9
|
+
export { activeProfile, credentialsDir, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, listProfiles, resolveHubspotAccessToken, resolveHubspotConnection, setActiveProfile, storeCredential, } from "./credentials.js";
|
|
10
10
|
export { generateDemoSnapshot } from "./demo.js";
|
|
11
11
|
export { diffFindings, diffSnapshots, diffToMarkdown, } from "./diff.js";
|
|
12
12
|
export { mergeSnapshots, } from "./merge.js";
|
|
13
13
|
export { createFilePlanStore } from "./planStore.js";
|
|
14
14
|
export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
|
|
15
|
+
export { auditReportToHtml, auditReportToMarkdown } from "./report.js";
|
|
15
16
|
export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, } from "./mappings.js";
|
|
16
|
-
export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, requiresHumanInput, staleDealRule, } from "./rules.js";
|
|
17
|
+
export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, requiresHumanInput, staleDealRule, } from "./rules.js";
|
|
17
18
|
export { sampleSnapshot } from "./sampleData.js";
|
|
19
|
+
export { suggestValues } from "./suggest.js";
|
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";
|
|
@@ -14,6 +45,7 @@ import { generateDemoSnapshot } from "./demo.js";
|
|
|
14
45
|
import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
|
|
15
46
|
import { builtinAuditRules } from "./rules.js";
|
|
16
47
|
import { sampleSnapshot } from "./sampleData.js";
|
|
48
|
+
import { suggestValues } from "./suggest.js";
|
|
17
49
|
function content(value) {
|
|
18
50
|
return {
|
|
19
51
|
content: [
|
|
@@ -84,7 +116,8 @@ export async function startMcpServer() {
|
|
|
84
116
|
server.registerTool("fullstackgtm_audit", {
|
|
85
117
|
title: "GTM Ops Audit",
|
|
86
118
|
description: "Run a dry-run GTM hygiene audit and return a reviewable patch plan. " +
|
|
87
|
-
"
|
|
119
|
+
"Sources: the realistic zero-credential demo CRM (provider: \"demo\" — richest test data), " +
|
|
120
|
+
"the minimal sample dataset, a snapshot file, or a live provider.",
|
|
88
121
|
inputSchema: {
|
|
89
122
|
provider: z.enum(["sample", "demo", "hubspot", "salesforce", "stripe"]).optional(),
|
|
90
123
|
inputPath: z.string().optional(),
|
|
@@ -108,6 +141,21 @@ export async function startMcpServer() {
|
|
|
108
141
|
const plan = auditSnapshot(await readSnapshot(provider, inputPath), policy, selected);
|
|
109
142
|
return content(output === "markdown" ? patchPlanToMarkdown(plan) : plan);
|
|
110
143
|
});
|
|
144
|
+
server.registerTool("fullstackgtm_suggest", {
|
|
145
|
+
title: "Suggest Placeholder Values",
|
|
146
|
+
description: "Derive values for a plan's requires_human_* placeholder operations from snapshot " +
|
|
147
|
+
"evidence (account-name matching, contact associations), with confidence levels and " +
|
|
148
|
+
"reasons. Read-only; feed accepted values into fullstackgtm_apply's valueOverrides.",
|
|
149
|
+
inputSchema: {
|
|
150
|
+
planPath: z.string(),
|
|
151
|
+
provider: z.enum(["sample", "demo", "hubspot", "salesforce", "stripe"]).optional(),
|
|
152
|
+
inputPath: z.string().optional(),
|
|
153
|
+
},
|
|
154
|
+
}, async ({ planPath, provider, inputPath }) => {
|
|
155
|
+
const plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8"));
|
|
156
|
+
const snapshot = await readSnapshot(provider, inputPath);
|
|
157
|
+
return content({ suggestions: suggestValues(plan, snapshot) });
|
|
158
|
+
});
|
|
111
159
|
server.registerTool("fullstackgtm_rules", {
|
|
112
160
|
title: "List Audit Rules",
|
|
113
161
|
description: "List the built-in deterministic audit rules with ids and descriptions.",
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { AuditFinding, AuditFindingSeverity, CanonicalGtmSnapshot, GtmAuditRule, PatchPlan } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Client-ready rendering of an audit patch plan: the same findings the CLI
|
|
4
|
+
* prints for operators, reshaped for the person who owns the CRM — counts up
|
|
5
|
+
* front, prose summary, per-rule detail with capped examples, and next steps.
|
|
6
|
+
* Deterministic: identical plan + options produce identical output, so a
|
|
7
|
+
* report can be regenerated and diffed across engagements.
|
|
8
|
+
*/
|
|
9
|
+
export type ReportOptions = {
|
|
10
|
+
/** Report heading (default "GTM Data Health Report"). */
|
|
11
|
+
title?: string;
|
|
12
|
+
/** Organization the report is about; shown in the heading and summary. */
|
|
13
|
+
clientName?: string;
|
|
14
|
+
/** Attribution line in the footer (e.g. a consultancy or team name). */
|
|
15
|
+
preparedBy?: string;
|
|
16
|
+
/** Report date (YYYY-MM-DD); defaults to the plan's creation date (UTC). */
|
|
17
|
+
date?: string;
|
|
18
|
+
/** Example records listed per rule before truncating (default 10). */
|
|
19
|
+
maxExamplesPerRule?: number;
|
|
20
|
+
/** Rule metadata used for section titles, descriptions, and categories. */
|
|
21
|
+
rules?: GtmAuditRule[];
|
|
22
|
+
/** Snapshot the plan was generated from; enables record counts and rates. */
|
|
23
|
+
snapshot?: CanonicalGtmSnapshot;
|
|
24
|
+
};
|
|
25
|
+
type RuleSection = {
|
|
26
|
+
ruleId: string;
|
|
27
|
+
title: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
category: string;
|
|
30
|
+
severity: AuditFindingSeverity;
|
|
31
|
+
findings: AuditFinding[];
|
|
32
|
+
};
|
|
33
|
+
type ReportModel = {
|
|
34
|
+
title: string;
|
|
35
|
+
clientName?: string;
|
|
36
|
+
preparedBy?: string;
|
|
37
|
+
date: string;
|
|
38
|
+
provider?: string;
|
|
39
|
+
recordCounts?: {
|
|
40
|
+
label: string;
|
|
41
|
+
count: number;
|
|
42
|
+
}[];
|
|
43
|
+
totalRecords?: number;
|
|
44
|
+
severityCounts: Record<AuditFindingSeverity, number>;
|
|
45
|
+
affectedRecords: number;
|
|
46
|
+
sections: RuleSection[];
|
|
47
|
+
maxExamplesPerRule: number;
|
|
48
|
+
operationCount: number;
|
|
49
|
+
humanInputOperationCount: number;
|
|
50
|
+
recordLabels: Map<string, string>;
|
|
51
|
+
summaryText: string;
|
|
52
|
+
};
|
|
53
|
+
export declare function buildReportModel(plan: PatchPlan, options?: ReportOptions): ReportModel;
|
|
54
|
+
export declare function auditReportToMarkdown(plan: PatchPlan, options?: ReportOptions): string;
|
|
55
|
+
/**
|
|
56
|
+
* Self-contained HTML (inline styles, no external assets) so the file can be
|
|
57
|
+
* emailed or dropped into a shared drive and render anywhere, including
|
|
58
|
+
* print-to-PDF.
|
|
59
|
+
*/
|
|
60
|
+
export declare function auditReportToHtml(plan: PatchPlan, options?: ReportOptions): string;
|
|
61
|
+
export {};
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { REQUIRES_HUMAN_PREFIX } from "./rules.js";
|
|
2
|
+
const SEVERITY_ORDER = ["critical", "warning", "info"];
|
|
3
|
+
const SEVERITY_RANK = {
|
|
4
|
+
critical: 2,
|
|
5
|
+
warning: 1,
|
|
6
|
+
info: 0,
|
|
7
|
+
};
|
|
8
|
+
export function buildReportModel(plan, options = {}) {
|
|
9
|
+
const ruleMeta = new Map((options.rules ?? []).map((rule) => [rule.id, rule]));
|
|
10
|
+
const byRule = new Map();
|
|
11
|
+
for (const finding of plan.findings) {
|
|
12
|
+
const findings = byRule.get(finding.ruleId) ?? [];
|
|
13
|
+
findings.push(finding);
|
|
14
|
+
byRule.set(finding.ruleId, findings);
|
|
15
|
+
}
|
|
16
|
+
const sections = Array.from(byRule.entries())
|
|
17
|
+
.map(([ruleId, findings]) => {
|
|
18
|
+
const meta = ruleMeta.get(ruleId);
|
|
19
|
+
const severity = findings.reduce((worst, finding) => SEVERITY_RANK[finding.severity] > SEVERITY_RANK[worst] ? finding.severity : worst, "info");
|
|
20
|
+
return {
|
|
21
|
+
ruleId,
|
|
22
|
+
title: meta?.title ?? ruleId,
|
|
23
|
+
description: meta?.description,
|
|
24
|
+
category: meta?.category ?? "uncategorized",
|
|
25
|
+
severity,
|
|
26
|
+
findings,
|
|
27
|
+
};
|
|
28
|
+
})
|
|
29
|
+
.sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] ||
|
|
30
|
+
b.findings.length - a.findings.length ||
|
|
31
|
+
a.ruleId.localeCompare(b.ruleId));
|
|
32
|
+
const severityCounts = {
|
|
33
|
+
critical: 0,
|
|
34
|
+
warning: 0,
|
|
35
|
+
info: 0,
|
|
36
|
+
};
|
|
37
|
+
const affected = new Set();
|
|
38
|
+
for (const finding of plan.findings) {
|
|
39
|
+
severityCounts[finding.severity] += 1;
|
|
40
|
+
affected.add(`${finding.objectType}:${finding.objectId}`);
|
|
41
|
+
}
|
|
42
|
+
const snapshot = options.snapshot;
|
|
43
|
+
const recordCounts = snapshot
|
|
44
|
+
? [
|
|
45
|
+
{ label: "Accounts", count: snapshot.accounts.length },
|
|
46
|
+
{ label: "Contacts", count: snapshot.contacts.length },
|
|
47
|
+
{ label: "Deals", count: snapshot.deals.length },
|
|
48
|
+
{ label: "Users", count: snapshot.users.length },
|
|
49
|
+
{ label: "Activities", count: snapshot.activities.length },
|
|
50
|
+
]
|
|
51
|
+
: undefined;
|
|
52
|
+
const totalRecords = recordCounts?.reduce((sum, entry) => sum + entry.count, 0);
|
|
53
|
+
const recordLabels = new Map();
|
|
54
|
+
if (snapshot) {
|
|
55
|
+
for (const row of snapshot.accounts)
|
|
56
|
+
recordLabels.set(`account:${row.id}`, row.name);
|
|
57
|
+
for (const row of snapshot.users)
|
|
58
|
+
recordLabels.set(`user:${row.id}`, row.name);
|
|
59
|
+
for (const row of snapshot.deals)
|
|
60
|
+
recordLabels.set(`deal:${row.id}`, row.name);
|
|
61
|
+
for (const row of snapshot.contacts) {
|
|
62
|
+
const name = [row.firstName, row.lastName].filter(Boolean).join(" ") || row.email;
|
|
63
|
+
if (name)
|
|
64
|
+
recordLabels.set(`contact:${row.id}`, name);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const humanInputOperationCount = plan.operations.filter((operation) => typeof operation.afterValue === "string" &&
|
|
68
|
+
operation.afterValue.startsWith(REQUIRES_HUMAN_PREFIX)).length;
|
|
69
|
+
const model = {
|
|
70
|
+
title: options.title ?? "GTM Data Health Report",
|
|
71
|
+
clientName: options.clientName,
|
|
72
|
+
preparedBy: options.preparedBy,
|
|
73
|
+
date: options.date ?? plan.createdAt.slice(0, 10),
|
|
74
|
+
provider: snapshot?.provider,
|
|
75
|
+
recordCounts,
|
|
76
|
+
totalRecords,
|
|
77
|
+
severityCounts,
|
|
78
|
+
affectedRecords: affected.size,
|
|
79
|
+
sections,
|
|
80
|
+
maxExamplesPerRule: Math.max(1, options.maxExamplesPerRule ?? 10),
|
|
81
|
+
operationCount: plan.operations.length,
|
|
82
|
+
humanInputOperationCount,
|
|
83
|
+
recordLabels,
|
|
84
|
+
summaryText: "",
|
|
85
|
+
};
|
|
86
|
+
model.summaryText = summaryText(model);
|
|
87
|
+
return model;
|
|
88
|
+
}
|
|
89
|
+
function summaryText(model) {
|
|
90
|
+
const subject = model.clientName ? `${model.clientName}'s CRM data` : "the CRM data";
|
|
91
|
+
if (model.sections.length === 0) {
|
|
92
|
+
return `This audit reviewed ${subject} and found no issues with the rules that ran. The checks are deterministic and can be re-run at any time to confirm the data stays healthy.`;
|
|
93
|
+
}
|
|
94
|
+
const total = model.sections.reduce((sum, section) => sum + section.findings.length, 0);
|
|
95
|
+
const severityParts = SEVERITY_ORDER.filter((severity) => model.severityCounts[severity] > 0)
|
|
96
|
+
.map((severity) => `${model.severityCounts[severity]} ${severity}`)
|
|
97
|
+
.join(", ");
|
|
98
|
+
const scope = model.totalRecords !== undefined
|
|
99
|
+
? `${model.affectedRecords} of ${model.totalRecords} records (${percent(model.affectedRecords, model.totalRecords)}%)`
|
|
100
|
+
: `${model.affectedRecords} records`;
|
|
101
|
+
const top = model.sections[0];
|
|
102
|
+
const fixes = model.operationCount > 0
|
|
103
|
+
? ` ${model.operationCount} of the issues have a proposed fix ready for review; nothing is changed in the CRM until each fix is explicitly approved.`
|
|
104
|
+
: "";
|
|
105
|
+
return (`This audit reviewed ${subject} and surfaced ${total} findings (${severityParts}) across ${scope}. ` +
|
|
106
|
+
`The largest issue is "${top.title}" (${top.findings.length} records).${fixes}`);
|
|
107
|
+
}
|
|
108
|
+
function percent(part, whole) {
|
|
109
|
+
if (whole === 0)
|
|
110
|
+
return 0;
|
|
111
|
+
return Math.round((part / whole) * 100);
|
|
112
|
+
}
|
|
113
|
+
function findingLabel(model, finding) {
|
|
114
|
+
const name = model.recordLabels.get(`${finding.objectType}:${finding.objectId}`);
|
|
115
|
+
return name ? `${name} (${finding.objectType})` : `${finding.objectType}/${finding.objectId}`;
|
|
116
|
+
}
|
|
117
|
+
export function auditReportToMarkdown(plan, options = {}) {
|
|
118
|
+
const model = buildReportModel(plan, options);
|
|
119
|
+
const lines = [];
|
|
120
|
+
lines.push(`# ${model.title}${model.clientName ? ` — ${model.clientName}` : ""}`, "");
|
|
121
|
+
const subtitle = [
|
|
122
|
+
`Prepared ${model.date}`,
|
|
123
|
+
model.provider ? `Source: ${model.provider}` : null,
|
|
124
|
+
model.preparedBy ? `By: ${model.preparedBy}` : null,
|
|
125
|
+
].filter(Boolean);
|
|
126
|
+
lines.push(subtitle.join(" · "), "");
|
|
127
|
+
lines.push("## At a Glance", "", "| Metric | Value |", "| --- | --- |");
|
|
128
|
+
if (model.recordCounts && model.totalRecords !== undefined) {
|
|
129
|
+
lines.push(`| Records audited | ${model.totalRecords} (${model.recordCounts
|
|
130
|
+
.map((entry) => `${entry.count} ${entry.label.toLowerCase()}`)
|
|
131
|
+
.join(", ")}) |`);
|
|
132
|
+
}
|
|
133
|
+
const totalFindings = SEVERITY_ORDER.reduce((sum, severity) => sum + model.severityCounts[severity], 0);
|
|
134
|
+
lines.push(`| Findings | ${totalFindings} |`);
|
|
135
|
+
for (const severity of SEVERITY_ORDER) {
|
|
136
|
+
lines.push(`| ${capitalize(severity)} | ${model.severityCounts[severity]} |`);
|
|
137
|
+
}
|
|
138
|
+
lines.push(`| Records affected | ${model.affectedRecords}${model.totalRecords !== undefined && model.totalRecords > 0
|
|
139
|
+
? ` (${percent(model.affectedRecords, model.totalRecords)}%)`
|
|
140
|
+
: ""} |`, `| Proposed fixes | ${model.operationCount}${model.humanInputOperationCount > 0
|
|
141
|
+
? ` (${model.humanInputOperationCount} need a human-chosen value)`
|
|
142
|
+
: ""} |`, "");
|
|
143
|
+
lines.push("## Summary", "", model.summaryText, "");
|
|
144
|
+
if (model.sections.length > 0) {
|
|
145
|
+
lines.push("## Findings by Rule", "", "| Rule | Category | Severity | Records |", "| --- | --- | --- | --- |");
|
|
146
|
+
for (const section of model.sections) {
|
|
147
|
+
lines.push(`| ${section.title} | ${section.category} | ${section.severity} | ${section.findings.length} |`);
|
|
148
|
+
}
|
|
149
|
+
lines.push("", "## Details", "");
|
|
150
|
+
for (const section of model.sections) {
|
|
151
|
+
lines.push(`### ${section.title} (${section.findings.length} ${section.findings.length === 1 ? "record" : "records"}, ${section.severity})`, "");
|
|
152
|
+
if (section.description)
|
|
153
|
+
lines.push(section.description, "");
|
|
154
|
+
const shown = section.findings.slice(0, model.maxExamplesPerRule);
|
|
155
|
+
for (const finding of shown) {
|
|
156
|
+
lines.push(`- **${findingLabel(model, finding)}** — ${finding.summary}`);
|
|
157
|
+
lines.push(` - Recommendation: ${finding.recommendation}`);
|
|
158
|
+
}
|
|
159
|
+
const hidden = section.findings.length - shown.length;
|
|
160
|
+
if (hidden > 0)
|
|
161
|
+
lines.push(`- … and ${hidden} more.`);
|
|
162
|
+
lines.push("");
|
|
163
|
+
}
|
|
164
|
+
lines.push("## Recommended Next Steps", "");
|
|
165
|
+
if (model.operationCount > 0) {
|
|
166
|
+
lines.push(`1. Review the ${model.operationCount} proposed fixes (\`fullstackgtm audit --save\`, then \`fullstackgtm plans show <id>\`).`, `2. Approve the operations to apply${model.humanInputOperationCount > 0
|
|
167
|
+
? `, supplying values for the ${model.humanInputOperationCount} that need a human decision`
|
|
168
|
+
: ""} (\`fullstackgtm plans approve\`). Nothing is written without explicit approval.`, "3. Re-run the audit after applying to confirm the findings clear, and schedule it recurring to catch regressions.");
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
lines.push("1. Address the findings above in the CRM (no automated fixes are available for them yet).", "2. Re-run the audit to confirm the findings clear, and schedule it recurring to catch regressions.");
|
|
172
|
+
}
|
|
173
|
+
lines.push("");
|
|
174
|
+
}
|
|
175
|
+
lines.push("---", "", `Generated by the fullstackgtm CLI on ${model.date}.` +
|
|
176
|
+
(model.preparedBy ? ` Prepared by ${model.preparedBy}.` : "") +
|
|
177
|
+
" Findings are deterministic and re-runnable; no CRM data was modified to produce this report.");
|
|
178
|
+
return `${lines.join("\n")}\n`;
|
|
179
|
+
}
|
|
180
|
+
const SEVERITY_COLORS = {
|
|
181
|
+
critical: { fg: "#991b1b", bg: "#fee2e2" },
|
|
182
|
+
warning: { fg: "#92400e", bg: "#fef3c7" },
|
|
183
|
+
info: { fg: "#1e40af", bg: "#dbeafe" },
|
|
184
|
+
};
|
|
185
|
+
function escapeHtml(value) {
|
|
186
|
+
return value
|
|
187
|
+
.replaceAll("&", "&")
|
|
188
|
+
.replaceAll("<", "<")
|
|
189
|
+
.replaceAll(">", ">")
|
|
190
|
+
.replaceAll('"', """);
|
|
191
|
+
}
|
|
192
|
+
function severityBadge(severity) {
|
|
193
|
+
const colors = SEVERITY_COLORS[severity];
|
|
194
|
+
return `<span class="badge" style="color:${colors.fg};background:${colors.bg}">${severity}</span>`;
|
|
195
|
+
}
|
|
196
|
+
function capitalize(value) {
|
|
197
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Self-contained HTML (inline styles, no external assets) so the file can be
|
|
201
|
+
* emailed or dropped into a shared drive and render anywhere, including
|
|
202
|
+
* print-to-PDF.
|
|
203
|
+
*/
|
|
204
|
+
export function auditReportToHtml(plan, options = {}) {
|
|
205
|
+
const model = buildReportModel(plan, options);
|
|
206
|
+
const totalFindings = SEVERITY_ORDER.reduce((sum, severity) => sum + model.severityCounts[severity], 0);
|
|
207
|
+
const glanceRows = [];
|
|
208
|
+
if (model.recordCounts && model.totalRecords !== undefined) {
|
|
209
|
+
glanceRows.push(row("Records audited", `${model.totalRecords} (${model.recordCounts
|
|
210
|
+
.map((entry) => `${entry.count} ${entry.label.toLowerCase()}`)
|
|
211
|
+
.join(", ")})`));
|
|
212
|
+
}
|
|
213
|
+
glanceRows.push(row("Findings", String(totalFindings)));
|
|
214
|
+
for (const severity of SEVERITY_ORDER) {
|
|
215
|
+
glanceRows.push(`<tr><th>${capitalize(severity)}</th><td>${model.severityCounts[severity]} ${severityBadge(severity)}</td></tr>`);
|
|
216
|
+
}
|
|
217
|
+
glanceRows.push(row("Records affected", `${model.affectedRecords}${model.totalRecords !== undefined && model.totalRecords > 0
|
|
218
|
+
? ` (${percent(model.affectedRecords, model.totalRecords)}%)`
|
|
219
|
+
: ""}`), row("Proposed fixes", `${model.operationCount}${model.humanInputOperationCount > 0
|
|
220
|
+
? ` (${model.humanInputOperationCount} need a human-chosen value)`
|
|
221
|
+
: ""}`));
|
|
222
|
+
const ruleRows = model.sections
|
|
223
|
+
.map((section) => `<tr><td>${escapeHtml(section.title)}</td><td>${escapeHtml(section.category)}</td>` +
|
|
224
|
+
`<td>${severityBadge(section.severity)}</td><td class="num">${section.findings.length}</td></tr>`)
|
|
225
|
+
.join("\n");
|
|
226
|
+
const detailSections = model.sections
|
|
227
|
+
.map((section) => {
|
|
228
|
+
const shown = section.findings.slice(0, model.maxExamplesPerRule);
|
|
229
|
+
const hidden = section.findings.length - shown.length;
|
|
230
|
+
const items = shown
|
|
231
|
+
.map((finding) => `<li><strong>${escapeHtml(findingLabel(model, finding))}</strong> — ${escapeHtml(finding.summary)}` +
|
|
232
|
+
`<div class="rec">Recommendation: ${escapeHtml(finding.recommendation)}</div></li>`)
|
|
233
|
+
.join("\n");
|
|
234
|
+
return [
|
|
235
|
+
`<section>`,
|
|
236
|
+
`<h3>${escapeHtml(section.title)} <span class="count">${section.findings.length} ${section.findings.length === 1 ? "record" : "records"}</span> ${severityBadge(section.severity)}</h3>`,
|
|
237
|
+
section.description ? `<p>${escapeHtml(section.description)}</p>` : "",
|
|
238
|
+
`<ul>`,
|
|
239
|
+
items,
|
|
240
|
+
hidden > 0 ? `<li class="more">… and ${hidden} more.</li>` : "",
|
|
241
|
+
`</ul>`,
|
|
242
|
+
`</section>`,
|
|
243
|
+
]
|
|
244
|
+
.filter(Boolean)
|
|
245
|
+
.join("\n");
|
|
246
|
+
})
|
|
247
|
+
.join("\n");
|
|
248
|
+
const nextSteps = model.sections.length === 0
|
|
249
|
+
? ""
|
|
250
|
+
: model.operationCount > 0
|
|
251
|
+
? `<ol>
|
|
252
|
+
<li>Review the ${model.operationCount} proposed fixes (<code>fullstackgtm audit --save</code>, then <code>fullstackgtm plans show <id></code>).</li>
|
|
253
|
+
<li>Approve the operations to apply${model.humanInputOperationCount > 0
|
|
254
|
+
? `, supplying values for the ${model.humanInputOperationCount} that need a human decision`
|
|
255
|
+
: ""} (<code>fullstackgtm plans approve</code>). Nothing is written without explicit approval.</li>
|
|
256
|
+
<li>Re-run the audit after applying to confirm the findings clear, and schedule it recurring to catch regressions.</li>
|
|
257
|
+
</ol>`
|
|
258
|
+
: `<ol>
|
|
259
|
+
<li>Address the findings above in the CRM (no automated fixes are available for them yet).</li>
|
|
260
|
+
<li>Re-run the audit to confirm the findings clear, and schedule it recurring to catch regressions.</li>
|
|
261
|
+
</ol>`;
|
|
262
|
+
const subtitle = [
|
|
263
|
+
`Prepared ${model.date}`,
|
|
264
|
+
model.provider ? `Source: ${escapeHtml(model.provider)}` : null,
|
|
265
|
+
model.preparedBy ? `By: ${escapeHtml(model.preparedBy)}` : null,
|
|
266
|
+
]
|
|
267
|
+
.filter(Boolean)
|
|
268
|
+
.join(" · ");
|
|
269
|
+
return `<!doctype html>
|
|
270
|
+
<html lang="en">
|
|
271
|
+
<head>
|
|
272
|
+
<meta charset="utf-8">
|
|
273
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
274
|
+
<title>${escapeHtml(model.title)}${model.clientName ? ` — ${escapeHtml(model.clientName)}` : ""}</title>
|
|
275
|
+
<style>
|
|
276
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
277
|
+
color: #1f2937; max-width: 860px; margin: 0 auto; padding: 2rem 1.5rem; line-height: 1.55; }
|
|
278
|
+
h1 { font-size: 1.6rem; margin-bottom: 0.25rem; }
|
|
279
|
+
h2 { font-size: 1.2rem; margin-top: 2rem; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.3rem; }
|
|
280
|
+
h3 { font-size: 1.05rem; margin-top: 1.5rem; }
|
|
281
|
+
.subtitle { color: #6b7280; margin-top: 0; }
|
|
282
|
+
table { border-collapse: collapse; width: 100%; margin: 0.75rem 0; }
|
|
283
|
+
th, td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid #e5e7eb; font-size: 0.95rem; }
|
|
284
|
+
th { color: #374151; font-weight: 600; }
|
|
285
|
+
td.num { text-align: right; }
|
|
286
|
+
.badge { display: inline-block; padding: 0.05rem 0.5rem; border-radius: 999px;
|
|
287
|
+
font-size: 0.78rem; font-weight: 600; vertical-align: middle; }
|
|
288
|
+
.count { color: #6b7280; font-weight: 400; font-size: 0.9rem; }
|
|
289
|
+
ul { padding-left: 1.2rem; }
|
|
290
|
+
li { margin-bottom: 0.5rem; }
|
|
291
|
+
.rec { color: #4b5563; font-size: 0.92rem; }
|
|
292
|
+
.more { color: #6b7280; list-style: none; }
|
|
293
|
+
code { background: #f3f4f6; padding: 0.1rem 0.3rem; border-radius: 4px; font-size: 0.88rem; }
|
|
294
|
+
footer { margin-top: 2.5rem; padding-top: 1rem; border-top: 1px solid #e5e7eb;
|
|
295
|
+
color: #6b7280; font-size: 0.85rem; }
|
|
296
|
+
@media print { body { padding: 0; } }
|
|
297
|
+
</style>
|
|
298
|
+
</head>
|
|
299
|
+
<body>
|
|
300
|
+
<h1>${escapeHtml(model.title)}${model.clientName ? ` — ${escapeHtml(model.clientName)}` : ""}</h1>
|
|
301
|
+
<p class="subtitle">${subtitle}</p>
|
|
302
|
+
|
|
303
|
+
<h2>At a Glance</h2>
|
|
304
|
+
<table>
|
|
305
|
+
${glanceRows.join("\n")}
|
|
306
|
+
</table>
|
|
307
|
+
|
|
308
|
+
<h2>Summary</h2>
|
|
309
|
+
<p>${escapeHtml(model.summaryText)}</p>
|
|
310
|
+
${model.sections.length > 0
|
|
311
|
+
? `
|
|
312
|
+
<h2>Findings by Rule</h2>
|
|
313
|
+
<table>
|
|
314
|
+
<tr><th>Rule</th><th>Category</th><th>Severity</th><th class="num">Records</th></tr>
|
|
315
|
+
${ruleRows}
|
|
316
|
+
</table>
|
|
317
|
+
|
|
318
|
+
<h2>Details</h2>
|
|
319
|
+
${detailSections}
|
|
320
|
+
|
|
321
|
+
<h2>Recommended Next Steps</h2>
|
|
322
|
+
${nextSteps}`
|
|
323
|
+
: ""}
|
|
324
|
+
<footer>Generated by the fullstackgtm CLI on ${model.date}.${model.preparedBy ? ` Prepared by ${escapeHtml(model.preparedBy)}.` : ""} Findings are deterministic and re-runnable; no CRM data was modified to produce this report.</footer>
|
|
325
|
+
</body>
|
|
326
|
+
</html>
|
|
327
|
+
`;
|
|
328
|
+
}
|
|
329
|
+
function row(label, value) {
|
|
330
|
+
return `<tr><th>${escapeHtml(label)}</th><td>${escapeHtml(value)}</td></tr>`;
|
|
331
|
+
}
|
package/dist/rules.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export declare const staleDealRule: GtmAuditRule;
|
|
|
18
18
|
export declare const missingDealAmountRule: GtmAuditRule;
|
|
19
19
|
export declare const duplicateAccountDomainRule: GtmAuditRule;
|
|
20
20
|
export declare const duplicateContactEmailRule: GtmAuditRule;
|
|
21
|
+
export declare const duplicateOpenDealRule: GtmAuditRule;
|
|
21
22
|
export declare const activeDealAccountWithoutContactsRule: GtmAuditRule;
|
|
22
23
|
export declare const closingSoonInactiveRule: GtmAuditRule;
|
|
23
24
|
export declare const accountSingleSourceRule: GtmAuditRule;
|