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/mcp.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { z } from "zod/v4";
|
|
6
|
+
import { auditSnapshot, defaultPolicy } from "./audit.js";
|
|
7
|
+
import { loadConfig, mergePolicy, resolveConfiguredRules } from "./config.js";
|
|
8
|
+
import { applyPatchPlan } from "./connector.js";
|
|
9
|
+
import { createHubspotConnector } from "./connectors/hubspot.js";
|
|
10
|
+
import { createSalesforceConnector } from "./connectors/salesforce.js";
|
|
11
|
+
import { createStripeConnector } from "./connectors/stripe.js";
|
|
12
|
+
import { getCredential, resolveHubspotAccessToken, resolveSalesforceConnection, } from "./credentials.js";
|
|
13
|
+
import { generateDemoSnapshot } from "./demo.js";
|
|
14
|
+
import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
|
|
15
|
+
import { builtinAuditRules } from "./rules.js";
|
|
16
|
+
import { sampleSnapshot } from "./sampleData.js";
|
|
17
|
+
function content(value) {
|
|
18
|
+
return {
|
|
19
|
+
content: [
|
|
20
|
+
{
|
|
21
|
+
type: "text",
|
|
22
|
+
text: typeof value === "string" ? value : JSON.stringify(value, null, 2),
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
async function connectorFor(provider) {
|
|
28
|
+
if (provider === "hubspot") {
|
|
29
|
+
const token = process.env.HUBSPOT_ACCESS_TOKEN ?? (await resolveHubspotAccessToken());
|
|
30
|
+
if (!token) {
|
|
31
|
+
throw new Error("No HubSpot credentials. Run `fullstackgtm login hubspot` or set HUBSPOT_ACCESS_TOKEN in the MCP server environment.");
|
|
32
|
+
}
|
|
33
|
+
return createHubspotConnector({ getAccessToken: () => token });
|
|
34
|
+
}
|
|
35
|
+
if (provider === "salesforce") {
|
|
36
|
+
const connection = process.env.SALESFORCE_ACCESS_TOKEN && process.env.SALESFORCE_INSTANCE_URL
|
|
37
|
+
? {
|
|
38
|
+
accessToken: process.env.SALESFORCE_ACCESS_TOKEN,
|
|
39
|
+
instanceUrl: process.env.SALESFORCE_INSTANCE_URL,
|
|
40
|
+
}
|
|
41
|
+
: await resolveSalesforceConnection();
|
|
42
|
+
if (!connection) {
|
|
43
|
+
throw new Error("No Salesforce credentials. Run `fullstackgtm login salesforce` or set SALESFORCE_ACCESS_TOKEN and SALESFORCE_INSTANCE_URL in the MCP server environment.");
|
|
44
|
+
}
|
|
45
|
+
return createSalesforceConnector({
|
|
46
|
+
getConnection: () => connection,
|
|
47
|
+
fieldMappings: connection.fieldMappings ?? undefined,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (provider === "stripe") {
|
|
51
|
+
const key = process.env.STRIPE_SECRET_KEY ?? getCredential("stripe")?.accessToken;
|
|
52
|
+
if (!key) {
|
|
53
|
+
throw new Error("No Stripe credentials. Run `fullstackgtm login stripe` or set STRIPE_SECRET_KEY in the MCP server environment.");
|
|
54
|
+
}
|
|
55
|
+
return createStripeConnector({ getApiKey: () => key });
|
|
56
|
+
}
|
|
57
|
+
throw new Error(`Unknown provider: ${provider}. Supported providers: hubspot, salesforce, stripe`);
|
|
58
|
+
}
|
|
59
|
+
async function readSnapshot(provider, inputPath) {
|
|
60
|
+
if (provider === "demo")
|
|
61
|
+
return generateDemoSnapshot();
|
|
62
|
+
if (provider && provider !== "sample") {
|
|
63
|
+
const connector = await connectorFor(provider);
|
|
64
|
+
return connector.fetchSnapshot();
|
|
65
|
+
}
|
|
66
|
+
if (!inputPath)
|
|
67
|
+
return sampleSnapshot;
|
|
68
|
+
return JSON.parse(readFileSync(resolve(process.cwd(), inputPath), "utf8"));
|
|
69
|
+
}
|
|
70
|
+
function packageVersion() {
|
|
71
|
+
try {
|
|
72
|
+
const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8");
|
|
73
|
+
return JSON.parse(raw).version ?? "0.0.0";
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return "0.0.0";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export async function startMcpServer() {
|
|
80
|
+
const server = new McpServer({
|
|
81
|
+
name: "fullstackgtm",
|
|
82
|
+
version: packageVersion(),
|
|
83
|
+
});
|
|
84
|
+
server.registerTool("fullstackgtm_audit", {
|
|
85
|
+
title: "GTM Ops Audit",
|
|
86
|
+
description: "Run a dry-run GTM hygiene audit and return a reviewable patch plan. " +
|
|
87
|
+
"Reads from the sample dataset, a snapshot file, or a live provider.",
|
|
88
|
+
inputSchema: {
|
|
89
|
+
provider: z.enum(["sample", "demo", "hubspot", "salesforce", "stripe"]).optional(),
|
|
90
|
+
inputPath: z.string().optional(),
|
|
91
|
+
configPath: z.string().optional(),
|
|
92
|
+
rules: z.array(z.string()).optional(),
|
|
93
|
+
output: z.enum(["json", "markdown"]).optional(),
|
|
94
|
+
today: z.string().optional(),
|
|
95
|
+
staleDealDays: z.number().int().positive().optional(),
|
|
96
|
+
},
|
|
97
|
+
}, async ({ provider, inputPath, configPath, rules, output, today, staleDealDays }) => {
|
|
98
|
+
const loaded = configPath ? loadConfig(configPath) : null;
|
|
99
|
+
const policy = mergePolicy(defaultPolicy(today), loaded?.config);
|
|
100
|
+
if (today)
|
|
101
|
+
policy.today = today;
|
|
102
|
+
if (staleDealDays !== undefined)
|
|
103
|
+
policy.staleDealDays = staleDealDays;
|
|
104
|
+
const ruleSet = await resolveConfiguredRules(loaded);
|
|
105
|
+
const selected = rules?.length
|
|
106
|
+
? ruleSet.filter((rule) => rules.includes(rule.id))
|
|
107
|
+
: ruleSet;
|
|
108
|
+
const plan = auditSnapshot(await readSnapshot(provider, inputPath), policy, selected);
|
|
109
|
+
return content(output === "markdown" ? patchPlanToMarkdown(plan) : plan);
|
|
110
|
+
});
|
|
111
|
+
server.registerTool("fullstackgtm_rules", {
|
|
112
|
+
title: "List Audit Rules",
|
|
113
|
+
description: "List the built-in deterministic audit rules with ids and descriptions.",
|
|
114
|
+
inputSchema: {},
|
|
115
|
+
}, async () => {
|
|
116
|
+
return content(builtinAuditRules.map(({ id, title, description }) => ({ id, title, description })));
|
|
117
|
+
});
|
|
118
|
+
server.registerTool("fullstackgtm_apply", {
|
|
119
|
+
title: "Apply Approved Patch Operations",
|
|
120
|
+
description: "Apply explicitly approved operations from a patch plan through a provider " +
|
|
121
|
+
"connector. Operations not listed in approvedOperationIds are never written, " +
|
|
122
|
+
"and requires_human_* placeholders need a value override.",
|
|
123
|
+
inputSchema: {
|
|
124
|
+
provider: z.enum(["hubspot", "salesforce"]),
|
|
125
|
+
planPath: z.string(),
|
|
126
|
+
approvedOperationIds: z.array(z.string()).min(1),
|
|
127
|
+
valueOverrides: z.record(z.string(), z.string()).optional(),
|
|
128
|
+
output: z.enum(["json", "markdown"]).optional(),
|
|
129
|
+
},
|
|
130
|
+
}, async ({ provider, planPath, approvedOperationIds, valueOverrides, output }) => {
|
|
131
|
+
const plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8"));
|
|
132
|
+
const run = await applyPatchPlan(await connectorFor(provider), plan, {
|
|
133
|
+
approvedOperationIds,
|
|
134
|
+
valueOverrides,
|
|
135
|
+
});
|
|
136
|
+
return content(output === "markdown" ? formatPatchPlanRun(run) : run);
|
|
137
|
+
});
|
|
138
|
+
const transport = new StdioServerTransport();
|
|
139
|
+
await server.connect(transport);
|
|
140
|
+
}
|
package/dist/merge.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { CanonicalGtmSnapshot } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Entity resolution across systems. GTM data disagrees because the same
|
|
4
|
+
* real-world entity lives in several tools under different ids; merging
|
|
5
|
+
* collapses canonical records onto deterministic match keys (account domain,
|
|
6
|
+
* contact/user email) while every original system id survives as an identity
|
|
7
|
+
* claim. Deals and activities are never merged — they are provider-unique —
|
|
8
|
+
* but their references are re-pointed at the merged records.
|
|
9
|
+
*
|
|
10
|
+
* Merging never invents data: the first source wins for conflicting fields,
|
|
11
|
+
* and every disagreement is reported, not silently resolved.
|
|
12
|
+
*/
|
|
13
|
+
export type MergeMatch = {
|
|
14
|
+
type: "user" | "account" | "contact";
|
|
15
|
+
primaryId: string;
|
|
16
|
+
mergedIds: string[];
|
|
17
|
+
matchedBy: "email" | "domain" | "name";
|
|
18
|
+
};
|
|
19
|
+
export type MergeConflict = {
|
|
20
|
+
type: "user" | "account" | "contact";
|
|
21
|
+
recordId: string;
|
|
22
|
+
field: string;
|
|
23
|
+
values: Array<{
|
|
24
|
+
provider: string;
|
|
25
|
+
value: unknown;
|
|
26
|
+
}>;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* A same-name collision that was NOT auto-merged (different or absent
|
|
30
|
+
* domains). Surfaced for human review rather than silently combined —
|
|
31
|
+
* fabricating a merge between two real companies both named "Acme" would
|
|
32
|
+
* corrupt deals, contacts, and attribution.
|
|
33
|
+
*/
|
|
34
|
+
export type MergeSuggestion = {
|
|
35
|
+
type: "account";
|
|
36
|
+
name: string;
|
|
37
|
+
recordIds: string[];
|
|
38
|
+
};
|
|
39
|
+
export type MergeReport = {
|
|
40
|
+
sources: string[];
|
|
41
|
+
matches: MergeMatch[];
|
|
42
|
+
conflicts: MergeConflict[];
|
|
43
|
+
suggestions: MergeSuggestion[];
|
|
44
|
+
};
|
|
45
|
+
export declare function mergeSnapshots(snapshots: CanonicalGtmSnapshot[]): {
|
|
46
|
+
snapshot: CanonicalGtmSnapshot;
|
|
47
|
+
report: MergeReport;
|
|
48
|
+
};
|
package/dist/merge.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
const CONFLICT_IGNORED_FIELDS = new Set([
|
|
2
|
+
"id", "provider", "crmId", "identities", "raw", "lastSyncAt", "lastActivityAt", "ownerId", "accountId",
|
|
3
|
+
]);
|
|
4
|
+
function normalizeDomain(domain) {
|
|
5
|
+
if (!domain)
|
|
6
|
+
return undefined;
|
|
7
|
+
return domain.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/.*$/, "") || undefined;
|
|
8
|
+
}
|
|
9
|
+
function normalizeEmail(email) {
|
|
10
|
+
const normalized = email?.trim().toLowerCase();
|
|
11
|
+
return normalized || undefined;
|
|
12
|
+
}
|
|
13
|
+
function identityOf(record) {
|
|
14
|
+
return { provider: record.provider ?? "unknown", externalId: record.crmId ?? record.id };
|
|
15
|
+
}
|
|
16
|
+
export function mergeSnapshots(snapshots) {
|
|
17
|
+
if (snapshots.length === 0)
|
|
18
|
+
throw new Error("mergeSnapshots needs at least one snapshot.");
|
|
19
|
+
const sources = snapshots.map((snapshot) => snapshot.provider);
|
|
20
|
+
const matches = [];
|
|
21
|
+
const conflicts = [];
|
|
22
|
+
// Original id (per source snapshot) → merged id, used to re-point references.
|
|
23
|
+
const idRemap = new Map();
|
|
24
|
+
function namespaced(provider, id) {
|
|
25
|
+
return `${provider}:${id}`;
|
|
26
|
+
}
|
|
27
|
+
function mergeCollection(type, collections, keysOf) {
|
|
28
|
+
const merged = [];
|
|
29
|
+
const byKey = new Map();
|
|
30
|
+
for (const { provider, records } of collections) {
|
|
31
|
+
for (const record of records) {
|
|
32
|
+
const sourceId = namespaced(provider, record.id);
|
|
33
|
+
const keyed = keysOf(record);
|
|
34
|
+
const existing = keyed
|
|
35
|
+
.map((entry) => ({ ...entry, match: byKey.get(`${entry.matchedBy}:${entry.key}`) }))
|
|
36
|
+
.find((entry) => entry.match);
|
|
37
|
+
if (!existing?.match) {
|
|
38
|
+
const copy = {
|
|
39
|
+
...record,
|
|
40
|
+
id: sourceId,
|
|
41
|
+
provider: record.provider ?? provider,
|
|
42
|
+
identities: [identityOf({ ...record, provider: record.provider ?? provider })],
|
|
43
|
+
};
|
|
44
|
+
merged.push(copy);
|
|
45
|
+
idRemap.set(sourceId, sourceId);
|
|
46
|
+
for (const entry of keyed)
|
|
47
|
+
byKey.set(`${entry.matchedBy}:${entry.key}`, copy);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const primary = existing.match;
|
|
51
|
+
idRemap.set(sourceId, primary.id);
|
|
52
|
+
primary.identities = [
|
|
53
|
+
...(primary.identities ?? []),
|
|
54
|
+
identityOf({ ...record, provider: record.provider ?? provider }),
|
|
55
|
+
];
|
|
56
|
+
const match = matches.find((candidate) => candidate.type === type && candidate.primaryId === primary.id);
|
|
57
|
+
if (match)
|
|
58
|
+
match.mergedIds.push(sourceId);
|
|
59
|
+
else
|
|
60
|
+
matches.push({ type, primaryId: primary.id, mergedIds: [sourceId], matchedBy: existing.matchedBy });
|
|
61
|
+
// First source wins; fill gaps, report disagreements.
|
|
62
|
+
for (const [field, value] of Object.entries(record)) {
|
|
63
|
+
if (CONFLICT_IGNORED_FIELDS.has(field) || value === undefined)
|
|
64
|
+
continue;
|
|
65
|
+
const current = primary[field];
|
|
66
|
+
if (current === undefined) {
|
|
67
|
+
primary[field] = value;
|
|
68
|
+
}
|
|
69
|
+
else if (JSON.stringify(current) !== JSON.stringify(value)) {
|
|
70
|
+
conflicts.push({
|
|
71
|
+
type,
|
|
72
|
+
recordId: primary.id,
|
|
73
|
+
field,
|
|
74
|
+
values: [
|
|
75
|
+
{ provider: primary.provider ?? "unknown", value: current },
|
|
76
|
+
{ provider: record.provider ?? provider, value },
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return merged;
|
|
84
|
+
}
|
|
85
|
+
const users = mergeCollection("user", snapshots.map((snapshot) => ({ provider: snapshot.provider, records: snapshot.users })), (user) => {
|
|
86
|
+
const email = normalizeEmail(user.email);
|
|
87
|
+
return email ? [{ key: email, matchedBy: "email" }] : [];
|
|
88
|
+
});
|
|
89
|
+
const accounts = mergeCollection("account", snapshots.map((snapshot) => ({ provider: snapshot.provider, records: snapshot.accounts })),
|
|
90
|
+
// Auto-merge accounts ONLY on a shared normalized domain. Name is a weak,
|
|
91
|
+
// collision-prone key (every "Acme Inc"); name-only collisions become
|
|
92
|
+
// review suggestions below instead of silent merges.
|
|
93
|
+
(account) => {
|
|
94
|
+
const domain = normalizeDomain(account.domain);
|
|
95
|
+
return domain ? [{ key: domain, matchedBy: "domain" }] : [];
|
|
96
|
+
});
|
|
97
|
+
const suggestions = [];
|
|
98
|
+
const byName = new Map();
|
|
99
|
+
for (const account of accounts) {
|
|
100
|
+
const key = account.name.trim().toLowerCase();
|
|
101
|
+
if (!key)
|
|
102
|
+
continue;
|
|
103
|
+
byName.set(key, [...(byName.get(key) ?? []), account.id]);
|
|
104
|
+
}
|
|
105
|
+
for (const [, recordIds] of byName) {
|
|
106
|
+
if (recordIds.length > 1) {
|
|
107
|
+
const name = accounts.find((account) => account.id === recordIds[0]).name;
|
|
108
|
+
suggestions.push({ type: "account", name, recordIds });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const contacts = mergeCollection("contact", snapshots.map((snapshot) => ({ provider: snapshot.provider, records: snapshot.contacts })), (contact) => {
|
|
112
|
+
const email = normalizeEmail(contact.email);
|
|
113
|
+
return email ? [{ key: email, matchedBy: "email" }] : [];
|
|
114
|
+
});
|
|
115
|
+
const remapRef = (provider, id) => id === undefined ? undefined : idRemap.get(`${provider}:${id}`) ?? namespaced(provider, id);
|
|
116
|
+
const deals = snapshots.flatMap((snapshot) => snapshot.deals.map((deal) => ({
|
|
117
|
+
...deal,
|
|
118
|
+
id: namespaced(snapshot.provider, deal.id),
|
|
119
|
+
provider: deal.provider ?? snapshot.provider,
|
|
120
|
+
identities: [identityOf({ ...deal, provider: deal.provider ?? snapshot.provider })],
|
|
121
|
+
accountId: remapRef(snapshot.provider, deal.accountId),
|
|
122
|
+
ownerId: remapRef(snapshot.provider, deal.ownerId),
|
|
123
|
+
})));
|
|
124
|
+
const activities = snapshots.flatMap((snapshot) => snapshot.activities.map((activity) => ({
|
|
125
|
+
...activity,
|
|
126
|
+
id: namespaced(snapshot.provider, activity.id),
|
|
127
|
+
provider: activity.provider ?? snapshot.provider,
|
|
128
|
+
accountId: remapRef(snapshot.provider, activity.accountId),
|
|
129
|
+
contactId: remapRef(snapshot.provider, activity.contactId),
|
|
130
|
+
dealId: remapRef(snapshot.provider, activity.dealId),
|
|
131
|
+
ownerId: remapRef(snapshot.provider, activity.ownerId),
|
|
132
|
+
})));
|
|
133
|
+
return {
|
|
134
|
+
snapshot: {
|
|
135
|
+
generatedAt: snapshots.map((snapshot) => snapshot.generatedAt).sort().at(-1),
|
|
136
|
+
provider: "merged",
|
|
137
|
+
users,
|
|
138
|
+
accounts,
|
|
139
|
+
contacts,
|
|
140
|
+
deals,
|
|
141
|
+
activities,
|
|
142
|
+
},
|
|
143
|
+
report: { sources, matches, conflicts, suggestions },
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ApprovalStatus, PatchPlan, PatchPlanRun } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Durable patch-plan workflow. A plan is an immutable proposal; the store
|
|
4
|
+
* tracks the mutable lifecycle around it — which operations a human approved,
|
|
5
|
+
* the concrete values they supplied, and every apply run. The hosted app's
|
|
6
|
+
* Convex tables and this file store are two implementations of the same
|
|
7
|
+
* contract.
|
|
8
|
+
*/
|
|
9
|
+
export type StoredPlan = {
|
|
10
|
+
plan: PatchPlan;
|
|
11
|
+
status: ApprovalStatus;
|
|
12
|
+
approvedOperationIds: string[];
|
|
13
|
+
valueOverrides: Record<string, unknown>;
|
|
14
|
+
runs: PatchPlanRun[];
|
|
15
|
+
createdAt: string;
|
|
16
|
+
updatedAt: string;
|
|
17
|
+
};
|
|
18
|
+
export interface PlanStore {
|
|
19
|
+
save(plan: PatchPlan): Promise<StoredPlan>;
|
|
20
|
+
get(planId: string): Promise<StoredPlan | null>;
|
|
21
|
+
list(status?: ApprovalStatus): Promise<StoredPlan[]>;
|
|
22
|
+
approveOperations(planId: string, operationIds: string[], valueOverrides?: Record<string, unknown>): Promise<StoredPlan>;
|
|
23
|
+
reject(planId: string): Promise<StoredPlan>;
|
|
24
|
+
recordRun(planId: string, run: PatchPlanRun): Promise<StoredPlan>;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Plans as JSON files in a directory (default `$FSGTM_HOME/plans`), one file
|
|
28
|
+
* per plan id. Filesystem-shaped on purpose: greppable, diffable, and any
|
|
29
|
+
* file-based tooling composes with it.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createFilePlanStore(directory?: string): PlanStore;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.js";
|
|
4
|
+
/**
|
|
5
|
+
* Plans as JSON files in a directory (default `$FSGTM_HOME/plans`), one file
|
|
6
|
+
* per plan id. Filesystem-shaped on purpose: greppable, diffable, and any
|
|
7
|
+
* file-based tooling composes with it.
|
|
8
|
+
*/
|
|
9
|
+
export function createFilePlanStore(directory) {
|
|
10
|
+
const dir = directory ?? join(credentialsDir(), "plans");
|
|
11
|
+
function pathFor(planId) {
|
|
12
|
+
if (!/^[\w.-]+$/.test(planId))
|
|
13
|
+
throw new Error(`Invalid plan id: ${planId}`);
|
|
14
|
+
return join(dir, `${planId}.json`);
|
|
15
|
+
}
|
|
16
|
+
function read(planId) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(readFileSync(pathFor(planId), "utf8"));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function write(stored) {
|
|
25
|
+
// Plan files contain real CRM before/after values; keep them owner-only.
|
|
26
|
+
// When the store lives under the default home, lock that down too —
|
|
27
|
+
// otherwise an `audit --save` before any `login` would create the home
|
|
28
|
+
// directory world-readable.
|
|
29
|
+
if (!directory)
|
|
30
|
+
ensureSecureHomeDir();
|
|
31
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
32
|
+
try {
|
|
33
|
+
chmodSync(dir, 0o700);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Non-POSIX filesystems ignore chmod.
|
|
37
|
+
}
|
|
38
|
+
const next = { ...stored, updatedAt: new Date().toISOString() };
|
|
39
|
+
writeSecureFile(pathFor(stored.plan.id), `${JSON.stringify(next, null, 2)}\n`);
|
|
40
|
+
return next;
|
|
41
|
+
}
|
|
42
|
+
function mustRead(planId) {
|
|
43
|
+
const stored = read(planId);
|
|
44
|
+
if (!stored)
|
|
45
|
+
throw new Error(`No stored plan with id ${planId}.`);
|
|
46
|
+
return stored;
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
async save(plan) {
|
|
50
|
+
const now = new Date().toISOString();
|
|
51
|
+
return write({
|
|
52
|
+
plan,
|
|
53
|
+
status: plan.status,
|
|
54
|
+
approvedOperationIds: [],
|
|
55
|
+
valueOverrides: {},
|
|
56
|
+
runs: [],
|
|
57
|
+
createdAt: now,
|
|
58
|
+
updatedAt: now,
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
async get(planId) {
|
|
62
|
+
return read(planId);
|
|
63
|
+
},
|
|
64
|
+
async list(status) {
|
|
65
|
+
let entries = [];
|
|
66
|
+
try {
|
|
67
|
+
entries = readdirSync(dir);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
const plans = entries
|
|
73
|
+
.filter((entry) => entry.endsWith(".json"))
|
|
74
|
+
.map((entry) => read(entry.slice(0, -".json".length)))
|
|
75
|
+
.filter((stored) => stored !== null)
|
|
76
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
77
|
+
return status ? plans.filter((stored) => stored.status === status) : plans;
|
|
78
|
+
},
|
|
79
|
+
async approveOperations(planId, operationIds, valueOverrides = {}) {
|
|
80
|
+
const stored = mustRead(planId);
|
|
81
|
+
if (stored.status === "applied") {
|
|
82
|
+
throw new Error(`Plan ${planId} has already been applied.`);
|
|
83
|
+
}
|
|
84
|
+
if (stored.status === "rejected") {
|
|
85
|
+
throw new Error(`Plan ${planId} was rejected; re-run the audit for a fresh plan.`);
|
|
86
|
+
}
|
|
87
|
+
const known = new Set(stored.plan.operations.map((operation) => operation.id));
|
|
88
|
+
for (const operationId of operationIds) {
|
|
89
|
+
if (!known.has(operationId)) {
|
|
90
|
+
throw new Error(`Plan ${planId} has no operation ${operationId}.`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return write({
|
|
94
|
+
...stored,
|
|
95
|
+
status: "approved",
|
|
96
|
+
approvedOperationIds: Array.from(new Set([...stored.approvedOperationIds, ...operationIds])),
|
|
97
|
+
valueOverrides: { ...stored.valueOverrides, ...valueOverrides },
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
async reject(planId) {
|
|
101
|
+
const stored = mustRead(planId);
|
|
102
|
+
if (stored.status === "applied") {
|
|
103
|
+
throw new Error(`Plan ${planId} has already been applied.`);
|
|
104
|
+
}
|
|
105
|
+
return write({ ...stored, status: "rejected", approvedOperationIds: [] });
|
|
106
|
+
},
|
|
107
|
+
async recordRun(planId, run) {
|
|
108
|
+
const stored = mustRead(planId);
|
|
109
|
+
return write({
|
|
110
|
+
...stored,
|
|
111
|
+
status: run.status === "applied" || run.status === "partial" ? "applied" : stored.status,
|
|
112
|
+
runs: [...stored.runs, run],
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
package/dist/rules.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { CanonicalGtmSnapshot, GtmAuditRule, GtmSnapshotIndex } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Placeholder used as `afterValue` when the right value is a human decision
|
|
4
|
+
* (e.g. which owner to assign). Apply orchestration refuses to write these
|
|
5
|
+
* unless an explicit override value is supplied at approval time.
|
|
6
|
+
*/
|
|
7
|
+
export declare const REQUIRES_HUMAN_PREFIX = "requires_human_";
|
|
8
|
+
export declare function requiresHumanInput(value: unknown): boolean;
|
|
9
|
+
export declare function auditFindingId(ruleId: string, objectId: string): string;
|
|
10
|
+
export declare function patchOperationId(ruleId: string, objectId: string): string;
|
|
11
|
+
export declare function stableHash(value: string): string;
|
|
12
|
+
export declare function buildSnapshotIndex(snapshot: CanonicalGtmSnapshot): GtmSnapshotIndex;
|
|
13
|
+
export declare const orphanAccountRule: GtmAuditRule;
|
|
14
|
+
export declare const missingDealOwnerRule: GtmAuditRule;
|
|
15
|
+
export declare const missingDealAccountRule: GtmAuditRule;
|
|
16
|
+
export declare const pastCloseDateRule: GtmAuditRule;
|
|
17
|
+
export declare const staleDealRule: GtmAuditRule;
|
|
18
|
+
export declare const missingDealAmountRule: GtmAuditRule;
|
|
19
|
+
export declare const duplicateAccountDomainRule: GtmAuditRule;
|
|
20
|
+
export declare const duplicateContactEmailRule: GtmAuditRule;
|
|
21
|
+
export declare const activeDealAccountWithoutContactsRule: GtmAuditRule;
|
|
22
|
+
export declare const closingSoonInactiveRule: GtmAuditRule;
|
|
23
|
+
export declare const accountSingleSourceRule: GtmAuditRule;
|
|
24
|
+
export declare const builtinAuditRules: GtmAuditRule[];
|