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/src/bulkUpdate.ts 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
@@ -72,40 +85,176 @@ export type BulkUpdateOptions = {
72
85
  reason?: string;
73
86
  /** refuse to build plans larger than this (default 500 operations) */
74
87
  maxOperations?: number;
88
+ /**
89
+ * Date the comparison `today` literal resolves to (ISO yyyy-mm-dd). Set from
90
+ * the policy/--today date at the CLI; defaults to the system date. Stored on
91
+ * plan.filter so apply-time filter re-verification resolves `today`
92
+ * identically to plan time.
93
+ */
94
+ today?: string;
75
95
  };
76
96
 
77
97
  type WhereClause = {
78
98
  field: string;
79
- op: "eq" | "neq" | "contains" | "notcontains" | "empty" | "notempty";
99
+ op: "eq" | "neq" | "contains" | "notcontains" | "empty" | "notempty" | "lt" | "gt" | "lte" | "gte";
80
100
  value?: string;
81
101
  raw: string;
82
102
  };
83
103
 
84
104
  const FIELD_PATTERN = "[a-zA-Z][a-zA-Z0-9_]*(?:\\.[a-zA-Z][a-zA-Z0-9_]*)?";
85
105
 
106
+ /**
107
+ * Is a comparison RHS (a single `|`-alternative) authorable for comparison?
108
+ * Accept `today`, OR a plain number, OR a date-SHAPED string — using the exact
109
+ * same `isPlainNumber`/`isDateLike` predicates the match-time branch uses, so
110
+ * the parse-time authoring check and the runtime branch cannot diverge. The
111
+ * intentional asymmetry: parse-time validates one literal in isolation (EITHER
112
+ * number OR date is fine), whereas match-time's `compareValues` additionally
113
+ * requires the FIELD and the RHS to agree on type (BOTH number, or BOTH date).
114
+ * Requiring a date *shape* here (not bare `Date.parse`-finite) means a prose
115
+ * typo like `July 4` or a stray `<5` fails loudly at parse instead of silently
116
+ * matching nothing. `isDateLike`/`isPlainNumber` are hoisted function decls.
117
+ */
118
+ function isComparableValue(rhs: string): boolean {
119
+ const trimmed = rhs.trim();
120
+ if (trimmed === "today") return true;
121
+ if (isPlainNumber(trimmed)) return true;
122
+ if (isDateLike(trimmed)) return true;
123
+ return false;
124
+ }
125
+
126
+ /**
127
+ * Catch the "multiple conditions crammed into one --where" footgun: a weak
128
+ * agent writes `--where 'stage!=closedwon AND stage!=closedlost'`, which the
129
+ * grammar would otherwise parse as a single neq clause whose VALUE is the
130
+ * literal string `closedwon AND stage!=closedlost` — matching almost nothing
131
+ * (or, with `!=`, almost EVERYTHING), so the gate faithfully applies a
132
+ * wildly-wrong-but-authorized plan.
133
+ *
134
+ * Fire only when a connector ` AND `/` OR ` is followed by a clause-LIKE
135
+ * structure (a field name + an operator), so incidental prose in a value —
136
+ * `name~Procter AND Gamble`, `name~research and development` — is NOT
137
+ * rejected (the word after the connector has no operator). Case-insensitive.
138
+ */
139
+ const INLINE_CONJUNCTION = new RegExp(
140
+ `\\s(and|or)\\s+${FIELD_PATTERN}\\s*(!=|<=|>=|!~|=|<|>|~|:(empty|notempty)\\b)`,
141
+ "i",
142
+ );
143
+
86
144
  export function parseWhere(expr: string): WhereClause {
145
+ if (INLINE_CONJUNCTION.test(expr)) {
146
+ throw new Error(
147
+ `Cannot parse "${expr}": looks like multiple conditions in one clause. ` +
148
+ `AND is implicit — use a SEPARATE --where for each condition ` +
149
+ `(e.g. --where stage!=closedwon --where stage!=closedlost). ` +
150
+ `For OR within one field, use value1|value2 (e.g. --where stage=closedwon|closedlost).`,
151
+ );
152
+ }
87
153
  const empty = expr.match(new RegExp(`^(${FIELD_PATTERN}):(empty|notempty)$`));
88
154
  if (empty) return { field: empty[1], op: empty[2] as "empty" | "notempty", raw: expr };
89
155
  const neq = expr.match(new RegExp(`^(${FIELD_PATTERN})!=(.*)$`));
90
156
  if (neq) return { field: neq[1], op: "neq", value: neq[2], raw: expr };
91
157
  const notContains = expr.match(new RegExp(`^(${FIELD_PATTERN})!~(.*)$`));
92
158
  if (notContains) return { field: notContains[1], op: "notcontains", value: notContains[2], raw: expr };
159
+ // Comparison operators: two-char tokens (`<=`/`>=`) MUST be tried before the
160
+ // one-char ones (`<`/`>`) so a prefix isn't stolen (`field<=5` → lte, not
161
+ // lt + stray `=`). They slot in after `!=`/`!~` and before `~`/`=` (different
162
+ // lead chars, no collision). The RHS is validated at parse time: an
163
+ // un-comparable literal (neither today, number, nor date) is a static
164
+ // authoring error and throws here, so a typo never silently matches nothing.
165
+ const lte = expr.match(new RegExp(`^(${FIELD_PATTERN})<=(.*)$`));
166
+ if (lte) return assertComparable({ field: lte[1], op: "lte", value: lte[2], raw: expr });
167
+ const gte = expr.match(new RegExp(`^(${FIELD_PATTERN})>=(.*)$`));
168
+ if (gte) return assertComparable({ field: gte[1], op: "gte", value: gte[2], raw: expr });
169
+ const lt = expr.match(new RegExp(`^(${FIELD_PATTERN})<(.*)$`));
170
+ if (lt) return assertComparable({ field: lt[1], op: "lt", value: lt[2], raw: expr });
171
+ const gt = expr.match(new RegExp(`^(${FIELD_PATTERN})>(.*)$`));
172
+ if (gt) return assertComparable({ field: gt[1], op: "gt", value: gt[2], raw: expr });
93
173
  const contains = expr.match(new RegExp(`^(${FIELD_PATTERN})~(.*)$`));
94
174
  if (contains) return { field: contains[1], op: "contains", value: contains[2], raw: expr };
95
175
  const eq = expr.match(new RegExp(`^(${FIELD_PATTERN})=(.*)$`));
96
176
  if (eq) return { field: eq[1], op: "eq", value: eq[2], raw: expr };
97
177
  throw new Error(
98
- `Cannot parse --where "${expr}". Use field=value, field!=value, field~substring, field!~substring, field:empty, or field:notempty.`,
178
+ `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.`,
99
179
  );
100
180
  }
