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.
@@ -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
- const companyId = String(operation.afterValue ?? "");
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: `Linked ${fromPath}/${operation.objectId} to company ${companyId}.`,
395
- providerData: { companyId },
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
- const body = String(operation.afterValue ?? operation.reason ?? "");
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
- const body = await response.text();
67
- return { ok: false, detail: `HubSpot rejected the token (${response.status}): ${body}` };
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
- const response = await fetchImpl(
159
- `${instanceUrl.replace(/\/$/, "")}/services/oauth2/userinfo`,
160
- { headers: { Authorization: `Bearer ${accessToken}` } },
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
- const body = await response.text();
166
- return { ok: false, detail: `Salesforce rejected the token (${response.status}): ${body}` };
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
  }
@@ -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
- return process.env.FSGTM_HOME ?? join(homedir(), ".fullstackgtm");
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
- mkdirSync(dir, { recursive: true, mode: 0o700 });
63
- try {
64
- chmodSync(dir, 0o700);
65
- } catch {
66
- // Non-POSIX filesystems (e.g. Windows) ignore chmod; nothing to enforce.
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
  }