fullstackgtm 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  {