fullstackgtm 0.27.0 → 0.28.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 +44 -0
- package/DATA-FLOWS.md +3 -3
- package/NOTICE +2 -2
- package/README.md +15 -1
- package/SECURITY.md +15 -2
- package/dist/bulkUpdate.d.ts +16 -4
- package/dist/bulkUpdate.js +209 -10
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +41 -1
- package/dist/connector.js +6 -2
- package/dist/credentials.js +85 -11
- package/dist/keychain.d.ts +30 -0
- package/dist/keychain.js +85 -0
- package/dist/mcp.js +8 -1
- package/dist/types.d.ts +6 -0
- package/docs/api.md +5 -2
- package/llms.txt +7 -1
- package/package.json +3 -3
- package/src/bulkUpdate.ts +226 -10
- package/src/cli.ts +40 -1
- package/src/connector.ts +6 -2
- package/src/credentials.ts +82 -11
- package/src/keychain.ts +112 -0
- package/src/mcp.ts +8 -1
- package/src/types.ts +10 -1
package/dist/credentials.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { refreshHubspotToken } from "./connectors/hubspotAuth.js";
|
|
5
6
|
import { refreshSalesforceToken } from "./connectors/salesforceAuth.js";
|
|
7
|
+
import { detectKeychainBackend } from "./keychain.js";
|
|
6
8
|
/**
|
|
7
9
|
* Local CLI credential store: ~/.fullstackgtm/credentials.json (0600), or
|
|
8
10
|
* $FSGTM_HOME/credentials.json when set. Environment tokens always win over
|
|
@@ -118,38 +120,103 @@ function enforceCredentialFileMode(path) {
|
|
|
118
120
|
// Missing file or non-POSIX filesystem: nothing to enforce.
|
|
119
121
|
}
|
|
120
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Persistence backend for the credential blob: the OS keychain when
|
|
125
|
+
* FSGTM_KEYCHAIN=1 and one is available, otherwise the 0600 file. The keychain
|
|
126
|
+
* account is derived from the credential file path so distinct homes/profiles
|
|
127
|
+
* never collide in the machine-wide store.
|
|
128
|
+
*/
|
|
129
|
+
function activeKeychain() {
|
|
130
|
+
if (process.env.FSGTM_KEYCHAIN !== "1")
|
|
131
|
+
return null;
|
|
132
|
+
const backend = detectKeychainBackend();
|
|
133
|
+
if (!backend)
|
|
134
|
+
return null;
|
|
135
|
+
return { account: createHash("sha256").update(credentialsPath()).digest("hex").slice(0, 24), backend };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* When keychain is enabled on an install that previously wrote a plaintext
|
|
139
|
+
* credentials.json, that file would otherwise sit on disk forever (readFile only
|
|
140
|
+
* looks at the keychain). Import it into the keychain and remove it — the whole
|
|
141
|
+
* point of keychain mode is that no plaintext credential remains at rest.
|
|
142
|
+
*/
|
|
143
|
+
function migratePlaintextToKeychain(keychain) {
|
|
144
|
+
if (!existsSync(credentialsPath()))
|
|
145
|
+
return;
|
|
146
|
+
try {
|
|
147
|
+
const fileParsed = JSON.parse(readFileSync(credentialsPath(), "utf8"));
|
|
148
|
+
if (fileParsed && fileParsed.version === 1 && fileParsed.providers) {
|
|
149
|
+
const current = keychain.backend.get(keychain.account);
|
|
150
|
+
const existing = current ? (JSON.parse(current).providers ?? {}) : {};
|
|
151
|
+
// Keychain entries win over the file on conflict (the file is the older copy).
|
|
152
|
+
const merged = { version: 1, providers: { ...fileParsed.providers, ...existing } };
|
|
153
|
+
keychain.backend.set(keychain.account, `${JSON.stringify(merged, null, 2)}\n`);
|
|
154
|
+
}
|
|
155
|
+
unlinkSync(credentialsPath());
|
|
156
|
+
console.error("fullstackgtm: migrated credentials.json into the OS keychain and removed the plaintext file.");
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Best effort: a malformed/locked file is left in place rather than lost.
|
|
160
|
+
}
|
|
161
|
+
}
|
|
121
162
|
function readFile() {
|
|
163
|
+
const keychain = activeKeychain();
|
|
164
|
+
if (keychain)
|
|
165
|
+
migratePlaintextToKeychain(keychain);
|
|
122
166
|
try {
|
|
123
|
-
enforceCredentialFileMode(credentialsPath());
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
167
|
+
const raw = keychain ? keychain.backend.get(keychain.account) : (enforceCredentialFileMode(credentialsPath()), readFileSync(credentialsPath(), "utf8"));
|
|
168
|
+
if (raw) {
|
|
169
|
+
const parsed = JSON.parse(raw);
|
|
170
|
+
if (parsed && typeof parsed === "object" && parsed.version === 1 && parsed.providers) {
|
|
171
|
+
return parsed;
|
|
172
|
+
}
|
|
127
173
|
}
|
|
128
174
|
}
|
|
129
175
|
catch {
|
|
130
|
-
// Missing or unreadable
|
|
176
|
+
// Missing or unreadable store falls through to an empty one.
|
|
131
177
|
}
|
|
132
178
|
return { version: 1, providers: {} };
|
|
133
179
|
}
|
|
180
|
+
function persist(file) {
|
|
181
|
+
const keychain = activeKeychain();
|
|
182
|
+
const blob = `${JSON.stringify(file, null, 2)}\n`;
|
|
183
|
+
if (keychain) {
|
|
184
|
+
keychain.backend.set(keychain.account, blob);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
ensureSecureHomeDir();
|
|
188
|
+
writeSecureFile(credentialsPath(), blob);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
134
191
|
export function getCredential(provider) {
|
|
135
192
|
return readFile().providers[provider] ?? null;
|
|
136
193
|
}
|
|
137
194
|
export function storeCredential(provider, credential) {
|
|
138
195
|
const file = readFile();
|
|
139
196
|
file.providers[provider] = credential;
|
|
140
|
-
|
|
141
|
-
writeSecureFile(credentialsPath(), `${JSON.stringify(file, null, 2)}\n`);
|
|
197
|
+
persist(file);
|
|
142
198
|
}
|
|
143
199
|
export function deleteCredential(provider) {
|
|
144
200
|
const file = readFile();
|
|
145
201
|
if (!file.providers[provider])
|
|
146
202
|
return false;
|
|
147
203
|
delete file.providers[provider];
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
204
|
+
const keychain = activeKeychain();
|
|
205
|
+
if (Object.keys(file.providers).length === 0) {
|
|
206
|
+
if (keychain) {
|
|
207
|
+
keychain.backend.delete(keychain.account);
|
|
208
|
+
// Defensive: remove any leftover plaintext file too (migration normally
|
|
209
|
+
// already did, but never leave a credential blob on disk after logout).
|
|
210
|
+
if (existsSync(credentialsPath()))
|
|
211
|
+
unlinkSync(credentialsPath());
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
if (existsSync(credentialsPath())) {
|
|
215
|
+
unlinkSync(credentialsPath());
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
151
218
|
}
|
|
152
|
-
|
|
219
|
+
persist(file);
|
|
153
220
|
return true;
|
|
154
221
|
}
|
|
155
222
|
const REFRESH_SKEW_MS = 2 * 60 * 1000;
|
|
@@ -222,6 +289,13 @@ async function brokerMint(provider, options) {
|
|
|
222
289
|
const broker = getCredential("broker");
|
|
223
290
|
if (!broker?.baseUrl)
|
|
224
291
|
return null;
|
|
292
|
+
// The mint replays the long-lived bearer and receives a live CRM token —
|
|
293
|
+
// refuse to do so over cleartext even if a tampered store points us at http.
|
|
294
|
+
const brokerUrl = new URL(broker.baseUrl);
|
|
295
|
+
const localhost = brokerUrl.hostname === "localhost" || brokerUrl.hostname === "127.0.0.1" || brokerUrl.hostname === "::1" || brokerUrl.hostname === "[::1]";
|
|
296
|
+
if (brokerUrl.protocol !== "https:" && !(brokerUrl.protocol === "http:" && localhost)) {
|
|
297
|
+
throw new Error(`Refusing to mint a CRM token over ${brokerUrl.protocol}//${brokerUrl.host} — the broker must use https. Re-pair with an https deployment.`);
|
|
298
|
+
}
|
|
225
299
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
226
300
|
const response = await fetchImpl(`${broker.baseUrl.replace(/\/$/, "")}/api/cli/token`, {
|
|
227
301
|
method: "POST",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional OS-keychain backing for the credential store. Off by default;
|
|
3
|
+
* enabled with FSGTM_KEYCHAIN=1. When on, the credential blob is stored in the
|
|
4
|
+
* OS secret store instead of a 0600 file, so a cloned home, a restored backup,
|
|
5
|
+
* or another tool reading `~/.fullstackgtm/credentials.json` finds nothing.
|
|
6
|
+
*
|
|
7
|
+
* Backends shell out to the OS tool — no native dependency, so the package
|
|
8
|
+
* stays zero-dep:
|
|
9
|
+
* - Linux: `secret-tool` (libsecret) — reads the secret from STDIN (no argv leak).
|
|
10
|
+
* - macOS: `security` — `add-generic-password` only accepts the secret via the
|
|
11
|
+
* `-w` argv flag, so it is briefly visible to same-user `ps` during the call.
|
|
12
|
+
* That transient, same-user exposure is strictly smaller than a persistent
|
|
13
|
+
* plaintext file (which the same processes can read at any time), but it is a
|
|
14
|
+
* real caveat, documented in SECURITY.md.
|
|
15
|
+
*
|
|
16
|
+
* Keychain entries are NOT scoped by $FSGTM_HOME (the OS store is machine-wide),
|
|
17
|
+
* so the account name is derived from the credential file path to keep distinct
|
|
18
|
+
* homes/profiles from colliding. This is also why keychain is opt-in: defaulting
|
|
19
|
+
* it on would make throwaway-home test/eval runs write to the machine keychain.
|
|
20
|
+
*/
|
|
21
|
+
export type KeychainBackend = {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
get(account: string): string | null;
|
|
24
|
+
set(account: string, secret: string): void;
|
|
25
|
+
delete(account: string): void;
|
|
26
|
+
};
|
|
27
|
+
/** Test seam: force a backend (or null to force "none"). undefined = re-detect. */
|
|
28
|
+
export declare function setKeychainBackendForTests(backend: KeychainBackend | null | undefined): void;
|
|
29
|
+
/** The active backend for this platform, or null if none is available. */
|
|
30
|
+
export declare function detectKeychainBackend(): KeychainBackend | null;
|
package/dist/keychain.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { platform } from "node:os";
|
|
3
|
+
const SERVICE = "fullstackgtm";
|
|
4
|
+
function hasBinary(bin) {
|
|
5
|
+
try {
|
|
6
|
+
execFileSync("/usr/bin/env", ["which", bin], { stdio: "ignore" });
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const macosBackend = {
|
|
14
|
+
name: "macos-keychain",
|
|
15
|
+
get(account) {
|
|
16
|
+
try {
|
|
17
|
+
return execFileSync("security", ["find-generic-password", "-s", SERVICE, "-a", account, "-w"], {
|
|
18
|
+
encoding: "utf8",
|
|
19
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
20
|
+
}).replace(/\n$/, "");
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null; // not found → non-zero exit
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
set(account, secret) {
|
|
27
|
+
// -U updates if present. NOTE: the secret is in argv for the duration of
|
|
28
|
+
// this call (see the module comment); `security` has no stdin path.
|
|
29
|
+
execFileSync("security", ["add-generic-password", "-U", "-s", SERVICE, "-a", account, "-w", secret], {
|
|
30
|
+
stdio: "ignore",
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
delete(account) {
|
|
34
|
+
try {
|
|
35
|
+
execFileSync("security", ["delete-generic-password", "-s", SERVICE, "-a", account], { stdio: "ignore" });
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// already absent
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
const secretToolBackend = {
|
|
43
|
+
name: "linux-secret-tool",
|
|
44
|
+
get(account) {
|
|
45
|
+
try {
|
|
46
|
+
return execFileSync("secret-tool", ["lookup", "service", SERVICE, "account", account], {
|
|
47
|
+
encoding: "utf8",
|
|
48
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
set(account, secret) {
|
|
56
|
+
// secret-tool reads the secret from STDIN — no argv exposure.
|
|
57
|
+
execFileSync("secret-tool", ["store", "--label", `${SERVICE} ${account}`, "service", SERVICE, "account", account], {
|
|
58
|
+
input: secret,
|
|
59
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
delete(account) {
|
|
63
|
+
try {
|
|
64
|
+
execFileSync("secret-tool", ["clear", "service", SERVICE, "account", account], { stdio: "ignore" });
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// already absent
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
let override;
|
|
72
|
+
/** Test seam: force a backend (or null to force "none"). undefined = re-detect. */
|
|
73
|
+
export function setKeychainBackendForTests(backend) {
|
|
74
|
+
override = backend;
|
|
75
|
+
}
|
|
76
|
+
/** The active backend for this platform, or null if none is available. */
|
|
77
|
+
export function detectKeychainBackend() {
|
|
78
|
+
if (override !== undefined)
|
|
79
|
+
return override;
|
|
80
|
+
if (platform() === "darwin" && hasBinary("security"))
|
|
81
|
+
return macosBackend;
|
|
82
|
+
if (platform() === "linux" && hasBinary("secret-tool"))
|
|
83
|
+
return secretToolBackend;
|
|
84
|
+
return null;
|
|
85
|
+
}
|
package/dist/mcp.js
CHANGED
|
@@ -23,8 +23,15 @@ async function importPeer(specifier) {
|
|
|
23
23
|
}
|
|
24
24
|
catch (error) {
|
|
25
25
|
try {
|
|
26
|
+
// Last-resort fallback to the invoking project's node_modules (the npx
|
|
27
|
+
// landmine: peers there, fullstackgtm in the npx cache). This loads code
|
|
28
|
+
// from the current working directory, so make it VISIBLE — running the
|
|
29
|
+
// MCP server in an untrusted directory could otherwise silently load a
|
|
30
|
+
// malicious `zod`/SDK from its node_modules.
|
|
26
31
|
const projectRequire = createRequire(join(process.cwd(), "package.json"));
|
|
27
|
-
|
|
32
|
+
const resolved = projectRequire.resolve(specifier);
|
|
33
|
+
console.error(`fullstackgtm-mcp: loading peer "${specifier}" from the current directory (${resolved}). Only run the MCP server in a directory you trust.`);
|
|
34
|
+
return (await import(__rewriteRelativeImportExtension(pathToFileURL(resolved).href)));
|
|
28
35
|
}
|
|
29
36
|
catch {
|
|
30
37
|
throw error; // the original error carries the missing-peer signal mcp-bin reports on
|
package/dist/types.d.ts
CHANGED
|
@@ -281,6 +281,12 @@ export type PatchPlan = {
|
|
|
281
281
|
filter?: {
|
|
282
282
|
objectType: "account" | "contact" | "deal";
|
|
283
283
|
where: string[];
|
|
284
|
+
/**
|
|
285
|
+
* The date the filter's comparison `today` literal resolves to (ISO
|
|
286
|
+
* yyyy-mm-dd). Stored so apply-time re-verification resolves `today`
|
|
287
|
+
* identically to plan time; absent on plans built before comparison ops.
|
|
288
|
+
*/
|
|
289
|
+
today?: string;
|
|
284
290
|
};
|
|
285
291
|
/**
|
|
286
292
|
* Plan-level guards re-evaluated against a FRESH snapshot at apply time.
|
package/docs/api.md
CHANGED
|
@@ -91,8 +91,11 @@ emits a standard dry-run `PatchPlan` for the normal approve → apply chain:
|
|
|
91
91
|
|
|
92
92
|
- `buildBulkUpdatePlan(snapshot, options: BulkUpdateOptions)` with
|
|
93
93
|
`parseWhere` (filter expressions: `=`, `!=`, `~`, `!~`, `:empty`,
|
|
94
|
-
`:notempty`,
|
|
95
|
-
`
|
|
94
|
+
`:notempty`, type-aware comparisons `<`, `>`, `<=`, `>=` — `today` resolves
|
|
95
|
+
to `options.today`/the policy date, date and numeric fields coerce by value
|
|
96
|
+
form, `|` any-of, relational pseudo-fields) and `isFilterableField`. Filters
|
|
97
|
+
are re-verified per record at apply time (the resolved `today` rides along on
|
|
98
|
+
`plan.filter.today` so re-verification agrees with plan time);
|
|
96
99
|
`from:<sourceField>` values derive per record from the snapshot.
|
|
97
100
|
- `buildDedupePlan(snapshot, options: DedupeOptions)` with `dedupeKey` —
|
|
98
101
|
duplicate groups by normalized identity key, one `merge_records` per group,
|
package/llms.txt
CHANGED
|
@@ -61,7 +61,13 @@ Storage is profile-scoped under `<home>/market/<category>`. MCP:
|
|
|
61
61
|
snapshot into a dry-run plan; the FULL filter is re-verified per record at
|
|
62
62
|
apply time (plus mid-apply rechecks); equality filters double as
|
|
63
63
|
preconditions, `--require`/`--guard` add explicit ones, `--max-operations`
|
|
64
|
-
caps blast radius.
|
|
64
|
+
caps blast radius. Filter operators: `=` `!=` `~` `!~` `:empty` `:notempty`,
|
|
65
|
+
plus type-aware comparisons `<` `>` `<=` `>=` (`today` resolves to the policy
|
|
66
|
+
date, e.g. `closeDate<today`; date fields compare as dates, numeric as
|
|
67
|
+
numbers, unset/non-parseable values do not match). For date/count hygiene
|
|
68
|
+
(past close dates, stale deals, missing accounts, duplicates) prefer the
|
|
69
|
+
rule-backed `fix --rule <id>` — it encodes the date/open-deal logic
|
|
70
|
+
deterministically; reach for bulk-update only when no rule covers the task. `--set f=from:<source>` derives per-record values (empty
|
|
65
71
|
source = skip + count, never guess). `--archive` refuses records sharing an
|
|
66
72
|
identity key — merge with `dedupe` instead. `dedupe <object> --key
|
|
67
73
|
<domain|email|name>` = one merge_records op per duplicate group,
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.1",
|
|
4
4
|
"description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
|
-
"author": "Full Stack GTM <
|
|
6
|
+
"author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
|
|
7
7
|
"homepage": "https://github.com/fullstackgtm/core#readme",
|
|
8
8
|
"bugs": {
|
|
9
9
|
"url": "https://github.com/fullstackgtm/core/issues"
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"DATA-FLOWS.md"
|
|
38
38
|
],
|
|
39
39
|
"scripts": {
|
|
40
|
-
"build": "tsc -p tsconfig.build.json",
|
|
40
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
41
41
|
"test": "node --experimental-strip-types --test tests/*.test.ts",
|
|
42
42
|
"prepublishOnly": "npm run build"
|
|
43
43
|
},
|