101
181
 
182
+ /**
183
+ * Parse-time validity for a comparison clause: every `|`-alternative of the
184
+ * RHS must be a comparable literal (today / number / date). An un-comparable
185
+ * value (e.g. `amount>`, `closeDate<garbage`) throws here — a comparison that
186
+ * can never coerce is an authoring error, not a silent empty match.
187
+ */
188
+ function assertComparable(clause: WhereClause): WhereClause {
189
+ const alternatives = (clause.value ?? "").split("|");
190
+ if (alternatives.some((a) => !isComparableValue(a))) {
191
+ throw new Error(
192
+ `Cannot parse --where "${clause.raw}": the comparison value must be a number, an ISO date, or "today" (got "${clause.value ?? ""}").`,
193
+ );
194
+ }
195
+ return clause;
196
+ }
197
+
102
198
  function fieldValue(view: Record<string, unknown>, field: string): string {
103
199
  const value = view[field];
104
200
  if (value === undefined || value === null) return "";
105
201
  return String(value);
106
202
  }
107
203
 
108
- function matches(view: Record<string, unknown>, clause: WhereClause): boolean {
204
+ /** System date as ISO yyyy-mm-dd the default `today` when none is threaded. */
205
+ function systemToday(): string {
206
+ return new Date().toISOString().slice(0, 10);
207
+ }
208
+
209
+ /** A non-empty trimmed string that parses as a finite JS number. */
210
+ function isPlainNumber(value: string): boolean {
211
+ const trimmed = value.trim();
212
+ return trimmed !== "" && Number.isFinite(Number(trimmed));
213
+ }
214
+
215
+ /**
216
+ * "Looks like a date": Date.parse-finite AND not a plain number. The guard
217
+ * against plain numbers is essential — `Date.parse("5000")` is finite (it reads
218
+ * as the YEAR 5000), so without it `closeDate<5000` and `amount>2026-04-30`
219
+ * would silently take the date branch and produce wrong, broad matches. Pure
220
+ * numbers are numbers; only date-SHAPED strings (with `-`/`/`/`T`, like the
221
+ * canonical ISO `2026-04-30`) are dates. Mirrors the ISO-date assumption the
222
+ * past-close-date rule already makes (rules.ts compareDate).
223
+ */
224
+ function isDateLike(value: string): boolean {
225
+ // Require an ISO-ish date separator (`-`/`/`/`T`/`:`) in addition to a finite
226
+ // Date.parse: excludes plain numbers (`Date.parse("5000")` = year 5000) AND
227
+ // locale-fragile prose (`Date.parse("July 4")` is finite but is not a date we
228
+ // want to accept). Canonical dates are ISO (`2026-04-30`), so this never
229
+ // rejects real data; it only rejects authoring typos.
230
+ return !isPlainNumber(value) && /[-/T:]/.test(value) && Number.isFinite(Date.parse(value));
231
+ }
232
+
233
+ /**
234
+ * Type-aware comparison for a single (un-lowercased) field value against one
235
+ * resolved RHS alternative. Date-first, then numeric; any non-coercible side
236
+ * (including empty/unset, type mismatch) yields null (caller → no match). Never
237
+ * throws. `today` is already resolved to a date string by the caller.
238
+ * Returns a<0 / 0 / a>0 in the chosen type's space, or null when not comparable.
239
+ */
240
+ function compareValues(rawField: string, rhs: string): number | null {
241
+ if (rawField.trim() === "") return null; // empty/unset is "not comparable", never epoch-0
242
+ // Date comparison when BOTH sides are date-shaped (mirrors compareDate in rules.ts).
243
+ if (isDateLike(rawField) && isDateLike(rhs)) {
244
+ return Date.parse(rawField) - Date.parse(rhs);
245
+ }
246
+ // Numeric comparison when BOTH sides are plain finite numbers.
247
+ if (isPlainNumber(rawField) && isPlainNumber(rhs)) {
248
+ return Number(rawField) - Number(rhs);
249
+ }
250
+ return null; // type mismatch / non-parseable → no match (caller returns false)
251
+ }
252
+
253
+ /**
254
+ * `today` is threaded from the policy/--today date (default: system date), so
255
+ * plan-time and apply-time re-verification resolve the literal identically.
256
+ */
257
+ function matches(view: Record<string, unknown>, clause: WhereClause, today: string): boolean {
109
258
  const actual = fieldValue(view, clause.field).toLowerCase();
110
259
  // `|` alternation: eq/contains match ANY alternative; neq/notcontains
111
260
  // must hold against ALL alternatives.
@@ -123,6 +272,34 @@ function matches(view: Record<string, unknown>, clause: WhereClause): boolean {
123
272
  return actual === "";
124
273
  case "notempty":
125
274
  return actual !== "";
275
+ // Comparison ops are type-aware and use the RAW (un-lowercased) field
276
+ // value, not `actual` — lowercasing an ISO date still parses, but the
277
+ // helper must read from fieldValue() directly (see §6.3 risk 2). `today`
278
+ // resolves to the threaded policy date. Alternation is OR for all four
279
+ // (`some`): each alternative is coerced independently; one that fails
280
+ // coercion contributes false.
281
+ case "lt":
282
+ case "gt":
283
+ case "lte":
284
+ case "gte": {
285
+ const rawField = fieldValue(view, clause.field);
286
+ const rawAlternatives = (clause.value ?? "").split("|");
287
+ return rawAlternatives.some((a) => {
288
+ const rhs = a.trim() === "today" ? today : a;
289
+ const cmp = compareValues(rawField, rhs);
290
+ if (cmp === null) return false;
291
+ switch (clause.op) {
292
+ case "lt":
293
+ return cmp < 0;
294
+ case "gt":
295
+ return cmp > 0;
296
+ case "lte":
297
+ return cmp <= 0;
298
+ case "gte":
299
+ return cmp >= 0;
300
+ }
301
+ });
302
+ }
126
303
  }
127
304
  }
128
305
 
@@ -232,24 +409,30 @@ export function parseGuard(raw: string): PlanGuard {
232
409
  return { objectType: objectType as PlanGuard["objectType"], where, expect: expect as PlanGuard["expect"], description: raw };
233
410
  }
234
411
 
235
- /** Ids of records matching a filter — used for apply-time filter re-verification. */
412
+ /**
413
+ * Ids of records matching a filter — used for apply-time filter
414
+ * re-verification. `today` resolves the comparison `today` literal; apply-time
415
+ * callers pass the value the plan was built with (stored on plan.filter.today)
416
+ * so re-verification uses the SAME today, defaulting to the system date.
417
+ */
236
418
  export function eligibleIds(
237
419
  snapshot: CanonicalGtmSnapshot,
238
420
  objectType: BulkUpdateOptions["objectType"],
239
421
  where: string[],
422
+ today: string = systemToday(),
240
423
  ): Set<string> {
241
424
  const clauses = where.map(parseWhere);
242
425
  const views = buildViews(snapshot, objectType);
243
426
  return new Set(
244
- views.filter(({ view }) => clauses.every((c) => matches(view, c))).map(({ record }) => String(record.id)),
427
+ views.filter(({ view }) => clauses.every((c) => matches(view, c, today))).map(({ record }) => String(record.id)),
245
428
  );
246
429
  }
247
430
 
248
431
  /** Evaluate a plan guard against a snapshot. Returns null when satisfied, else a failure detail. */
249
- export function evaluateGuard(snapshot: CanonicalGtmSnapshot, guard: PlanGuard): string | null {
432
+ export function evaluateGuard(snapshot: CanonicalGtmSnapshot, guard: PlanGuard, today: string = systemToday()): string | null {
250
433
  const clauses = guard.where.map(parseWhere);
251
434
  const views = buildViews(snapshot, guard.objectType);
252
- const matchCount = views.filter(({ view }) => clauses.every((c) => matches(view, c))).length;
435
+ const matchCount = views.filter(({ view }) => clauses.every((c) => matches(view, c, today))).length;
253
436
  const ok = guard.expect === "none" ? matchCount === 0 : matchCount > 0;
254
437
  if (ok) return null;
255
438
  return `Guard failed: expected ${guard.expect === "none" ? "no" : "at least one"} ${guard.objectType}(s) matching [${guard.where.join(" AND ")}], found ${matchCount}.`;
@@ -260,6 +443,10 @@ export function buildBulkUpdatePlan(
260
443
  options: BulkUpdateOptions,
261
444
  ): PatchPlan {
262
445
  const maxOperations = options.maxOperations ?? 500;
446
+ // Resolve `today` once: the comparison `today` literal in both --where and
447
+ // --guard binds to this value at plan time, and it is stored on plan.filter
448
+ // so apply-time re-verification (eligibleIds) resolves it identically.
449
+ const today = options.today ?? systemToday();
263
450
  if (options.where.length === 0) {
264
451
  throw new Error(
265
452
  "bulk-update requires at least one --where filter — refusing to build an unscoped mass write.",
@@ -273,6 +460,12 @@ export function buildBulkUpdatePlan(
273
460
 
274
461
  const clauses = options.where.map(parseWhere);
275
462
  assertValidFields(options.objectType, clauses, "--where");
463
+ // Whether any comparison clause references the `today` literal — drives
464
+ // whether the resolved `today` is persisted on plan.filter (see below).
465
+ const isComparisonOp = (op: WhereClause["op"]) => op === "lt" || op === "gt" || op === "lte" || op === "gte";
466
+ const referencesToday = clauses.some(
467
+ (c) => isComparisonOp(c.op) && (c.value ?? "").split("|").some((a) => a.trim() === "today"),
468
+ );
276
469
  const WRITABLE_BLOCKLIST = new Set(["id", "crmId", "contactCount", "openDealCount", "openDealStages"]);
277
470
  // `from:<sourceField>` values resolve per record from the filter view —
278
471
  // the source is validated with the same strictness as filters (relational
@@ -295,7 +488,7 @@ export function buildBulkUpdatePlan(
295
488
  }
296
489
  }
297
490
  const views = buildViews(snapshot, options.objectType);
298
- const matched = views.filter(({ view }) => clauses.every((c) => matches(view, c)));
491
+ const matched = views.filter(({ view }) => clauses.every((c) => matches(view, c, today)));
299
492
  if (matched.length > maxOperations) {
300
493
  throw new Error(
301
494
  `Filter matched ${matched.length} ${COLLECTIONS[options.objectType]} — above the ${maxOperations}-record safety cap. Narrow the --where filter or raise --max-operations explicitly.`,
@@ -461,9 +654,21 @@ export function buildBulkUpdatePlan(
461
654
  // guards must hold at plan time too — building a plan whose guard already
462
655
  // fails is a footgun, surface it immediately
463
656
  for (const guard of guards) {
464
- const failure = evaluateGuard(snapshot, guard);
657
+ const failure = evaluateGuard(snapshot, guard, today);
465
658
  if (failure) throw new Error(`${failure} The guard already fails against the current snapshot — the plan would never apply.`);
466
659
  }
660
+ // A `today` literal inside a --guard (not just --where) must also pin
661
+ // plan.filter.today: connector.ts re-evaluates guards at apply time and,
662
+ // without the persisted date, would resolve `today` to the apply-day system
663
+ // date — silently drifting a fail-closed guard. Persist `today` if EITHER a
664
+ // --where or a --guard clause references the literal.
665
+ const guardReferencesToday = guards.some((g) =>
666
+ g.where.some((raw) => {
667
+ const c = parseWhere(raw);
668
+ return isComparisonOp(c.op) && (c.value ?? "").split("|").some((a) => a.trim() === "today");
669
+ }),
670
+ );
671
+ const persistToday = referencesToday || guardReferencesToday;
467
672
 
468
673
  const skippedText = [...skippedBySource.entries()]
469
674
  .map(([sourceField, count]) => ` ${count} skipped: empty ${sourceField}.`)
@@ -477,7 +682,18 @@ export function buildBulkUpdatePlan(
477
682
  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).` : ""}`,
478
683
  findings: [],
479
684
  operations,
480
- filter: { objectType: options.objectType, where: options.where },
685
+ // Persist the resolved `today` ONLY when a filter clause actually
686
+ // references the `today` literal, so apply-time re-verification (eligibleIds
687
+ // in connector.ts) resolves it to the same date the plan was built with —
688
+ // the apply command carries no --today of its own. Plans without `today`
689
+ // (the vast majority — equality filters, reassign, etc.) keep the original
690
+ // `{ objectType, where }` shape unchanged; eligibleIds defaults to the
691
+ // system date for them, which never affects a non-`today` filter.
692
+ filter: {
693
+ objectType: options.objectType,
694
+ where: options.where,
695
+ ...(persistToday ? { today } : {}),
696
+ },
481
697
  ...(guards.length > 0 ? { guards } : {}),
482
698
  };
483
699
  }
package/src/cli.ts CHANGED
@@ -35,6 +35,7 @@ import { generateDemoSnapshot } from "./demo.ts";
35
35
  import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
36
36
  import { mergeSnapshots } from "./merge.ts";
37
37
  import { verifyApprovalDigests } from "./integrity.ts";
38
+ import { buildAuditLog, verifyAuditLog } from "./auditLog.ts";
38
39
  import { createFilePlanStore } from "./planStore.ts";
39
40
  import { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
40
41
  import { builtinAuditRules } from "./rules.ts";
@@ -254,6 +255,7 @@ Usage:
254
255
  fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low] [--include-creates]
255
256
  fullstackgtm apply --plan-id <id> --provider <name>
256
257
  fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [options]
258
+ fullstackgtm audit-log export [--out <path>] | verify --in <path> tamper-evident apply-run record
257
259
  fullstackgtm rules [--json]
258
260
  fullstackgtm profiles [--json] list credential profiles
259
261
  fullstackgtm doctor [--json] check install, credentials, and next step
@@ -2280,6 +2282,9 @@ async function bulkUpdateCommand(args: string[]) {
2280
2282
  guard: repeatedOption(rest, "--guard"),
2281
2283
  reason: option(rest, "--reason") ?? undefined,
2282
2284
  maxOperations: numericOption(rest, "--max-operations"),
2285
+ // --today resolves the comparison `today` literal (e.g. closeDate<today);
2286
+ // defaults to the system date inside buildBulkUpdatePlan when omitted.
2287
+ today: option(rest, "--today") ?? undefined,
2283
2288
  });
2284
2289
  await emitPlan(plan, rest);
2285
2290
  }
@@ -2558,6 +2563,50 @@ function readSuggestionValues(path: string, minConfidence: string, includeCreate
2558
2563
  return { overrides, skipped };
2559
2564
  }
2560
2565
 
2566
+ async function auditLogCommand(args: string[]) {
2567
+ const [sub, ...rest] = args;
2568
+ if (!sub || sub === "--help" || sub === "-h" || (sub !== "export" && sub !== "verify")) {
2569
+ console.log(`Usage:
2570
+ audit-log export [--out <path>] [--json] hash-chained, signed record of every apply run
2571
+ audit-log verify [--in <path>] re-check an exported log's chain and signature
2572
+
2573
+ export flattens every apply run across all stored plans (this profile) into a
2574
+ tamper-evident chain — each entry carries the prior entry's hash, and the chain
2575
+ head is HMAC-signed with this install's key — so a change-management process can
2576
+ archive one file and later prove it was not edited. verify recomputes the chain
2577
+ and (if the signing key is present) the signature.`);
2578
+ return;
2579
+ }
2580
+
2581
+ if (sub === "export") {
2582
+ const plans = await createFilePlanStore().list();
2583
+ const log = buildAuditLog(plans, new Date().toISOString());
2584
+ const payload = `${JSON.stringify(log, null, 2)}\n`;
2585
+ const outPath = option(rest, "--out");
2586
+ if (outPath) {
2587
+ writeFileSync(resolve(process.cwd(), outPath), payload);
2588
+ 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)"}.`);
2589
+ } else if (rest.includes("--json")) {
2590
+ console.log(payload);
2591
+ } else {
2592
+ 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.`);
2593
+ }
2594
+ return;
2595
+ }
2596
+
2597
+ // verify
2598
+ const inPath = option(rest, "--in");
2599
+ if (!inPath) throw new Error("audit-log verify requires --in <exported-log.json>");
2600
+ const log = JSON.parse(readFileSync(resolve(process.cwd(), inPath), "utf8")) as Parameters<typeof verifyAuditLog>[0];
2601
+ const result = verifyAuditLog(log);
2602
+ if (rest.includes("--json")) {
2603
+ console.log(JSON.stringify(result, null, 2));
2604
+ } else {
2605
+ console.log(result.ok ? `OK — ${result.detail}` : `TAMPERED — ${result.detail}`);
2606
+ }
2607
+ if (!result.ok) process.exitCode = 2;
2608
+ }
2609
+
2561
2610
  async function apply(args: string[]) {
2562
2611
  const provider = option(args, "--provider");
2563
2612
  if (!provider) throw new Error("apply requires --provider <name>");
@@ -2899,7 +2948,31 @@ function rejectArgvSecret(args: string[], ...flags: string[]) {
2899
2948
  }
2900
2949
  }
2901
2950
 
2951
+ /**
2952
+ * The broker channel carries a long-lived pairing bearer and receives freshly
2953
+ * minted live-CRM tokens, so it must be TLS unless it's an explicit localhost
2954
+ * dev target. Refuse http:// (and non-http schemes) otherwise — single-quote
2955
+ * shell escaping does nothing for a token sent in cleartext.
2956
+ */
2957
+ export function assertSecureBrokerUrl(raw: string): URL {
2958
+ let url: URL;
2959
+ try {
2960
+ url = new URL(raw);
2961
+ } catch {
2962
+ throw new Error(`--via must be a full URL (e.g. https://gtm.yourco.com), got "${raw}".`);
2963
+ }
2964
+ const isLocalhost =
2965
+ url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]";
2966
+ if (url.protocol === "https:") return url;
2967
+ if (url.protocol === "http:" && isLocalhost) return url; // local dev only
2968
+ throw new Error(
2969
+ `Refusing to pair over ${url.protocol}//${url.host}: the broker exchanges a long-lived token and mints live CRM ` +
2970
+ "credentials, so it must use https (http is allowed only for localhost dev).",
2971
+ );
2972
+ }
2973
+
2902
2974
  async function brokerLogin(baseUrl: string) {
2975
+ const viaUrl = assertSecureBrokerUrl(baseUrl);
2903
2976
  const base = baseUrl.replace(/\/$/, "");
2904
2977
  const os = await import("node:os");
2905
2978
  // Self-reported, shown to the approver so they can recognize this request
@@ -2922,10 +2995,22 @@ async function brokerLogin(baseUrl: string) {
2922
2995
  );
2923
2996
  }
2924
2997
  const start = await startResponse.json();
2998
+ // Only auto-open a verification URL that belongs to the --via origin the user
2999
+ // typed — a malicious/typo'd deployment cannot redirect the browser elsewhere.
3000
+ let sameOrigin = false;
3001
+ try {
3002
+ sameOrigin = new URL(start.verificationUrl).origin === viaUrl.origin;
3003
+ } catch {
3004
+ sameOrigin = false;
3005
+ }
2925
3006
  console.error(
2926
3007
  `\nPairing code: ${start.userCode}\n\nApprove this CLI ("${requesterLabel}") in your dashboard:\n\n ${start.verificationUrl}\n`,
2927
3008
  );
2928
- void openInBrowser(start.verificationUrl);
3009
+ if (sameOrigin) {
3010
+ void openInBrowser(start.verificationUrl);
3011
+ } else {
3012
+ console.error(`(Not auto-opening: the verification URL is not on ${viaUrl.origin}. Open it manually only if you trust it.)`);
3013
+ }
2929
3014
 
2930
3015
  const deadline = Date.now() + (start.expiresInSeconds ?? 600) * 1000;
2931
3016
  const intervalMs = Math.max(0, (start.intervalSeconds ?? 3) * 1000);
@@ -3416,6 +3501,10 @@ export async function runCli(argv: string[]) {
3416
3501
  await plansCommand(args);
3417
3502
  return;
3418
3503
  }
3504
+ if (command === "audit-log") {
3505
+ await auditLogCommand(args);
3506
+ return;
3507
+ }
3419
3508
  if (command === "apply") {
3420
3509
  await apply(args);
3421
3510
  return;
package/src/connector.ts CHANGED
@@ -170,7 +170,11 @@ export async function applyPatchPlan(
170
170
  const { evaluateGuard, eligibleIds } = await import("./bulkUpdate.ts");
171
171
  const liveSnapshot = await connector.fetchSnapshot!();
172
172
  if (plan.filter) {
173
- const stillEligible = eligibleIds(liveSnapshot, plan.filter.objectType, plan.filter.where);
173
+ // Resolve the comparison `today` literal to the date the plan was built
174
+ // with (stored on plan.filter), so apply-time re-verification of a
175
+ // `closeDate<today`-style filter agrees with plan time. eligibleIds
176
+ // defaults to the system date when the plan predates comparison ops.
177
+ const stillEligible = eligibleIds(liveSnapshot, plan.filter.objectType, plan.filter.where, plan.filter.today);
174
178
  staleIds.clear();
175
179
  for (const operation of plan.operations) {
176
180
  if (!stillEligible.has(operation.objectId)) staleIds.add(operation.objectId);
@@ -185,7 +189,7 @@ export async function applyPatchPlan(
185
189
  }
186
190
  }
187
191
  for (const guard of plan.guards ?? []) {
188
- const failure = evaluateGuard(liveSnapshot, guard);
192
+ const failure = evaluateGuard(liveSnapshot, guard, plan.filter?.today);
189
193
  if (failure) {
190
194
  guardFailure = failure;
191
195
  return;
@@ -8,10 +8,12 @@ import {
8
8
  unlinkSync,
9
9
  writeFileSync,
10
10
  } from "node:fs";
11
+ import { createHash } from "node:crypto";
11
12
  import { homedir } from "node:os";
12
13
  import { join } from "node:path";
13
14
  import { refreshHubspotToken } from "./connectors/hubspotAuth.ts";
14
15
  import { refreshSalesforceToken } from "./connectors/salesforceAuth.ts";
16
+ import { detectKeychainBackend } from "./keychain.ts";
15
17
 
16
18
  /**
17
19
  * Local CLI credential store: ~/.fullstackgtm/credentials.json (0600), or
@@ -166,19 +168,71 @@ function enforceCredentialFileMode(path: string): void {
166
168
  }
167
169
  }
168
170
 
171
+ /**
172
+ * Persistence backend for the credential blob: the OS keychain when
173
+ * FSGTM_KEYCHAIN=1 and one is available, otherwise the 0600 file. The keychain
174
+ * account is derived from the credential file path so distinct homes/profiles
175
+ * never collide in the machine-wide store.
176
+ */
177
+ function activeKeychain(): { account: string; backend: NonNullable<ReturnType<typeof detectKeychainBackend>> } | null {
178
+ if (process.env.FSGTM_KEYCHAIN !== "1") return null;
179
+ const backend = detectKeychainBackend();
180
+ if (!backend) return null;
181
+ return { account: createHash("sha256").update(credentialsPath()).digest("hex").slice(0, 24), backend };
182
+ }
183
+
184
+ /**
185
+ * When keychain is enabled on an install that previously wrote a plaintext
186
+ * credentials.json, that file would otherwise sit on disk forever (readFile only
187
+ * looks at the keychain). Import it into the keychain and remove it — the whole
188
+ * point of keychain mode is that no plaintext credential remains at rest.
189
+ */
190
+ function migratePlaintextToKeychain(keychain: NonNullable<ReturnType<typeof activeKeychain>>): void {
191
+ if (!existsSync(credentialsPath())) return;
192
+ try {
193
+ const fileParsed = JSON.parse(readFileSync(credentialsPath(), "utf8"));
194
+ if (fileParsed && fileParsed.version === 1 && fileParsed.providers) {
195
+ const current = keychain.backend.get(keychain.account);
196
+ const existing = current ? (JSON.parse(current).providers ?? {}) : {};
197
+ // Keychain entries win over the file on conflict (the file is the older copy).
198
+ const merged = { version: 1 as const, providers: { ...fileParsed.providers, ...existing } };
199
+ keychain.backend.set(keychain.account, `${JSON.stringify(merged, null, 2)}\n`);
200
+ }
201
+ unlinkSync(credentialsPath());
202
+ console.error("fullstackgtm: migrated credentials.json into the OS keychain and removed the plaintext file.");
203
+ } catch {
204
+ // Best effort: a malformed/locked file is left in place rather than lost.
205
+ }
206
+ }
207
+
169
208
  function readFile(): CredentialsFile {
209
+ const keychain = activeKeychain();
210
+ if (keychain) migratePlaintextToKeychain(keychain);
170
211
  try {
171
- enforceCredentialFileMode(credentialsPath());
172
- const parsed = JSON.parse(readFileSync(credentialsPath(), "utf8"));
173
- if (parsed && typeof parsed === "object" && parsed.version === 1 && parsed.providers) {
174
- return parsed as CredentialsFile;
212
+ const raw = keychain ? keychain.backend.get(keychain.account) : (enforceCredentialFileMode(credentialsPath()), readFileSync(credentialsPath(), "utf8"));
213
+ if (raw) {
214
+ const parsed = JSON.parse(raw);
215
+ if (parsed && typeof parsed === "object" && parsed.version === 1 && parsed.providers) {
216
+ return parsed as CredentialsFile;
217
+ }
175
218
  }
176
219
  } catch {
177
- // Missing or unreadable file falls through to an empty store.
220
+ // Missing or unreadable store falls through to an empty one.
178
221
  }
179
222
  return { version: 1, providers: {} };
180
223
  }
181
224
 
225
+ function persist(file: CredentialsFile): void {
226
+ const keychain = activeKeychain();
227
+ const blob = `${JSON.stringify(file, null, 2)}\n`;
228
+ if (keychain) {
229
+ keychain.backend.set(keychain.account, blob);
230
+ } else {
231
+ ensureSecureHomeDir();
232
+ writeSecureFile(credentialsPath(), blob);
233
+ }
234
+ }
235
+
182
236
  export function getCredential(provider: string): StoredCredential | null {
183
237
  return readFile().providers[provider] ?? null;
184
238
  }
@@ -186,19 +240,28 @@ export function getCredential(provider: string): StoredCredential | null {
186
240
  export function storeCredential(provider: string, credential: StoredCredential) {
187
241
  const file = readFile();
188
242
  file.providers[provider] = credential;
189
- ensureSecureHomeDir();
190
- writeSecureFile(credentialsPath(), `${JSON.stringify(file, null, 2)}\n`);
243
+ persist(file);
191
244
  }
192
245
 
193
246
  export function deleteCredential(provider: string): boolean {
194
247
  const file = readFile();
195
248
  if (!file.providers[provider]) return false;
196
249
  delete file.providers[provider];
197
- if (Object.keys(file.providers).length === 0 && existsSync(credentialsPath())) {
198
- unlinkSync(credentialsPath());
199
- return true;
250
+ const keychain = activeKeychain();
251
+ if (Object.keys(file.providers).length === 0) {
252
+ if (keychain) {
253
+ keychain.backend.delete(keychain.account);
254
+ // Defensive: remove any leftover plaintext file too (migration normally
255
+ // already did, but never leave a credential blob on disk after logout).
256
+ if (existsSync(credentialsPath())) unlinkSync(credentialsPath());
257
+ return true;
258
+ }
259
+ if (existsSync(credentialsPath())) {
260
+ unlinkSync(credentialsPath());
261
+ return true;
262
+ }
200
263
  }
201
- writeSecureFile(credentialsPath(), `${JSON.stringify(file, null, 2)}\n`);
264
+ persist(file);
202
265
  return true;
203
266
  }
204
267
 
@@ -299,6 +362,14 @@ async function brokerMint(
299
362
  ): Promise<{ accessToken: string; instanceUrl?: string; fieldMappings?: unknown } | null> {
300
363
  const broker = getCredential("broker");
301
364
  if (!broker?.baseUrl) return null;
365
+ // The mint replays the long-lived bearer and receives a live CRM token —
366
+ // refuse to do so over cleartext even if a tampered store points us at http.
367
+ const brokerUrl = new URL(broker.baseUrl);
368
+ const localhost =
369
+ brokerUrl.hostname === "localhost" || brokerUrl.hostname === "127.0.0.1" || brokerUrl.hostname === "::1" || brokerUrl.hostname === "[::1]";
370
+ if (brokerUrl.protocol !== "https:" && !(brokerUrl.protocol === "http:" && localhost)) {
371
+ 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.`);
372
+ }
302
373
  const fetchImpl = options.fetchImpl ?? fetch;
303
374
  const response = await fetchImpl(`${broker.baseUrl.replace(/\/$/, "")}/api/cli/token`, {
304
375
  method: "POST",
package/src/index.ts CHANGED
@@ -123,6 +123,13 @@ export {
123
123
  verifyApprovalDigests,
124
124
  type ApprovalVerification,
125
125
  } from "./integrity.ts";
126
+ export {
127
+ buildAuditLog,
128
+ verifyAuditLog,
129
+ type AuditLogEntry,
130
+ type AuditLogExport,
131
+ type AuditLogVerification,
132
+ } from "./auditLog.ts";
126
133
  export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
127
134
  export { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
128
135
  export {