fullstackgtm 0.26.0 → 0.28.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.
- package/CHANGELOG.md +83 -0
- package/DATA-FLOWS.md +52 -0
- package/NOTICE +5 -0
- package/README.md +18 -2
- package/SECURITY.md +82 -0
- package/dist/auditLog.d.ts +58 -0
- package/dist/auditLog.js +112 -0
- package/dist/bulkUpdate.d.ts +16 -4
- package/dist/bulkUpdate.js +209 -10
- package/dist/cli.d.ts +8 -1
- package/dist/cli.js +93 -1
- package/dist/connector.js +6 -2
- package/dist/credentials.js +85 -11
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/keychain.d.ts +30 -0
- package/dist/keychain.js +85 -0
- package/dist/llm.js +48 -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 +7 -4
- package/src/auditLog.ts +173 -0
- package/src/bulkUpdate.ts +226 -10
- package/src/cli.ts +90 -1
- package/src/connector.ts +6 -2
- package/src/credentials.ts +82 -11
- package/src/index.ts +7 -0
- package/src/keychain.ts +112 -0
- package/src/llm.ts +47 -0
- package/src/mcp.ts +8 -1
- package/src/types.ts +10 -1
package/dist/bulkUpdate.js
CHANGED
|
@@ -18,8 +18,21 @@
|
|
|
18
18
|
* field=value case-insensitive equality
|
|
19
19
|
* field!=value case-insensitive inequality
|
|
20
20
|
* field~value case-insensitive substring
|
|
21
|
+
* field!~value case-insensitive not-substring
|
|
21
22
|
* field:empty unset or empty string
|
|
22
23
|
* field:notempty set and non-empty
|
|
24
|
+
* field<value type-aware less-than (date or numeric)
|
|
25
|
+
* field>value type-aware greater-than
|
|
26
|
+
* field<=value type-aware less-than-or-equal
|
|
27
|
+
* field>=value type-aware greater-than-or-equal
|
|
28
|
+
*
|
|
29
|
+
* Comparison operators (`<` `>` `<=` `>=`) are type-aware, unlike the string
|
|
30
|
+
* operators above: if both the field value and the RHS parse as finite dates
|
|
31
|
+
* they compare as dates; else if both parse as finite numbers they compare
|
|
32
|
+
* numerically; otherwise the clause does not match (never throws at match
|
|
33
|
+
* time). The bare literal `today` on the RHS resolves to the policy/today date
|
|
34
|
+
* (`--today`, defaulting to the system date) and forces a date comparison —
|
|
35
|
+
* so `closeDate<today` is the canonical "past close date" filter.
|
|
23
36
|
*
|
|
24
37
|
* Fields are canonical (ownerId, stage, closeDate, amount, domain, name,
|
|
25
38
|
* email, isClosed, accountId, …). Relational pseudo-fields are available in
|
|
@@ -31,7 +44,49 @@ import { recoverableFields } from "./dedupe.js";
|
|
|
31
44
|
import { normalizeDomain } from "./merge.js";
|
|
32
45
|
import { stableHash } from "./rules.js";
|
|
33
46
|
const FIELD_PATTERN = "[a-zA-Z][a-zA-Z0-9_]*(?:\\.[a-zA-Z][a-zA-Z0-9_]*)?";
|
|
47
|
+
/**
|
|
48
|
+
* Is a comparison RHS (a single `|`-alternative) authorable for comparison?
|
|
49
|
+
* Accept `today`, OR a plain number, OR a date-SHAPED string — using the exact
|
|
50
|
+
* same `isPlainNumber`/`isDateLike` predicates the match-time branch uses, so
|
|
51
|
+
* the parse-time authoring check and the runtime branch cannot diverge. The
|
|
52
|
+
* intentional asymmetry: parse-time validates one literal in isolation (EITHER
|
|
53
|
+
* number OR date is fine), whereas match-time's `compareValues` additionally
|
|
54
|
+
* requires the FIELD and the RHS to agree on type (BOTH number, or BOTH date).
|
|
55
|
+
* Requiring a date *shape* here (not bare `Date.parse`-finite) means a prose
|
|
56
|
+
* typo like `July 4` or a stray `<5` fails loudly at parse instead of silently
|
|
57
|
+
* matching nothing. `isDateLike`/`isPlainNumber` are hoisted function decls.
|
|
58
|
+
*/
|
|
59
|
+
function isComparableValue(rhs) {
|
|
60
|
+
const trimmed = rhs.trim();
|
|
61
|
+
if (trimmed === "today")
|
|
62
|
+
return true;
|
|
63
|
+
if (isPlainNumber(trimmed))
|
|
64
|
+
return true;
|
|
65
|
+
if (isDateLike(trimmed))
|
|
66
|
+
return true;
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Catch the "multiple conditions crammed into one --where" footgun: a weak
|
|
71
|
+
* agent writes `--where 'stage!=closedwon AND stage!=closedlost'`, which the
|
|
72
|
+
* grammar would otherwise parse as a single neq clause whose VALUE is the
|
|
73
|
+
* literal string `closedwon AND stage!=closedlost` — matching almost nothing
|
|
74
|
+
* (or, with `!=`, almost EVERYTHING), so the gate faithfully applies a
|
|
75
|
+
* wildly-wrong-but-authorized plan.
|
|
76
|
+
*
|
|
77
|
+
* Fire only when a connector ` AND `/` OR ` is followed by a clause-LIKE
|
|
78
|
+
* structure (a field name + an operator), so incidental prose in a value —
|
|
79
|
+
* `name~Procter AND Gamble`, `name~research and development` — is NOT
|
|
80
|
+
* rejected (the word after the connector has no operator). Case-insensitive.
|
|
81
|
+
*/
|
|
82
|
+
const INLINE_CONJUNCTION = new RegExp(`\\s(and|or)\\s+${FIELD_PATTERN}\\s*(!=|<=|>=|!~|=|<|>|~|:(empty|notempty)\\b)`, "i");
|
|
34
83
|
export function parseWhere(expr) {
|
|
84
|
+
if (INLINE_CONJUNCTION.test(expr)) {
|
|
85
|
+
throw new Error(`Cannot parse "${expr}": looks like multiple conditions in one clause. ` +
|
|
86
|
+
`AND is implicit — use a SEPARATE --where for each condition ` +
|
|
87
|
+
`(e.g. --where stage!=closedwon --where stage!=closedlost). ` +
|
|
88
|
+
`For OR within one field, use value1|value2 (e.g. --where stage=closedwon|closedlost).`);
|
|
89
|
+
}
|
|
35
90
|
const empty = expr.match(new RegExp(`^(${FIELD_PATTERN}):(empty|notempty)$`));
|
|
36
91
|
if (empty)
|
|
37
92
|
return { field: empty[1], op: empty[2], raw: expr };
|
|
@@ -41,13 +96,44 @@ export function parseWhere(expr) {
|
|
|
41
96
|
const notContains = expr.match(new RegExp(`^(${FIELD_PATTERN})!~(.*)$`));
|
|
42
97
|
if (notContains)
|
|
43
98
|
return { field: notContains[1], op: "notcontains", value: notContains[2], raw: expr };
|
|
99
|
+
// Comparison operators: two-char tokens (`<=`/`>=`) MUST be tried before the
|
|
100
|
+
// one-char ones (`<`/`>`) so a prefix isn't stolen (`field<=5` → lte, not
|
|
101
|
+
// lt + stray `=`). They slot in after `!=`/`!~` and before `~`/`=` (different
|
|
102
|
+
// lead chars, no collision). The RHS is validated at parse time: an
|
|
103
|
+
// un-comparable literal (neither today, number, nor date) is a static
|
|
104
|
+
// authoring error and throws here, so a typo never silently matches nothing.
|
|
105
|
+
const lte = expr.match(new RegExp(`^(${FIELD_PATTERN})<=(.*)$`));
|
|
106
|
+
if (lte)
|
|
107
|
+
return assertComparable({ field: lte[1], op: "lte", value: lte[2], raw: expr });
|
|
108
|
+
const gte = expr.match(new RegExp(`^(${FIELD_PATTERN})>=(.*)$`));
|
|
109
|
+
if (gte)
|
|
110
|
+
return assertComparable({ field: gte[1], op: "gte", value: gte[2], raw: expr });
|
|
111
|
+
const lt = expr.match(new RegExp(`^(${FIELD_PATTERN})<(.*)$`));
|
|
112
|
+
if (lt)
|
|
113
|
+
return assertComparable({ field: lt[1], op: "lt", value: lt[2], raw: expr });
|
|
114
|
+
const gt = expr.match(new RegExp(`^(${FIELD_PATTERN})>(.*)$`));
|
|
115
|
+
if (gt)
|
|
116
|
+
return assertComparable({ field: gt[1], op: "gt", value: gt[2], raw: expr });
|
|
44
117
|
const contains = expr.match(new RegExp(`^(${FIELD_PATTERN})~(.*)$`));
|
|
45
118
|
if (contains)
|
|
46
119
|
return { field: contains[1], op: "contains", value: contains[2], raw: expr };
|
|
47
120
|
const eq = expr.match(new RegExp(`^(${FIELD_PATTERN})=(.*)$`));
|
|
48
121
|
if (eq)
|
|
49
122
|
return { field: eq[1], op: "eq", value: eq[2], raw: expr };
|
|
50
|
-
throw new Error(`Cannot parse --where "${expr}". Use field=value, field!=value, field~substring, field!~substring, field:empty, or field
|
|
123
|
+
throw new Error(`Cannot parse --where "${expr}". Use field=value, field!=value, field~substring, field!~substring, field:empty, field:notempty, field<value, field>value, field<=value, or field>=value.`);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Parse-time validity for a comparison clause: every `|`-alternative of the
|
|
127
|
+
* RHS must be a comparable literal (today / number / date). An un-comparable
|
|
128
|
+
* value (e.g. `amount>`, `closeDate<garbage`) throws here — a comparison that
|
|
129
|
+
* can never coerce is an authoring error, not a silent empty match.
|
|
130
|
+
*/
|
|
131
|
+
function assertComparable(clause) {
|
|
132
|
+
const alternatives = (clause.value ?? "").split("|");
|
|
133
|
+
if (alternatives.some((a) => !isComparableValue(a))) {
|
|
134
|
+
throw new Error(`Cannot parse --where "${clause.raw}": the comparison value must be a number, an ISO date, or "today" (got "${clause.value ?? ""}").`);
|
|
135
|
+
}
|
|
136
|
+
return clause;
|
|
51
137
|
}
|
|
52
138
|
function fieldValue(view, field) {
|
|
53
139
|
const value = view[field];
|
|
@@ -55,7 +141,57 @@ function fieldValue(view, field) {
|
|
|
55
141
|
return "";
|
|
56
142
|
return String(value);
|
|
57
143
|
}
|
|
58
|
-
|
|
144
|
+
/** System date as ISO yyyy-mm-dd — the default `today` when none is threaded. */
|
|
145
|
+
function systemToday() {
|
|
146
|
+
return new Date().toISOString().slice(0, 10);
|
|
147
|
+
}
|
|
148
|
+
/** A non-empty trimmed string that parses as a finite JS number. */
|
|
149
|
+
function isPlainNumber(value) {
|
|
150
|
+
const trimmed = value.trim();
|
|
151
|
+
return trimmed !== "" && Number.isFinite(Number(trimmed));
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* "Looks like a date": Date.parse-finite AND not a plain number. The guard
|
|
155
|
+
* against plain numbers is essential — `Date.parse("5000")` is finite (it reads
|
|
156
|
+
* as the YEAR 5000), so without it `closeDate<5000` and `amount>2026-04-30`
|
|
157
|
+
* would silently take the date branch and produce wrong, broad matches. Pure
|
|
158
|
+
* numbers are numbers; only date-SHAPED strings (with `-`/`/`/`T`, like the
|
|
159
|
+
* canonical ISO `2026-04-30`) are dates. Mirrors the ISO-date assumption the
|
|
160
|
+
* past-close-date rule already makes (rules.ts compareDate).
|
|
161
|
+
*/
|
|
162
|
+
function isDateLike(value) {
|
|
163
|
+
// Require an ISO-ish date separator (`-`/`/`/`T`/`:`) in addition to a finite
|
|
164
|
+
// Date.parse: excludes plain numbers (`Date.parse("5000")` = year 5000) AND
|
|
165
|
+
// locale-fragile prose (`Date.parse("July 4")` is finite but is not a date we
|
|
166
|
+
// want to accept). Canonical dates are ISO (`2026-04-30`), so this never
|
|
167
|
+
// rejects real data; it only rejects authoring typos.
|
|
168
|
+
return !isPlainNumber(value) && /[-/T:]/.test(value) && Number.isFinite(Date.parse(value));
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Type-aware comparison for a single (un-lowercased) field value against one
|
|
172
|
+
* resolved RHS alternative. Date-first, then numeric; any non-coercible side
|
|
173
|
+
* (including empty/unset, type mismatch) yields null (caller → no match). Never
|
|
174
|
+
* throws. `today` is already resolved to a date string by the caller.
|
|
175
|
+
* Returns a<0 / 0 / a>0 in the chosen type's space, or null when not comparable.
|
|
176
|
+
*/
|
|
177
|
+
function compareValues(rawField, rhs) {
|
|
178
|
+
if (rawField.trim() === "")
|
|
179
|
+
return null; // empty/unset is "not comparable", never epoch-0
|
|
180
|
+
// Date comparison when BOTH sides are date-shaped (mirrors compareDate in rules.ts).
|
|
181
|
+
if (isDateLike(rawField) && isDateLike(rhs)) {
|
|
182
|
+
return Date.parse(rawField) - Date.parse(rhs);
|
|
183
|
+
}
|
|
184
|
+
// Numeric comparison when BOTH sides are plain finite numbers.
|
|
185
|
+
if (isPlainNumber(rawField) && isPlainNumber(rhs)) {
|
|
186
|
+
return Number(rawField) - Number(rhs);
|
|
187
|
+
}
|
|
188
|
+
return null; // type mismatch / non-parseable → no match (caller returns false)
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* `today` is threaded from the policy/--today date (default: system date), so
|
|
192
|
+
* plan-time and apply-time re-verification resolve the literal identically.
|
|
193
|
+
*/
|
|
194
|
+
function matches(view, clause, today) {
|
|
59
195
|
const actual = fieldValue(view, clause.field).toLowerCase();
|
|
60
196
|
// `|` alternation: eq/contains match ANY alternative; neq/notcontains
|
|
61
197
|
// must hold against ALL alternatives.
|
|
@@ -73,6 +209,35 @@ function matches(view, clause) {
|
|
|
73
209
|
return actual === "";
|
|
74
210
|
case "notempty":
|
|
75
211
|
return actual !== "";
|
|
212
|
+
// Comparison ops are type-aware and use the RAW (un-lowercased) field
|
|
213
|
+
// value, not `actual` — lowercasing an ISO date still parses, but the
|
|
214
|
+
// helper must read from fieldValue() directly (see §6.3 risk 2). `today`
|
|
215
|
+
// resolves to the threaded policy date. Alternation is OR for all four
|
|
216
|
+
// (`some`): each alternative is coerced independently; one that fails
|
|
217
|
+
// coercion contributes false.
|
|
218
|
+
case "lt":
|
|
219
|
+
case "gt":
|
|
220
|
+
case "lte":
|
|
221
|
+
case "gte": {
|
|
222
|
+
const rawField = fieldValue(view, clause.field);
|
|
223
|
+
const rawAlternatives = (clause.value ?? "").split("|");
|
|
224
|
+
return rawAlternatives.some((a) => {
|
|
225
|
+
const rhs = a.trim() === "today" ? today : a;
|
|
226
|
+
const cmp = compareValues(rawField, rhs);
|
|
227
|
+
if (cmp === null)
|
|
228
|
+
return false;
|
|
229
|
+
switch (clause.op) {
|
|
230
|
+
case "lt":
|
|
231
|
+
return cmp < 0;
|
|
232
|
+
case "gt":
|
|
233
|
+
return cmp > 0;
|
|
234
|
+
case "lte":
|
|
235
|
+
return cmp <= 0;
|
|
236
|
+
case "gte":
|
|
237
|
+
return cmp >= 0;
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
76
241
|
}
|
|
77
242
|
}
|
|
78
243
|
const COLLECTIONS = {
|
|
@@ -169,17 +334,22 @@ export function parseGuard(raw) {
|
|
|
169
334
|
assertValidFields(objectType, where.map(parseWhere), "--guard");
|
|
170
335
|
return { objectType: objectType, where, expect: expect, description: raw };
|
|
171
336
|
}
|
|
172
|
-
/**
|
|
173
|
-
|
|
337
|
+
/**
|
|
338
|
+
* Ids of records matching a filter — used for apply-time filter
|
|
339
|
+
* re-verification. `today` resolves the comparison `today` literal; apply-time
|
|
340
|
+
* callers pass the value the plan was built with (stored on plan.filter.today)
|
|
341
|
+
* so re-verification uses the SAME today, defaulting to the system date.
|
|
342
|
+
*/
|
|
343
|
+
export function eligibleIds(snapshot, objectType, where, today = systemToday()) {
|
|
174
344
|
const clauses = where.map(parseWhere);
|
|
175
345
|
const views = buildViews(snapshot, objectType);
|
|
176
|
-
return new Set(views.filter(({ view }) => clauses.every((c) => matches(view, c))).map(({ record }) => String(record.id)));
|
|
346
|
+
return new Set(views.filter(({ view }) => clauses.every((c) => matches(view, c, today))).map(({ record }) => String(record.id)));
|
|
177
347
|
}
|
|
178
348
|
/** Evaluate a plan guard against a snapshot. Returns null when satisfied, else a failure detail. */
|
|
179
|
-
export function evaluateGuard(snapshot, guard) {
|
|
349
|
+
export function evaluateGuard(snapshot, guard, today = systemToday()) {
|
|
180
350
|
const clauses = guard.where.map(parseWhere);
|
|
181
351
|
const views = buildViews(snapshot, guard.objectType);
|
|
182
|
-
const matchCount = views.filter(({ view }) => clauses.every((c) => matches(view, c))).length;
|
|
352
|
+
const matchCount = views.filter(({ view }) => clauses.every((c) => matches(view, c, today))).length;
|
|
183
353
|
const ok = guard.expect === "none" ? matchCount === 0 : matchCount > 0;
|
|
184
354
|
if (ok)
|
|
185
355
|
return null;
|
|
@@ -187,6 +357,10 @@ export function evaluateGuard(snapshot, guard) {
|
|
|
187
357
|
}
|
|
188
358
|
export function buildBulkUpdatePlan(snapshot, options) {
|
|
189
359
|
const maxOperations = options.maxOperations ?? 500;
|
|
360
|
+
// Resolve `today` once: the comparison `today` literal in both --where and
|
|
361
|
+
// --guard binds to this value at plan time, and it is stored on plan.filter
|
|
362
|
+
// so apply-time re-verification (eligibleIds) resolves it identically.
|
|
363
|
+
const today = options.today ?? systemToday();
|
|
190
364
|
if (options.where.length === 0) {
|
|
191
365
|
throw new Error("bulk-update requires at least one --where filter — refusing to build an unscoped mass write.");
|
|
192
366
|
}
|
|
@@ -197,6 +371,10 @@ export function buildBulkUpdatePlan(snapshot, options) {
|
|
|
197
371
|
}
|
|
198
372
|
const clauses = options.where.map(parseWhere);
|
|
199
373
|
assertValidFields(options.objectType, clauses, "--where");
|
|
374
|
+
// Whether any comparison clause references the `today` literal — drives
|
|
375
|
+
// whether the resolved `today` is persisted on plan.filter (see below).
|
|
376
|
+
const isComparisonOp = (op) => op === "lt" || op === "gt" || op === "lte" || op === "gte";
|
|
377
|
+
const referencesToday = clauses.some((c) => isComparisonOp(c.op) && (c.value ?? "").split("|").some((a) => a.trim() === "today"));
|
|
200
378
|
const WRITABLE_BLOCKLIST = new Set(["id", "crmId", "contactCount", "openDealCount", "openDealStages"]);
|
|
201
379
|
// `from:<sourceField>` values resolve per record from the filter view —
|
|
202
380
|
// the source is validated with the same strictness as filters (relational
|
|
@@ -218,7 +396,7 @@ export function buildBulkUpdatePlan(snapshot, options) {
|
|
|
218
396
|
}
|
|
219
397
|
}
|
|
220
398
|
const views = buildViews(snapshot, options.objectType);
|
|
221
|
-
const matched = views.filter(({ view }) => clauses.every((c) => matches(view, c)));
|
|
399
|
+
const matched = views.filter(({ view }) => clauses.every((c) => matches(view, c, today)));
|
|
222
400
|
if (matched.length > maxOperations) {
|
|
223
401
|
throw new Error(`Filter matched ${matched.length} ${COLLECTIONS[options.objectType]} — above the ${maxOperations}-record safety cap. Narrow the --where filter or raise --max-operations explicitly.`);
|
|
224
402
|
}
|
|
@@ -381,10 +559,20 @@ export function buildBulkUpdatePlan(snapshot, options) {
|
|
|
381
559
|
// guards must hold at plan time too — building a plan whose guard already
|
|
382
560
|
// fails is a footgun, surface it immediately
|
|
383
561
|
for (const guard of guards) {
|
|
384
|
-
const failure = evaluateGuard(snapshot, guard);
|
|
562
|
+
const failure = evaluateGuard(snapshot, guard, today);
|
|
385
563
|
if (failure)
|
|
386
564
|
throw new Error(`${failure} The guard already fails against the current snapshot — the plan would never apply.`);
|
|
387
565
|
}
|
|
566
|
+
// A `today` literal inside a --guard (not just --where) must also pin
|
|
567
|
+
// plan.filter.today: connector.ts re-evaluates guards at apply time and,
|
|
568
|
+
// without the persisted date, would resolve `today` to the apply-day system
|
|
569
|
+
// date — silently drifting a fail-closed guard. Persist `today` if EITHER a
|
|
570
|
+
// --where or a --guard clause references the literal.
|
|
571
|
+
const guardReferencesToday = guards.some((g) => g.where.some((raw) => {
|
|
572
|
+
const c = parseWhere(raw);
|
|
573
|
+
return isComparisonOp(c.op) && (c.value ?? "").split("|").some((a) => a.trim() === "today");
|
|
574
|
+
}));
|
|
575
|
+
const persistToday = referencesToday || guardReferencesToday;
|
|
388
576
|
const skippedText = [...skippedBySource.entries()]
|
|
389
577
|
.map(([sourceField, count]) => ` ${count} skipped: empty ${sourceField}.`)
|
|
390
578
|
.join("");
|
|
@@ -397,7 +585,18 @@ export function buildBulkUpdatePlan(snapshot, options) {
|
|
|
397
585
|
summary: `${matched.length} ${COLLECTIONS[options.objectType]} matched (${whereText}); ${operations.length} proposed dry-run operations (${action}).${skippedText}${guards.length > 0 ? ` ${guards.length} apply-time guard(s).` : ""}`,
|
|
398
586
|
findings: [],
|
|
399
587
|
operations,
|
|
400
|
-
|
|
588
|
+
// Persist the resolved `today` ONLY when a filter clause actually
|
|
589
|
+
// references the `today` literal, so apply-time re-verification (eligibleIds
|
|
590
|
+
// in connector.ts) resolves it to the same date the plan was built with —
|
|
591
|
+
// the apply command carries no --today of its own. Plans without `today`
|
|
592
|
+
// (the vast majority — equality filters, reassign, etc.) keep the original
|
|
593
|
+
// `{ objectType, where }` shape unchanged; eligibleIds defaults to the
|
|
594
|
+
// system date for them, which never affects a non-`today` filter.
|
|
595
|
+
filter: {
|
|
596
|
+
objectType: options.objectType,
|
|
597
|
+
where: options.where,
|
|
598
|
+
...(persistToday ? { today } : {}),
|
|
599
|
+
},
|
|
401
600
|
...(guards.length > 0 ? { guards } : {}),
|
|
402
601
|
};
|
|
403
602
|
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { type LlmProvider } from "./llm.ts";
|
|
2
|
+
/**
|
|
3
|
+
* The broker channel carries a long-lived pairing bearer and receives freshly
|
|
4
|
+
* minted live-CRM tokens, so it must be TLS unless it's an explicit localhost
|
|
5
|
+
* dev target. Refuse http:// (and non-http schemes) otherwise — single-quote
|
|
6
|
+
* shell escaping does nothing for a token sent in cleartext.
|
|
7
|
+
*/
|
|
8
|
+
export declare function assertSecureBrokerUrl(raw: string): URL;
|
|
2
9
|
type ProviderDoctorStatus = {
|
|
3
10
|
source: "env" | "stored" | "broker" | "none";
|
|
4
11
|
detail: string;
|
|
@@ -33,7 +40,7 @@ export declare function doctorReport(env?: Record<string, string | undefined>):
|
|
|
33
40
|
llm: {
|
|
34
41
|
configured: boolean;
|
|
35
42
|
provider: LlmProvider;
|
|
36
|
-
source: "
|
|
43
|
+
source: "stored" | "env";
|
|
37
44
|
detail?: undefined;
|
|
38
45
|
} | {
|
|
39
46
|
configured: boolean;
|
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import { generateDemoSnapshot } from "./demo.js";
|
|
|
14
14
|
import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
|
|
15
15
|
import { mergeSnapshots } from "./merge.js";
|
|
16
16
|
import { verifyApprovalDigests } from "./integrity.js";
|
|
17
|
+
import { buildAuditLog, verifyAuditLog } from "./auditLog.js";
|
|
17
18
|
import { createFilePlanStore } from "./planStore.js";
|
|
18
19
|
import { auditReportToHtml, auditReportToMarkdown } from "./report.js";
|
|
19
20
|
import { builtinAuditRules } from "./rules.js";
|
|
@@ -155,6 +156,7 @@ Usage:
|
|
|
155
156
|
fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low] [--include-creates]
|
|
156
157
|
fullstackgtm apply --plan-id <id> --provider <name>
|
|
157
158
|
fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [options]
|
|
159
|
+
fullstackgtm audit-log export [--out <path>] | verify --in <path> tamper-evident apply-run record
|
|
158
160
|
fullstackgtm rules [--json]
|
|
159
161
|
fullstackgtm profiles [--json] list credential profiles
|
|
160
162
|
fullstackgtm doctor [--json] check install, credentials, and next step
|
|
@@ -2027,6 +2029,9 @@ async function bulkUpdateCommand(args) {
|
|
|
2027
2029
|
guard: repeatedOption(rest, "--guard"),
|
|
2028
2030
|
reason: option(rest, "--reason") ?? undefined,
|
|
2029
2031
|
maxOperations: numericOption(rest, "--max-operations"),
|
|
2032
|
+
// --today resolves the comparison `today` literal (e.g. closeDate<today);
|
|
2033
|
+
// defaults to the system date inside buildBulkUpdatePlan when omitted.
|
|
2034
|
+
today: option(rest, "--today") ?? undefined,
|
|
2030
2035
|
});
|
|
2031
2036
|
await emitPlan(plan, rest);
|
|
2032
2037
|
}
|
|
@@ -2281,6 +2286,52 @@ function readSuggestionValues(path, minConfidence, includeCreates) {
|
|
|
2281
2286
|
}
|
|
2282
2287
|
return { overrides, skipped };
|
|
2283
2288
|
}
|
|
2289
|
+
async function auditLogCommand(args) {
|
|
2290
|
+
const [sub, ...rest] = args;
|
|
2291
|
+
if (!sub || sub === "--help" || sub === "-h" || (sub !== "export" && sub !== "verify")) {
|
|
2292
|
+
console.log(`Usage:
|
|
2293
|
+
audit-log export [--out <path>] [--json] hash-chained, signed record of every apply run
|
|
2294
|
+
audit-log verify [--in <path>] re-check an exported log's chain and signature
|
|
2295
|
+
|
|
2296
|
+
export flattens every apply run across all stored plans (this profile) into a
|
|
2297
|
+
tamper-evident chain — each entry carries the prior entry's hash, and the chain
|
|
2298
|
+
head is HMAC-signed with this install's key — so a change-management process can
|
|
2299
|
+
archive one file and later prove it was not edited. verify recomputes the chain
|
|
2300
|
+
and (if the signing key is present) the signature.`);
|
|
2301
|
+
return;
|
|
2302
|
+
}
|
|
2303
|
+
if (sub === "export") {
|
|
2304
|
+
const plans = await createFilePlanStore().list();
|
|
2305
|
+
const log = buildAuditLog(plans, new Date().toISOString());
|
|
2306
|
+
const payload = `${JSON.stringify(log, null, 2)}\n`;
|
|
2307
|
+
const outPath = option(rest, "--out");
|
|
2308
|
+
if (outPath) {
|
|
2309
|
+
writeFileSync(resolve(process.cwd(), outPath), payload);
|
|
2310
|
+
console.log(`Wrote ${outPath}: ${log.entryCount} run(s), chain head ${log.chainHead.slice(0, 12)}${log.signature ? " (signed)" : " (unsigned — no signing key on this install)"}.`);
|
|
2311
|
+
}
|
|
2312
|
+
else if (rest.includes("--json")) {
|
|
2313
|
+
console.log(payload);
|
|
2314
|
+
}
|
|
2315
|
+
else {
|
|
2316
|
+
console.log(`${log.entryCount} apply run(s); chain head ${log.chainHead.slice(0, 12)}${log.signature ? ", signed" : ", unsigned"}. Pass --out <path> to archive, or --json to print.`);
|
|
2317
|
+
}
|
|
2318
|
+
return;
|
|
2319
|
+
}
|
|
2320
|
+
// verify
|
|
2321
|
+
const inPath = option(rest, "--in");
|
|
2322
|
+
if (!inPath)
|
|
2323
|
+
throw new Error("audit-log verify requires --in <exported-log.json>");
|
|
2324
|
+
const log = JSON.parse(readFileSync(resolve(process.cwd(), inPath), "utf8"));
|
|
2325
|
+
const result = verifyAuditLog(log);
|
|
2326
|
+
if (rest.includes("--json")) {
|
|
2327
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2328
|
+
}
|
|
2329
|
+
else {
|
|
2330
|
+
console.log(result.ok ? `OK — ${result.detail}` : `TAMPERED — ${result.detail}`);
|
|
2331
|
+
}
|
|
2332
|
+
if (!result.ok)
|
|
2333
|
+
process.exitCode = 2;
|
|
2334
|
+
}
|
|
2284
2335
|
async function apply(args) {
|
|
2285
2336
|
const provider = option(args, "--provider");
|
|
2286
2337
|
if (!provider)
|
|
@@ -2570,7 +2621,30 @@ function rejectArgvSecret(args, ...flags) {
|
|
|
2570
2621
|
}
|
|
2571
2622
|
}
|
|
2572
2623
|
}
|
|
2624
|
+
/**
|
|
2625
|
+
* The broker channel carries a long-lived pairing bearer and receives freshly
|
|
2626
|
+
* minted live-CRM tokens, so it must be TLS unless it's an explicit localhost
|
|
2627
|
+
* dev target. Refuse http:// (and non-http schemes) otherwise — single-quote
|
|
2628
|
+
* shell escaping does nothing for a token sent in cleartext.
|
|
2629
|
+
*/
|
|
2630
|
+
export function assertSecureBrokerUrl(raw) {
|
|
2631
|
+
let url;
|
|
2632
|
+
try {
|
|
2633
|
+
url = new URL(raw);
|
|
2634
|
+
}
|
|
2635
|
+
catch {
|
|
2636
|
+
throw new Error(`--via must be a full URL (e.g. https://gtm.yourco.com), got "${raw}".`);
|
|
2637
|
+
}
|
|
2638
|
+
const isLocalhost = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]";
|
|
2639
|
+
if (url.protocol === "https:")
|
|
2640
|
+
return url;
|
|
2641
|
+
if (url.protocol === "http:" && isLocalhost)
|
|
2642
|
+
return url; // local dev only
|
|
2643
|
+
throw new Error(`Refusing to pair over ${url.protocol}//${url.host}: the broker exchanges a long-lived token and mints live CRM ` +
|
|
2644
|
+
"credentials, so it must use https (http is allowed only for localhost dev).");
|
|
2645
|
+
}
|
|
2573
2646
|
async function brokerLogin(baseUrl) {
|
|
2647
|
+
const viaUrl = assertSecureBrokerUrl(baseUrl);
|
|
2574
2648
|
const base = baseUrl.replace(/\/$/, "");
|
|
2575
2649
|
const os = await import("node:os");
|
|
2576
2650
|
// Self-reported, shown to the approver so they can recognize this request
|
|
@@ -2592,8 +2666,22 @@ async function brokerLogin(baseUrl) {
|
|
|
2592
2666
|
throw new Error(`Could not start pairing with ${base} (${startResponse.status}). Is this a FullStackGTM deployment?`);
|
|
2593
2667
|
}
|
|
2594
2668
|
const start = await startResponse.json();
|
|
2669
|
+
// Only auto-open a verification URL that belongs to the --via origin the user
|
|
2670
|
+
// typed — a malicious/typo'd deployment cannot redirect the browser elsewhere.
|
|
2671
|
+
let sameOrigin = false;
|
|
2672
|
+
try {
|
|
2673
|
+
sameOrigin = new URL(start.verificationUrl).origin === viaUrl.origin;
|
|
2674
|
+
}
|
|
2675
|
+
catch {
|
|
2676
|
+
sameOrigin = false;
|
|
2677
|
+
}
|
|
2595
2678
|
console.error(`\nPairing code: ${start.userCode}\n\nApprove this CLI ("${requesterLabel}") in your dashboard:\n\n ${start.verificationUrl}\n`);
|
|
2596
|
-
|
|
2679
|
+
if (sameOrigin) {
|
|
2680
|
+
void openInBrowser(start.verificationUrl);
|
|
2681
|
+
}
|
|
2682
|
+
else {
|
|
2683
|
+
console.error(`(Not auto-opening: the verification URL is not on ${viaUrl.origin}. Open it manually only if you trust it.)`);
|
|
2684
|
+
}
|
|
2597
2685
|
const deadline = Date.now() + (start.expiresInSeconds ?? 600) * 1000;
|
|
2598
2686
|
const intervalMs = Math.max(0, (start.intervalSeconds ?? 3) * 1000);
|
|
2599
2687
|
while (Date.now() < deadline) {
|
|
@@ -3053,6 +3141,10 @@ export async function runCli(argv) {
|
|
|
3053
3141
|
await plansCommand(args);
|
|
3054
3142
|
return;
|
|
3055
3143
|
}
|
|
3144
|
+
if (command === "audit-log") {
|
|
3145
|
+
await auditLogCommand(args);
|
|
3146
|
+
return;
|
|
3147
|
+
}
|
|
3056
3148
|
if (command === "apply") {
|
|
3057
3149
|
await apply(args);
|
|
3058
3150
|
return;
|
package/dist/connector.js
CHANGED
|
@@ -117,7 +117,11 @@ export async function applyPatchPlan(connector, plan, options) {
|
|
|
117
117
|
const { evaluateGuard, eligibleIds } = await import("./bulkUpdate.js");
|
|
118
118
|
const liveSnapshot = await connector.fetchSnapshot();
|
|
119
119
|
if (plan.filter) {
|
|
120
|
-
|
|
120
|
+
// Resolve the comparison `today` literal to the date the plan was built
|
|
121
|
+
// with (stored on plan.filter), so apply-time re-verification of a
|
|
122
|
+
// `closeDate<today`-style filter agrees with plan time. eligibleIds
|
|
123
|
+
// defaults to the system date when the plan predates comparison ops.
|
|
124
|
+
const stillEligible = eligibleIds(liveSnapshot, plan.filter.objectType, plan.filter.where, plan.filter.today);
|
|
121
125
|
staleIds.clear();
|
|
122
126
|
for (const operation of plan.operations) {
|
|
123
127
|
if (!stillEligible.has(operation.objectId))
|
|
@@ -135,7 +139,7 @@ export async function applyPatchPlan(connector, plan, options) {
|
|
|
135
139
|
}
|
|
136
140
|
}
|
|
137
141
|
for (const guard of plan.guards ?? []) {
|
|
138
|
-
const failure = evaluateGuard(liveSnapshot, guard);
|
|
142
|
+
const failure = evaluateGuard(liveSnapshot, guard, plan.filter?.today);
|
|
139
143
|
if (failure) {
|
|
140
144
|
guardFailure = failure;
|
|
141
145
|
return;
|
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",
|
package/dist/index.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export { diffFindings, diffSnapshots, diffToMarkdown, type CollectionDiff, type
|
|
|
17
17
|
export { mergeSnapshots, type MergeConflict, type MergeMatch, type MergeReport, type MergeSuggestion, } from "./merge.ts";
|
|
18
18
|
export { createFilePlanStore, type PlanStore, type StoredPlan } from "./planStore.ts";
|
|
19
19
|
export { computeApprovalDigests, loadOrCreateSigningKey, loadSigningKey, signApproval, verifyApprovalDigests, type ApprovalVerification, } from "./integrity.ts";
|
|
20
|
+
export { buildAuditLog, verifyAuditLog, type AuditLogEntry, type AuditLogExport, type AuditLogVerification, } from "./auditLog.ts";
|
|
20
21
|
export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
21
22
|
export { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
|
|
22
23
|
export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, type CrmObjectType, type FieldMappings, } from "./mappings.ts";
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@ export { diffFindings, diffSnapshots, diffToMarkdown, } from "./diff.js";
|
|
|
17
17
|
export { mergeSnapshots, } from "./merge.js";
|
|
18
18
|
export { createFilePlanStore } from "./planStore.js";
|
|
19
19
|
export { computeApprovalDigests, loadOrCreateSigningKey, loadSigningKey, signApproval, verifyApprovalDigests, } from "./integrity.js";
|
|
20
|
+
export { buildAuditLog, verifyAuditLog, } from "./auditLog.js";
|
|
20
21
|
export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
|
|
21
22
|
export { auditReportToHtml, auditReportToMarkdown } from "./report.js";
|
|
22
23
|
export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, } from "./mappings.js";
|