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
|
@@ -54,6 +54,10 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
54
54
|
const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
|
|
55
55
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
56
56
|
const mappings = options.fieldMappings;
|
|
57
|
+
// create:<Name> dedup within one connector lifetime (one apply run): the
|
|
58
|
+
// search API is eventually consistent, so a just-created company is
|
|
59
|
+
// invisible to search — this map is the authoritative same-run record.
|
|
60
|
+
const createdCompaniesByName = new Map<string, string>();
|
|
57
61
|
|
|
58
62
|
async function request(path: string, init: RequestInit = {}): Promise<any> {
|
|
59
63
|
const token = await options.getAccessToken();
|
|
@@ -380,10 +384,51 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
380
384
|
detail: "link_record is supported for deals and contacts (to a company).",
|
|
381
385
|
};
|
|
382
386
|
}
|
|
383
|
-
|
|
387
|
+
let companyId = String(operation.afterValue ?? "");
|
|
384
388
|
if (!companyId) {
|
|
385
389
|
return { operationId: operation.id, status: "skipped", detail: "link_record needs a target company id." };
|
|
386
390
|
}
|
|
391
|
+
// `create:<Name>` is resolve-first: link to an existing company when one
|
|
392
|
+
// unambiguously matches, refuse on ambiguity, create only on a confirmed
|
|
393
|
+
// miss — and never create the same name twice within one apply run
|
|
394
|
+
// (HubSpot's search API is eventually consistent, so a just-created
|
|
395
|
+
// record is invisible to search for several seconds).
|
|
396
|
+
let createdCompanyName: string | null = null;
|
|
397
|
+
let resolvedExisting = false;
|
|
398
|
+
if (companyId.startsWith("create:")) {
|
|
399
|
+
const name = companyId.slice("create:".length).trim();
|
|
400
|
+
if (!name) {
|
|
401
|
+
return { operationId: operation.id, status: "skipped", detail: "create: needs a company name (create:<Name>)." };
|
|
402
|
+
}
|
|
403
|
+
const nameKey = name.toLowerCase();
|
|
404
|
+
const alreadyCreated = createdCompaniesByName.get(nameKey);
|
|
405
|
+
if (alreadyCreated) {
|
|
406
|
+
companyId = alreadyCreated;
|
|
407
|
+
resolvedExisting = true;
|
|
408
|
+
} else {
|
|
409
|
+
const matches = await searchCompaniesByName(name);
|
|
410
|
+
if (matches.length > 1) {
|
|
411
|
+
return {
|
|
412
|
+
operationId: operation.id,
|
|
413
|
+
status: "skipped",
|
|
414
|
+
detail: `create:${name} is ambiguous — ${matches.length} companies already named "${name}" (ids ${matches.join(", ")}). Link an explicit company id instead.`,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
if (matches.length === 1) {
|
|
418
|
+
companyId = matches[0];
|
|
419
|
+
resolvedExisting = true;
|
|
420
|
+
createdCompaniesByName.set(nameKey, companyId);
|
|
421
|
+
} else {
|
|
422
|
+
const created = await request(`/crm/v3/objects/companies`, {
|
|
423
|
+
method: "POST",
|
|
424
|
+
body: JSON.stringify({ properties: { name } }),
|
|
425
|
+
});
|
|
426
|
+
companyId = String(created.id);
|
|
427
|
+
createdCompanyName = name;
|
|
428
|
+
createdCompaniesByName.set(nameKey, companyId);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
387
432
|
await request(
|
|
388
433
|
`/crm/v4/objects/${fromPath}/${encodeURIComponent(operation.objectId)}/associations/default/companies/${encodeURIComponent(companyId)}`,
|
|
389
434
|
{ method: "PUT" },
|
|
@@ -391,11 +436,28 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
391
436
|
return {
|
|
392
437
|
operationId: operation.id,
|
|
393
438
|
status: "applied",
|
|
394
|
-
detail:
|
|
395
|
-
|
|
439
|
+
detail: createdCompanyName
|
|
440
|
+
? `Created company "${createdCompanyName}" (${companyId}) and linked ${fromPath}/${operation.objectId} to it.`
|
|
441
|
+
: resolvedExisting
|
|
442
|
+
? `Linked ${fromPath}/${operation.objectId} to existing company ${companyId} (resolved by name, nothing created).`
|
|
443
|
+
: `Linked ${fromPath}/${operation.objectId} to company ${companyId}.`,
|
|
444
|
+
providerData: { companyId, ...(createdCompanyName ? { createdCompany: true } : {}) },
|
|
396
445
|
};
|
|
397
446
|
}
|
|
398
447
|
|
|
448
|
+
/** Exact-name company lookup for resolve-first creates. Returns matching ids (max 3 fetched). */
|
|
449
|
+
async function searchCompaniesByName(name: string): Promise<string[]> {
|
|
450
|
+
const data = await request(`/crm/v3/objects/companies/search`, {
|
|
451
|
+
method: "POST",
|
|
452
|
+
body: JSON.stringify({
|
|
453
|
+
filterGroups: [{ filters: [{ propertyName: "name", operator: "EQ", value: name }] }],
|
|
454
|
+
properties: ["name"],
|
|
455
|
+
limit: 3,
|
|
456
|
+
}),
|
|
457
|
+
});
|
|
458
|
+
return ((data?.results ?? []) as Array<{ id: string }>).map((row) => String(row.id));
|
|
459
|
+
}
|
|
460
|
+
|
|
399
461
|
async function createTask(operation: PatchOperation): Promise<PatchOperationResult> {
|
|
400
462
|
const associationTypeId = TASK_ASSOCIATION_TYPE_IDS[operation.objectType];
|
|
401
463
|
if (associationTypeId === undefined) {
|
|
@@ -406,7 +468,33 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
406
468
|
};
|
|
407
469
|
}
|
|
408
470
|
const subject = operation.field ? humanizeField(operation.field) : "Follow up";
|
|
409
|
-
|
|
471
|
+
// The operation id doubles as an idempotency token: it is stamped into
|
|
472
|
+
// the task body and pre-checked so a replayed plan does not create the
|
|
473
|
+
// same task twice. Fail-open — a search hiccup must not block the apply.
|
|
474
|
+
const token = `fsgtm ${operation.id.replace(/^op_/, "")}`;
|
|
475
|
+
try {
|
|
476
|
+
const existing = await request(`/crm/v3/objects/tasks/search`, {
|
|
477
|
+
method: "POST",
|
|
478
|
+
body: JSON.stringify({
|
|
479
|
+
filterGroups: [
|
|
480
|
+
{ filters: [{ propertyName: "hs_task_body", operator: "CONTAINS_TOKEN", value: token.split(" ")[1] }] },
|
|
481
|
+
],
|
|
482
|
+
limit: 1,
|
|
483
|
+
}),
|
|
484
|
+
});
|
|
485
|
+
const hit = (existing?.results ?? [])[0] as { id?: string } | undefined;
|
|
486
|
+
if (hit?.id) {
|
|
487
|
+
return {
|
|
488
|
+
operationId: operation.id,
|
|
489
|
+
status: "skipped",
|
|
490
|
+
detail: `Task for this operation already exists (task ${hit.id}); not creating a duplicate.`,
|
|
491
|
+
providerData: { id: hit.id, existing: true },
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
} catch {
|
|
495
|
+
// fall through to create
|
|
496
|
+
}
|
|
497
|
+
const body = `${String(operation.afterValue ?? operation.reason ?? "")}\n\n[${token}]`;
|
|
410
498
|
const response = await request(`/crm/v3/objects/tasks`, {
|
|
411
499
|
method: "POST",
|
|
412
500
|
body: JSON.stringify({
|
|
@@ -501,6 +589,15 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
501
589
|
if (!objectPath || !mappingType) {
|
|
502
590
|
throw new Error(`Field reads are only supported for accounts, contacts, and deals.`);
|
|
503
591
|
}
|
|
592
|
+
// accountId is an association in HubSpot, not a property — without this
|
|
593
|
+
// branch the compare-and-set on link_record reads null and passes blind.
|
|
594
|
+
if (field === "accountId" && (objectType === "deal" || objectType === "contact")) {
|
|
595
|
+
const data = await request(
|
|
596
|
+
`/crm/v4/objects/${objectPath}/${encodeURIComponent(objectId)}/associations/companies?limit=1`,
|
|
597
|
+
);
|
|
598
|
+
const first = (data?.results ?? [])[0] as { toObjectId?: number | string } | undefined;
|
|
599
|
+
return first?.toObjectId !== undefined ? String(first.toObjectId) : null;
|
|
600
|
+
}
|
|
504
601
|
const defaults = HUBSPOT_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
|
|
505
602
|
const property = mappedField(mappings, mappingType, field, defaults[field] ?? field);
|
|
506
603
|
const data = await request(
|
|
@@ -63,8 +63,12 @@ export async function validateHubspotToken(
|
|
|
63
63
|
if (response.ok) {
|
|
64
64
|
return { ok: true, detail: "Token accepted by the HubSpot CRM API." };
|
|
65
65
|
}
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
// Never echo the response body: provider error payloads can reflect request
|
|
67
|
+
// details and end up in logs or shell scrollback.
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
detail: `HubSpot rejected the token: HTTP ${response.status} ${response.statusText}`.trim(),
|
|
71
|
+
};
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
async function tokenRequest(
|
|
@@ -63,6 +63,8 @@ export function createSalesforceConnector(
|
|
|
63
63
|
const apiVersion = options.apiVersion ?? DEFAULT_API_VERSION;
|
|
64
64
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
65
65
|
const mappings = options.fieldMappings;
|
|
66
|
+
// create:<Name> dedup within one connector lifetime (one apply run).
|
|
67
|
+
const createdAccountsByName = new Map<string, string>();
|
|
66
68
|
|
|
67
69
|
async function request(path: string, init: RequestInit = {}): Promise<any> {
|
|
68
70
|
const connection = await options.getConnection();
|
|
@@ -318,11 +320,29 @@ export function createSalesforceConnector(
|
|
|
318
320
|
detail: "Tasks can be attached to accounts, contacts, and deals.",
|
|
319
321
|
};
|
|
320
322
|
}
|
|
323
|
+
// Idempotency: the operation id is stamped into the Description and
|
|
324
|
+
// pre-checked, so replaying a plan does not duplicate tasks. Fail-open.
|
|
325
|
+
const token = `fsgtm:${operation.id}`;
|
|
326
|
+
try {
|
|
327
|
+
const existing = await query(
|
|
328
|
+
`SELECT Id FROM Task WHERE Description LIKE '%${token.replace(/'/g, "\\'")}%' LIMIT 1`,
|
|
329
|
+
);
|
|
330
|
+
if (existing.length > 0) {
|
|
331
|
+
return {
|
|
332
|
+
operationId: operation.id,
|
|
333
|
+
status: "skipped",
|
|
334
|
+
detail: `Task for this operation already exists (task ${existing[0].Id}); not creating a duplicate.`,
|
|
335
|
+
providerData: { id: String(existing[0].Id), existing: true },
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
} catch {
|
|
339
|
+
// fall through to create
|
|
340
|
+
}
|
|
321
341
|
const response = await request(`/services/data/${apiVersion}/sobjects/Task`, {
|
|
322
342
|
method: "POST",
|
|
323
343
|
body: JSON.stringify({
|
|
324
344
|
Subject: operation.field ? humanizeField(operation.field) : "Follow up",
|
|
325
|
-
Description: String(operation.afterValue ?? operation.reason ?? "")
|
|
345
|
+
Description: `${String(operation.afterValue ?? operation.reason ?? "")}\n\n[${token}]`,
|
|
326
346
|
Status: "Not Started",
|
|
327
347
|
Priority: "Normal",
|
|
328
348
|
...reference,
|
|
@@ -363,8 +383,56 @@ export function createSalesforceConnector(
|
|
|
363
383
|
case "clear_field":
|
|
364
384
|
// link_record on a deal is just setting AccountId in Salesforce.
|
|
365
385
|
return await setField(operation);
|
|
366
|
-
case "link_record":
|
|
386
|
+
case "link_record": {
|
|
387
|
+
// `create:<Name>` is resolve-first: link to an unambiguous existing
|
|
388
|
+
// Account, refuse on ambiguity, create only on a confirmed miss —
|
|
389
|
+
// and never create the same name twice within one apply run.
|
|
390
|
+
const value = String(operation.afterValue ?? "");
|
|
391
|
+
if (value.startsWith("create:")) {
|
|
392
|
+
const name = value.slice("create:".length).trim();
|
|
393
|
+
if (!name) {
|
|
394
|
+
return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
|
|
395
|
+
}
|
|
396
|
+
const nameKey = name.toLowerCase();
|
|
397
|
+
let accountId = createdAccountsByName.get(nameKey);
|
|
398
|
+
let createdNew = false;
|
|
399
|
+
if (!accountId) {
|
|
400
|
+
const soqlName = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
401
|
+
const matches = await query(
|
|
402
|
+
`SELECT Id FROM Account WHERE Name = '${soqlName}' LIMIT 3`,
|
|
403
|
+
);
|
|
404
|
+
if (matches.length > 1) {
|
|
405
|
+
return {
|
|
406
|
+
operationId: operation.id,
|
|
407
|
+
status: "skipped",
|
|
408
|
+
detail: `create:${name} is ambiguous — ${matches.length} accounts already named "${name}". Link an explicit account id instead.`,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
if (matches.length === 1) {
|
|
412
|
+
accountId = String(matches[0].Id);
|
|
413
|
+
} else {
|
|
414
|
+
const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
|
|
415
|
+
method: "POST",
|
|
416
|
+
body: JSON.stringify({ Name: name }),
|
|
417
|
+
});
|
|
418
|
+
accountId = String(created.id);
|
|
419
|
+
createdNew = true;
|
|
420
|
+
}
|
|
421
|
+
createdAccountsByName.set(nameKey, accountId);
|
|
422
|
+
}
|
|
423
|
+
const result = await setField({ ...operation, operation: "set_field", afterValue: accountId });
|
|
424
|
+
return result.status === "applied"
|
|
425
|
+
? {
|
|
426
|
+
...result,
|
|
427
|
+
detail: createdNew
|
|
428
|
+
? `Created account "${name}" (${accountId}) and linked ${operation.objectType}/${operation.objectId} to it.`
|
|
429
|
+
: `Linked ${operation.objectType}/${operation.objectId} to existing account ${accountId} (resolved by name, nothing created).`,
|
|
430
|
+
providerData: { accountId, ...(createdNew ? { createdAccount: true } : {}) },
|
|
431
|
+
}
|
|
432
|
+
: result;
|
|
433
|
+
}
|
|
367
434
|
return await setField({ ...operation, operation: "set_field" });
|
|
435
|
+
}
|
|
368
436
|
case "create_task":
|
|
369
437
|
return await createTask(operation);
|
|
370
438
|
case "archive_record":
|
|
@@ -155,13 +155,26 @@ export async function validateSalesforceToken(
|
|
|
155
155
|
instanceUrl: string,
|
|
156
156
|
fetchImpl: typeof fetch = fetch,
|
|
157
157
|
): Promise<{ ok: boolean; detail: string }> {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
158
|
+
let response: Response;
|
|
159
|
+
try {
|
|
160
|
+
response = await fetchImpl(
|
|
161
|
+
`${instanceUrl.replace(/\/$/, "")}/services/oauth2/userinfo`,
|
|
162
|
+
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
|
163
|
+
);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
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.`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
162
171
|
if (response.ok) {
|
|
163
172
|
return { ok: true, detail: "Token accepted by the Salesforce API." };
|
|
164
173
|
}
|
|
165
|
-
|
|
166
|
-
|
|
174
|
+
// Never echo the response body: provider error payloads can reflect request
|
|
175
|
+
// details and end up in logs or shell scrollback.
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
detail: `Salesforce rejected the token: HTTP ${response.status} ${response.statusText}`.trim(),
|
|
179
|
+
};
|
|
167
180
|
}
|
package/src/credentials.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
chmodSync,
|
|
3
3
|
existsSync,
|
|
4
4
|
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
5
6
|
readFileSync,
|
|
6
7
|
unlinkSync,
|
|
7
8
|
writeFileSync,
|
|
@@ -15,8 +16,63 @@ import { refreshSalesforceToken } from "./connectors/salesforceAuth.ts";
|
|
|
15
16
|
* Local CLI credential store: ~/.fullstackgtm/credentials.json (0600), or
|
|
16
17
|
* $FSGTM_HOME/credentials.json when set. Environment tokens always win over
|
|
17
18
|
* stored credentials so CI and agent sandboxes never touch the filesystem.
|
|
19
|
+
*
|
|
20
|
+
* Profiles let one operator hold credentials for several organizations at
|
|
21
|
+
* once (a consultant working across client CRMs). The default profile keeps
|
|
22
|
+
* the historical layout; a named profile scopes the entire home — credentials
|
|
23
|
+
* AND stored plans — under `profiles/<name>/`, so a patch plan proposed
|
|
24
|
+
* against one client's CRM can never be applied through another client's
|
|
25
|
+
* credentials.
|
|
18
26
|
*/
|
|
19
27
|
|
|
28
|
+
export const DEFAULT_PROFILE = "default";
|
|
29
|
+
|
|
30
|
+
const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
31
|
+
|
|
32
|
+
let explicitProfile: string | null = null;
|
|
33
|
+
|
|
34
|
+
export function validateProfileName(name: string): string {
|
|
35
|
+
if (!PROFILE_NAME_PATTERN.test(name) || name === "." || name === "..") {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Invalid profile name: ${JSON.stringify(name)}. Use letters, numbers, dots, dashes, ` +
|
|
38
|
+
"or underscores (must start with a letter or number, max 64 characters).",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return name;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Select the profile for this process; wins over $FULLSTACKGTM_PROFILE. */
|
|
45
|
+
export function setActiveProfile(name: string) {
|
|
46
|
+
explicitProfile = validateProfileName(name);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function activeProfile(): string {
|
|
50
|
+
if (explicitProfile) return explicitProfile;
|
|
51
|
+
const fromEnv = process.env.FULLSTACKGTM_PROFILE;
|
|
52
|
+
return fromEnv ? validateProfileName(fromEnv) : DEFAULT_PROFILE;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Base home directory, shared by every profile. */
|
|
56
|
+
export function baseHomeDir(): string {
|
|
57
|
+
return process.env.FSGTM_HOME ?? join(homedir(), ".fullstackgtm");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Profiles that exist on disk (have a directory), always including the
|
|
62
|
+
* default profile. Existence does not imply stored credentials.
|
|
63
|
+
*/
|
|
64
|
+
export function listProfiles(): string[] {
|
|
65
|
+
const names = new Set([DEFAULT_PROFILE]);
|
|
66
|
+
try {
|
|
67
|
+
for (const entry of readdirSync(join(baseHomeDir(), "profiles"), { withFileTypes: true })) {
|
|
68
|
+
if (entry.isDirectory() && PROFILE_NAME_PATTERN.test(entry.name)) names.add(entry.name);
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// No profiles directory yet.
|
|
72
|
+
}
|
|
73
|
+
return Array.from(names).sort();
|
|
74
|
+
}
|
|
75
|
+
|
|
20
76
|
export type StoredCredential = {
|
|
21
77
|
kind: "private_app" | "oauth" | "broker";
|
|
22
78
|
accessToken: string;
|
|
@@ -43,7 +99,9 @@ type CredentialsFile = {
|
|
|
43
99
|
};
|
|
44
100
|
|
|
45
101
|
export function credentialsDir(): string {
|
|
46
|
-
|
|
102
|
+
const base = baseHomeDir();
|
|
103
|
+
const profile = activeProfile();
|
|
104
|
+
return profile === DEFAULT_PROFILE ? base : join(base, "profiles", profile);
|
|
47
105
|
}
|
|
48
106
|
|
|
49
107
|
export function credentialsPath(): string {
|
|
@@ -59,11 +117,18 @@ export function credentialsPath(): string {
|
|
|
59
117
|
*/
|
|
60
118
|
export function ensureSecureHomeDir(): string {
|
|
61
119
|
const dir = credentialsDir();
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
120
|
+
// A named profile nests under base/profiles/<name>; lock down every level
|
|
121
|
+
// we create, not just the leaf — recursive mkdir applies `mode` (less
|
|
122
|
+
// umask) only to directories it creates, and never to pre-existing ones.
|
|
123
|
+
const levels =
|
|
124
|
+
dir === baseHomeDir() ? [dir] : [baseHomeDir(), join(baseHomeDir(), "profiles"), dir];
|
|
125
|
+
for (const level of levels) {
|
|
126
|
+
mkdirSync(level, { recursive: true, mode: 0o700 });
|
|
127
|
+
try {
|
|
128
|
+
chmodSync(level, 0o700);
|
|
129
|
+
} catch {
|
|
130
|
+
// Non-POSIX filesystems (e.g. Windows) ignore chmod; nothing to enforce.
|
|
131
|
+
}
|
|
67
132
|
}
|
|
68
133
|
return dir;
|
|
69
134
|
}
|
package/src/index.ts
CHANGED
|
@@ -34,12 +34,16 @@ export {
|
|
|
34
34
|
} from "./connectors/salesforceAuth.ts";
|
|
35
35
|
export { createStripeConnector, type StripeConnectorOptions } from "./connectors/stripe.ts";
|
|
36
36
|
export {
|
|
37
|
+
activeProfile,
|
|
37
38
|
credentialsDir,
|
|
38
39
|
credentialsPath,
|
|
40
|
+
DEFAULT_PROFILE,
|
|
39
41
|
deleteCredential,
|
|
40
42
|
getCredential,
|
|
43
|
+
listProfiles,
|
|
41
44
|
resolveHubspotAccessToken,
|
|
42
45
|
resolveHubspotConnection,
|
|
46
|
+
setActiveProfile,
|
|
43
47
|
storeCredential,
|
|
44
48
|
type HubspotConnection,
|
|
45
49
|
type StoredCredential,
|
|
@@ -64,6 +68,7 @@ export {
|
|
|
64
68
|
} from "./merge.ts";
|
|
65
69
|
export { createFilePlanStore, type PlanStore, type StoredPlan } from "./planStore.ts";
|
|
66
70
|
export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
71
|
+
export { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
|
|
67
72
|
export {
|
|
68
73
|
HUBSPOT_DEFAULT_FIELD_MAPPINGS,
|
|
69
74
|
SALESFORCE_DEFAULT_FIELD_MAPPINGS,
|
|
@@ -83,6 +88,7 @@ export {
|
|
|
83
88
|
closingSoonInactiveRule,
|
|
84
89
|
duplicateAccountDomainRule,
|
|
85
90
|
duplicateContactEmailRule,
|
|
91
|
+
duplicateOpenDealRule,
|
|
86
92
|
missingDealAccountRule,
|
|
87
93
|
missingDealAmountRule,
|
|
88
94
|
missingDealOwnerRule,
|
|
@@ -93,6 +99,7 @@ export {
|
|
|
93
99
|
staleDealRule,
|
|
94
100
|
} from "./rules.ts";
|
|
95
101
|
export { sampleSnapshot } from "./sampleData.ts";
|
|
102
|
+
export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
|
|
96
103
|
export type {
|
|
97
104
|
ApprovalStatus,
|
|
98
105
|
AuditFinding,
|
package/src/mcp.ts
CHANGED
|
@@ -46,6 +46,7 @@ import type { FieldMappings } from "./mappings.ts";
|
|
|
46
46
|
import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
47
47
|
import { builtinAuditRules } from "./rules.ts";
|
|
48
48
|
import { sampleSnapshot } from "./sampleData.ts";
|
|
49
|
+
import { suggestValues } from "./suggest.ts";
|
|
49
50
|
import type { CanonicalGtmSnapshot, GtmConnector, PatchPlan } from "./types.ts";
|
|
50
51
|
|
|
51
52
|
function content(value: unknown) {
|
|
@@ -166,6 +167,27 @@ export async function startMcpServer() {
|
|
|
166
167
|
},
|
|
167
168
|
);
|
|
168
169
|
|
|
170
|
+
server.registerTool(
|
|
171
|
+
"fullstackgtm_suggest",
|
|
172
|
+
{
|
|
173
|
+
title: "Suggest Placeholder Values",
|
|
174
|
+
description:
|
|
175
|
+
"Derive values for a plan's requires_human_* placeholder operations from snapshot " +
|
|
176
|
+
"evidence (account-name matching, contact associations), with confidence levels and " +
|
|
177
|
+
"reasons. Read-only; feed accepted values into fullstackgtm_apply's valueOverrides.",
|
|
178
|
+
inputSchema: {
|
|
179
|
+
planPath: z.string(),
|
|
180
|
+
provider: z.enum(["sample", "demo", "hubspot", "salesforce", "stripe"]).optional(),
|
|
181
|
+
inputPath: z.string().optional(),
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
async ({ planPath, provider, inputPath }) => {
|
|
185
|
+
const plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8")) as PatchPlan;
|
|
186
|
+
const snapshot = await readSnapshot(provider, inputPath);
|
|
187
|
+
return content({ suggestions: suggestValues(plan, snapshot) });
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
|
|
169
191
|
server.registerTool(
|
|
170
192
|
"fullstackgtm_rules",
|
|
171
193
|
{
|
package/src/merge.ts
CHANGED
|
@@ -55,7 +55,7 @@ const CONFLICT_IGNORED_FIELDS = new Set([
|
|
|
55
55
|
"id", "provider", "crmId", "identities", "raw", "lastSyncAt", "lastActivityAt", "ownerId", "accountId",
|
|
56
56
|
]);
|
|
57
57
|
|
|
58
|
-
function normalizeDomain(domain?: string): string | undefined {
|
|
58
|
+
export function normalizeDomain(domain?: string): string | undefined {
|
|
59
59
|
if (!domain) return undefined;
|
|
60
60
|
return domain.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/.*$/, "") || undefined;
|
|
61
61
|
}
|