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.
@@ -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
- const companyId = String(operation.afterValue ?? "");
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: `Linked ${fromPath}/${operation.objectId} to company ${companyId}.`,
294
- providerData: { companyId },
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
- const body = String(operation.afterValue ?? operation.reason ?? "");
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
- const body = await response.text();
53
- return { ok: false, detail: `HubSpot rejected the token (${response.status}): ${body}` };
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
- const response = await fetchImpl(`${instanceUrl.replace(/\/$/, "")}/services/oauth2/userinfo`, { headers: { Authorization: `Bearer ${accessToken}` } });
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
- const body = await response.text();
119
- return { ok: false, detail: `Salesforce rejected the token (${response.status}): ${body}` };
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
  }
@@ -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;
@@ -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
- export function credentialsDir() {
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
- mkdirSync(dir, { recursive: true, mode: 0o700 });
22
- try {
23
- chmodSync(dir, 0o700);
24
- }
25
- catch {
26
- // Non-POSIX filesystems (e.g. Windows) ignore chmod; nothing to enforce.
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;
@@ -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 {};