fullstackgtm 0.10.0 → 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.
@@ -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
@@ -1,8 +1,35 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { resolve } from "node:path";
3
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
- import { z } from "zod/v4";
2
+ import { createRequire } from "node:module";
3
+ import { join, resolve } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+
6
+ /**
7
+ * The MCP peers resolve normally when installed alongside this package, but
8
+ * `npx -p fullstackgtm -p @modelcontextprotocol/sdk -p zod` skips installing
9
+ * peers into the npx cache when the invoking project's node_modules already
10
+ * satisfies them — and the cache can't reach the project's tree. Fall back to
11
+ * resolving from the working directory: peer dependencies' natural home.
12
+ */
13
+ async function importPeer<T>(specifier: string): Promise<T> {
14
+ try {
15
+ return (await import(specifier)) as T;
16
+ } catch (error) {
17
+ try {
18
+ const projectRequire = createRequire(join(process.cwd(), "package.json"));
19
+ return (await import(pathToFileURL(projectRequire.resolve(specifier)).href)) as T;
20
+ } catch {
21
+ throw error; // the original error carries the missing-peer signal mcp-bin reports on
22
+ }
23
+ }
24
+ }
25
+
26
+ const { McpServer } = await importPeer<typeof import("@modelcontextprotocol/sdk/server/mcp.js")>(
27
+ "@modelcontextprotocol/sdk/server/mcp.js",
28
+ );
29
+ const { StdioServerTransport } = await importPeer<typeof import("@modelcontextprotocol/sdk/server/stdio.js")>(
30
+ "@modelcontextprotocol/sdk/server/stdio.js",
31
+ );
32
+ const { z } = await importPeer<typeof import("zod/v4")>("zod/v4");
6
33
  import { auditSnapshot, defaultPolicy } from "./audit.ts";
7
34
  import { loadConfig, mergePolicy, resolveConfiguredRules } from "./config.ts";
8
35
  import { applyPatchPlan } from "./connector.ts";
@@ -19,6 +46,7 @@ import type { FieldMappings } from "./mappings.ts";
19
46
  import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
20
47
  import { builtinAuditRules } from "./rules.ts";
21
48
  import { sampleSnapshot } from "./sampleData.ts";
49
+ import { suggestValues } from "./suggest.ts";
22
50
  import type { CanonicalGtmSnapshot, GtmConnector, PatchPlan } from "./types.ts";
23
51
 
24
52
  function content(value: unknown) {
@@ -113,7 +141,8 @@ export async function startMcpServer() {
113
141
  title: "GTM Ops Audit",
114
142
  description:
115
143
  "Run a dry-run GTM hygiene audit and return a reviewable patch plan. " +
116
- "Reads from the sample dataset, a snapshot file, or a live provider.",
144
+ "Sources: the realistic zero-credential demo CRM (provider: \"demo\" richest test data), " +
145
+ "the minimal sample dataset, a snapshot file, or a live provider.",
117
146
  inputSchema: {
118
147
  provider: z.enum(["sample", "demo", "hubspot", "salesforce", "stripe"]).optional(),
119
148
  inputPath: z.string().optional(),
@@ -138,6 +167,27 @@ export async function startMcpServer() {
138
167
  },
139
168
  );
140
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
+
141
191
  server.registerTool(
142
192
  "fullstackgtm_rules",
143
193
  {