fullstackgtm 0.10.1 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +90 -0
- package/INSTALL_FOR_AGENTS.md +14 -0
- package/README.md +33 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +244 -13
- package/dist/connectors/hubspot.js +101 -4
- package/dist/connectors/hubspotAuth.js +6 -2
- package/dist/connectors/salesforce.js +68 -2
- package/dist/connectors/salesforceAuth.js +17 -3
- package/dist/credentials.d.ts +19 -0
- package/dist/credentials.js +69 -8
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/mcp.js +16 -0
- package/dist/merge.d.ts +1 -0
- package/dist/merge.js +1 -1
- package/dist/report.d.ts +61 -0
- package/dist/report.js +331 -0
- package/dist/rules.d.ts +1 -0
- package/dist/rules.js +49 -1
- package/dist/suggest.d.ts +31 -0
- package/dist/suggest.js +148 -0
- package/docs/api.md +13 -1
- package/docs/crm-health-lifecycle.md +135 -0
- package/llms.txt +1 -0
- package/package.json +1 -1
- package/src/cli.ts +264 -11
- package/src/connectors/hubspot.ts +101 -4
- package/src/connectors/hubspotAuth.ts +6 -2
- package/src/connectors/salesforce.ts +70 -2
- package/src/connectors/salesforceAuth.ts +19 -6
- package/src/credentials.ts +71 -6
- package/src/index.ts +7 -0
- package/src/mcp.ts +22 -0
- package/src/merge.ts +1 -1
- package/src/report.ts +502 -0
- package/src/rules.ts +52 -1
- package/src/suggest.ts +202 -0
|
@@ -22,6 +22,10 @@ export function createHubspotConnector(options) {
|
|
|
22
22
|
const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
|
|
23
23
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
24
24
|
const mappings = options.fieldMappings;
|
|
25
|
+
// create:<Name> dedup within one connector lifetime (one apply run): the
|
|
26
|
+
// search API is eventually consistent, so a just-created company is
|
|
27
|
+
// invisible to search — this map is the authoritative same-run record.
|
|
28
|
+
const createdCompaniesByName = new Map();
|
|
25
29
|
async function request(path, init = {}) {
|
|
26
30
|
const token = await options.getAccessToken();
|
|
27
31
|
let response;
|
|
@@ -282,18 +286,77 @@ export function createHubspotConnector(options) {
|
|
|
282
286
|
detail: "link_record is supported for deals and contacts (to a company).",
|
|
283
287
|
};
|
|
284
288
|
}
|
|
285
|
-
|
|
289
|
+
let companyId = String(operation.afterValue ?? "");
|
|
286
290
|
if (!companyId) {
|
|
287
291
|
return { operationId: operation.id, status: "skipped", detail: "link_record needs a target company id." };
|
|
288
292
|
}
|
|
293
|
+
// `create:<Name>` is resolve-first: link to an existing company when one
|
|
294
|
+
// unambiguously matches, refuse on ambiguity, create only on a confirmed
|
|
295
|
+
// miss — and never create the same name twice within one apply run
|
|
296
|
+
// (HubSpot's search API is eventually consistent, so a just-created
|
|
297
|
+
// record is invisible to search for several seconds).
|
|
298
|
+
let createdCompanyName = null;
|
|
299
|
+
let resolvedExisting = false;
|
|
300
|
+
if (companyId.startsWith("create:")) {
|
|
301
|
+
const name = companyId.slice("create:".length).trim();
|
|
302
|
+
if (!name) {
|
|
303
|
+
return { operationId: operation.id, status: "skipped", detail: "create: needs a company name (create:<Name>)." };
|
|
304
|
+
}
|
|
305
|
+
const nameKey = name.toLowerCase();
|
|
306
|
+
const alreadyCreated = createdCompaniesByName.get(nameKey);
|
|
307
|
+
if (alreadyCreated) {
|
|
308
|
+
companyId = alreadyCreated;
|
|
309
|
+
resolvedExisting = true;
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
const matches = await searchCompaniesByName(name);
|
|
313
|
+
if (matches.length > 1) {
|
|
314
|
+
return {
|
|
315
|
+
operationId: operation.id,
|
|
316
|
+
status: "skipped",
|
|
317
|
+
detail: `create:${name} is ambiguous — ${matches.length} companies already named "${name}" (ids ${matches.join(", ")}). Link an explicit company id instead.`,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (matches.length === 1) {
|
|
321
|
+
companyId = matches[0];
|
|
322
|
+
resolvedExisting = true;
|
|
323
|
+
createdCompaniesByName.set(nameKey, companyId);
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
const created = await request(`/crm/v3/objects/companies`, {
|
|
327
|
+
method: "POST",
|
|
328
|
+
body: JSON.stringify({ properties: { name } }),
|
|
329
|
+
});
|
|
330
|
+
companyId = String(created.id);
|
|
331
|
+
createdCompanyName = name;
|
|
332
|
+
createdCompaniesByName.set(nameKey, companyId);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
289
336
|
await request(`/crm/v4/objects/${fromPath}/${encodeURIComponent(operation.objectId)}/associations/default/companies/${encodeURIComponent(companyId)}`, { method: "PUT" });
|
|
290
337
|
return {
|
|
291
338
|
operationId: operation.id,
|
|
292
339
|
status: "applied",
|
|
293
|
-
detail:
|
|
294
|
-
|
|
340
|
+
detail: createdCompanyName
|
|
341
|
+
? `Created company "${createdCompanyName}" (${companyId}) and linked ${fromPath}/${operation.objectId} to it.`
|
|
342
|
+
: resolvedExisting
|
|
343
|
+
? `Linked ${fromPath}/${operation.objectId} to existing company ${companyId} (resolved by name, nothing created).`
|
|
344
|
+
: `Linked ${fromPath}/${operation.objectId} to company ${companyId}.`,
|
|
345
|
+
providerData: { companyId, ...(createdCompanyName ? { createdCompany: true } : {}) },
|
|
295
346
|
};
|
|
296
347
|
}
|
|
348
|
+
/** Exact-name company lookup for resolve-first creates. Returns matching ids (max 3 fetched). */
|
|
349
|
+
async function searchCompaniesByName(name) {
|
|
350
|
+
const data = await request(`/crm/v3/objects/companies/search`, {
|
|
351
|
+
method: "POST",
|
|
352
|
+
body: JSON.stringify({
|
|
353
|
+
filterGroups: [{ filters: [{ propertyName: "name", operator: "EQ", value: name }] }],
|
|
354
|
+
properties: ["name"],
|
|
355
|
+
limit: 3,
|
|
356
|
+
}),
|
|
357
|
+
});
|
|
358
|
+
return (data?.results ?? []).map((row) => String(row.id));
|
|
359
|
+
}
|
|
297
360
|
async function createTask(operation) {
|
|
298
361
|
const associationTypeId = TASK_ASSOCIATION_TYPE_IDS[operation.objectType];
|
|
299
362
|
if (associationTypeId === undefined) {
|
|
@@ -304,7 +367,34 @@ export function createHubspotConnector(options) {
|
|
|
304
367
|
};
|
|
305
368
|
}
|
|
306
369
|
const subject = operation.field ? humanizeField(operation.field) : "Follow up";
|
|
307
|
-
|
|
370
|
+
// The operation id doubles as an idempotency token: it is stamped into
|
|
371
|
+
// the task body and pre-checked so a replayed plan does not create the
|
|
372
|
+
// same task twice. Fail-open — a search hiccup must not block the apply.
|
|
373
|
+
const token = `fsgtm ${operation.id.replace(/^op_/, "")}`;
|
|
374
|
+
try {
|
|
375
|
+
const existing = await request(`/crm/v3/objects/tasks/search`, {
|
|
376
|
+
method: "POST",
|
|
377
|
+
body: JSON.stringify({
|
|
378
|
+
filterGroups: [
|
|
379
|
+
{ filters: [{ propertyName: "hs_task_body", operator: "CONTAINS_TOKEN", value: token.split(" ")[1] }] },
|
|
380
|
+
],
|
|
381
|
+
limit: 1,
|
|
382
|
+
}),
|
|
383
|
+
});
|
|
384
|
+
const hit = (existing?.results ?? [])[0];
|
|
385
|
+
if (hit?.id) {
|
|
386
|
+
return {
|
|
387
|
+
operationId: operation.id,
|
|
388
|
+
status: "skipped",
|
|
389
|
+
detail: `Task for this operation already exists (task ${hit.id}); not creating a duplicate.`,
|
|
390
|
+
providerData: { id: hit.id, existing: true },
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
// fall through to create
|
|
396
|
+
}
|
|
397
|
+
const body = `${String(operation.afterValue ?? operation.reason ?? "")}\n\n[${token}]`;
|
|
308
398
|
const response = await request(`/crm/v3/objects/tasks`, {
|
|
309
399
|
method: "POST",
|
|
310
400
|
body: JSON.stringify({
|
|
@@ -387,6 +477,13 @@ export function createHubspotConnector(options) {
|
|
|
387
477
|
if (!objectPath || !mappingType) {
|
|
388
478
|
throw new Error(`Field reads are only supported for accounts, contacts, and deals.`);
|
|
389
479
|
}
|
|
480
|
+
// accountId is an association in HubSpot, not a property — without this
|
|
481
|
+
// branch the compare-and-set on link_record reads null and passes blind.
|
|
482
|
+
if (field === "accountId" && (objectType === "deal" || objectType === "contact")) {
|
|
483
|
+
const data = await request(`/crm/v4/objects/${objectPath}/${encodeURIComponent(objectId)}/associations/companies?limit=1`);
|
|
484
|
+
const first = (data?.results ?? [])[0];
|
|
485
|
+
return first?.toObjectId !== undefined ? String(first.toObjectId) : null;
|
|
486
|
+
}
|
|
390
487
|
const defaults = HUBSPOT_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
|
|
391
488
|
const property = mappedField(mappings, mappingType, field, defaults[field] ?? field);
|
|
392
489
|
const data = await request(`/crm/v3/objects/${objectPath}/${encodeURIComponent(objectId)}?properties=${encodeURIComponent(property)}`);
|
|
@@ -49,8 +49,12 @@ export async function validateHubspotToken(token, fetchImpl = fetch) {
|
|
|
49
49
|
if (response.ok) {
|
|
50
50
|
return { ok: true, detail: "Token accepted by the HubSpot CRM API." };
|
|
51
51
|
}
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
// Never echo the response body: provider error payloads can reflect request
|
|
53
|
+
// details and end up in logs or shell scrollback.
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
detail: `HubSpot rejected the token: HTTP ${response.status} ${response.statusText}`.trim(),
|
|
57
|
+
};
|
|
54
58
|
}
|
|
55
59
|
async function tokenRequest(params, fetchImpl) {
|
|
56
60
|
const response = await fetchImpl(HS_TOKEN_URL, {
|
|
@@ -23,6 +23,8 @@ export function createSalesforceConnector(options) {
|
|
|
23
23
|
const apiVersion = options.apiVersion ?? DEFAULT_API_VERSION;
|
|
24
24
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
25
25
|
const mappings = options.fieldMappings;
|
|
26
|
+
// create:<Name> dedup within one connector lifetime (one apply run).
|
|
27
|
+
const createdAccountsByName = new Map();
|
|
26
28
|
async function request(path, init = {}) {
|
|
27
29
|
const connection = await options.getConnection();
|
|
28
30
|
const url = path.startsWith("http")
|
|
@@ -228,11 +230,28 @@ export function createSalesforceConnector(options) {
|
|
|
228
230
|
detail: "Tasks can be attached to accounts, contacts, and deals.",
|
|
229
231
|
};
|
|
230
232
|
}
|
|
233
|
+
// Idempotency: the operation id is stamped into the Description and
|
|
234
|
+
// pre-checked, so replaying a plan does not duplicate tasks. Fail-open.
|
|
235
|
+
const token = `fsgtm:${operation.id}`;
|
|
236
|
+
try {
|
|
237
|
+
const existing = await query(`SELECT Id FROM Task WHERE Description LIKE '%${token.replace(/'/g, "\\'")}%' LIMIT 1`);
|
|
238
|
+
if (existing.length > 0) {
|
|
239
|
+
return {
|
|
240
|
+
operationId: operation.id,
|
|
241
|
+
status: "skipped",
|
|
242
|
+
detail: `Task for this operation already exists (task ${existing[0].Id}); not creating a duplicate.`,
|
|
243
|
+
providerData: { id: String(existing[0].Id), existing: true },
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// fall through to create
|
|
249
|
+
}
|
|
231
250
|
const response = await request(`/services/data/${apiVersion}/sobjects/Task`, {
|
|
232
251
|
method: "POST",
|
|
233
252
|
body: JSON.stringify({
|
|
234
253
|
Subject: operation.field ? humanizeField(operation.field) : "Follow up",
|
|
235
|
-
Description: String(operation.afterValue ?? operation.reason ?? "")
|
|
254
|
+
Description: `${String(operation.afterValue ?? operation.reason ?? "")}\n\n[${token}]`,
|
|
236
255
|
Status: "Not Started",
|
|
237
256
|
Priority: "Normal",
|
|
238
257
|
...reference,
|
|
@@ -268,8 +287,55 @@ export function createSalesforceConnector(options) {
|
|
|
268
287
|
case "clear_field":
|
|
269
288
|
// link_record on a deal is just setting AccountId in Salesforce.
|
|
270
289
|
return await setField(operation);
|
|
271
|
-
case "link_record":
|
|
290
|
+
case "link_record": {
|
|
291
|
+
// `create:<Name>` is resolve-first: link to an unambiguous existing
|
|
292
|
+
// Account, refuse on ambiguity, create only on a confirmed miss —
|
|
293
|
+
// and never create the same name twice within one apply run.
|
|
294
|
+
const value = String(operation.afterValue ?? "");
|
|
295
|
+
if (value.startsWith("create:")) {
|
|
296
|
+
const name = value.slice("create:".length).trim();
|
|
297
|
+
if (!name) {
|
|
298
|
+
return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
|
|
299
|
+
}
|
|
300
|
+
const nameKey = name.toLowerCase();
|
|
301
|
+
let accountId = createdAccountsByName.get(nameKey);
|
|
302
|
+
let createdNew = false;
|
|
303
|
+
if (!accountId) {
|
|
304
|
+
const soqlName = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
305
|
+
const matches = await query(`SELECT Id FROM Account WHERE Name = '${soqlName}' LIMIT 3`);
|
|
306
|
+
if (matches.length > 1) {
|
|
307
|
+
return {
|
|
308
|
+
operationId: operation.id,
|
|
309
|
+
status: "skipped",
|
|
310
|
+
detail: `create:${name} is ambiguous — ${matches.length} accounts already named "${name}". Link an explicit account id instead.`,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
if (matches.length === 1) {
|
|
314
|
+
accountId = String(matches[0].Id);
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
|
|
318
|
+
method: "POST",
|
|
319
|
+
body: JSON.stringify({ Name: name }),
|
|
320
|
+
});
|
|
321
|
+
accountId = String(created.id);
|
|
322
|
+
createdNew = true;
|
|
323
|
+
}
|
|
324
|
+
createdAccountsByName.set(nameKey, accountId);
|
|
325
|
+
}
|
|
326
|
+
const result = await setField({ ...operation, operation: "set_field", afterValue: accountId });
|
|
327
|
+
return result.status === "applied"
|
|
328
|
+
? {
|
|
329
|
+
...result,
|
|
330
|
+
detail: createdNew
|
|
331
|
+
? `Created account "${name}" (${accountId}) and linked ${operation.objectType}/${operation.objectId} to it.`
|
|
332
|
+
: `Linked ${operation.objectType}/${operation.objectId} to existing account ${accountId} (resolved by name, nothing created).`,
|
|
333
|
+
providerData: { accountId, ...(createdNew ? { createdAccount: true } : {}) },
|
|
334
|
+
}
|
|
335
|
+
: result;
|
|
336
|
+
}
|
|
272
337
|
return await setField({ ...operation, operation: "set_field" });
|
|
338
|
+
}
|
|
273
339
|
case "create_task":
|
|
274
340
|
return await createTask(operation);
|
|
275
341
|
case "archive_record":
|
|
@@ -111,10 +111,24 @@ export async function refreshSalesforceToken(options) {
|
|
|
111
111
|
};
|
|
112
112
|
}
|
|
113
113
|
export async function validateSalesforceToken(accessToken, instanceUrl, fetchImpl = fetch) {
|
|
114
|
-
|
|
114
|
+
let response;
|
|
115
|
+
try {
|
|
116
|
+
response = await fetchImpl(`${instanceUrl.replace(/\/$/, "")}/services/oauth2/userinfo`, { headers: { Authorization: `Bearer ${accessToken}` } });
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
detail: `Cannot reach Salesforce at ${instanceUrl}${cause}. Check the --instance-url (your My Domain URL, e.g. https://yourco.my.salesforce.com) and network access.`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
115
125
|
if (response.ok) {
|
|
116
126
|
return { ok: true, detail: "Token accepted by the Salesforce API." };
|
|
117
127
|
}
|
|
118
|
-
|
|
119
|
-
|
|
128
|
+
// Never echo the response body: provider error payloads can reflect request
|
|
129
|
+
// details and end up in logs or shell scrollback.
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
detail: `Salesforce rejected the token: HTTP ${response.status} ${response.statusText}`.trim(),
|
|
133
|
+
};
|
|
120
134
|
}
|
package/dist/credentials.d.ts
CHANGED
|
@@ -2,7 +2,26 @@
|
|
|
2
2
|
* Local CLI credential store: ~/.fullstackgtm/credentials.json (0600), or
|
|
3
3
|
* $FSGTM_HOME/credentials.json when set. Environment tokens always win over
|
|
4
4
|
* stored credentials so CI and agent sandboxes never touch the filesystem.
|
|
5
|
+
*
|
|
6
|
+
* Profiles let one operator hold credentials for several organizations at
|
|
7
|
+
* once (a consultant working across client CRMs). The default profile keeps
|
|
8
|
+
* the historical layout; a named profile scopes the entire home — credentials
|
|
9
|
+
* AND stored plans — under `profiles/<name>/`, so a patch plan proposed
|
|
10
|
+
* against one client's CRM can never be applied through another client's
|
|
11
|
+
* credentials.
|
|
5
12
|
*/
|
|
13
|
+
export declare const DEFAULT_PROFILE = "default";
|
|
14
|
+
export declare function validateProfileName(name: string): string;
|
|
15
|
+
/** Select the profile for this process; wins over $FULLSTACKGTM_PROFILE. */
|
|
16
|
+
export declare function setActiveProfile(name: string): void;
|
|
17
|
+
export declare function activeProfile(): string;
|
|
18
|
+
/** Base home directory, shared by every profile. */
|
|
19
|
+
export declare function baseHomeDir(): string;
|
|
20
|
+
/**
|
|
21
|
+
* Profiles that exist on disk (have a directory), always including the
|
|
22
|
+
* default profile. Existence does not imply stored credentials.
|
|
23
|
+
*/
|
|
24
|
+
export declare function listProfiles(): string[];
|
|
6
25
|
export type StoredCredential = {
|
|
7
26
|
kind: "private_app" | "oauth" | "broker";
|
|
8
27
|
accessToken: string;
|
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
|
@@ -45,6 +45,7 @@ import { generateDemoSnapshot } from "./demo.js";
|
|
|
45
45
|
import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
|
|
46
46
|
import { builtinAuditRules } from "./rules.js";
|
|
47
47
|
import { sampleSnapshot } from "./sampleData.js";
|
|
48
|
+
import { suggestValues } from "./suggest.js";
|
|
48
49
|
function content(value) {
|
|
49
50
|
return {
|
|
50
51
|
content: [
|
|
@@ -140,6 +141,21 @@ export async function startMcpServer() {
|
|
|
140
141
|
const plan = auditSnapshot(await readSnapshot(provider, inputPath), policy, selected);
|
|
141
142
|
return content(output === "markdown" ? patchPlanToMarkdown(plan) : plan);
|
|
142
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
|
+
});
|
|
143
159
|
server.registerTool("fullstackgtm_rules", {
|
|
144
160
|
title: "List Audit Rules",
|
|
145
161
|
description: "List the built-in deterministic audit rules with ids and descriptions.",
|
package/dist/merge.d.ts
CHANGED
|
@@ -42,6 +42,7 @@ export type MergeReport = {
|
|
|
42
42
|
conflicts: MergeConflict[];
|
|
43
43
|
suggestions: MergeSuggestion[];
|
|
44
44
|
};
|
|
45
|
+
export declare function normalizeDomain(domain?: string): string | undefined;
|
|
45
46
|
export declare function mergeSnapshots(snapshots: CanonicalGtmSnapshot[]): {
|
|
46
47
|
snapshot: CanonicalGtmSnapshot;
|
|
47
48
|
report: MergeReport;
|
package/dist/merge.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const CONFLICT_IGNORED_FIELDS = new Set([
|
|
2
2
|
"id", "provider", "crmId", "identities", "raw", "lastSyncAt", "lastActivityAt", "ownerId", "accountId",
|
|
3
3
|
]);
|
|
4
|
-
function normalizeDomain(domain) {
|
|
4
|
+
export function normalizeDomain(domain) {
|
|
5
5
|
if (!domain)
|
|
6
6
|
return undefined;
|
|
7
7
|
return domain.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/.*$/, "") || undefined;
|
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 {};
|