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/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,50 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
5
5
|
and the project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
|
|
7
7
|
|
|
8
|
+
## [0.28.1] — 2026-06-16
|
|
9
|
+
|
|
10
|
+
Company-of-record correction: the legal entity is **Full Stack GTM LLC** and the
|
|
11
|
+
contact/disclosure address is **ryan@fullstackgtm.com** (package.json author,
|
|
12
|
+
NOTICE copyright, SECURITY.md, DATA-FLOWS.md). No code changes.
|
|
13
|
+
|
|
14
|
+
## [0.28.0] — 2026-06-16
|
|
15
|
+
|
|
16
|
+
Connectors, credentials & supply chain — the last of the hardening train.
|
|
17
|
+
Each security change was re-attacked; the keychain and supply-chain gates each
|
|
18
|
+
took two rounds (the re-attack found the stale-plaintext-on-migration gap and
|
|
19
|
+
the orphan-dist gap that round 1 left open).
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **Opt-in OS-keychain credential storage** (`FSGTM_KEYCHAIN=1`). The credential
|
|
24
|
+
blob is stored in the macOS Keychain (`security`) or Linux libsecret
|
|
25
|
+
(`secret-tool`) instead of a 0600 file — no native dependency. Enabling it on
|
|
26
|
+
an existing install migrates `credentials.json` into the keychain and removes
|
|
27
|
+
the plaintext file. Default (unset) is unchanged: the 0600 file. macOS caveat
|
|
28
|
+
(transient argv exposure during the keychain write) documented in SECURITY.md.
|
|
29
|
+
|
|
30
|
+
### Security
|
|
31
|
+
|
|
32
|
+
- **Broker URL must be https.** `login --via` and the token-mint path refuse a
|
|
33
|
+
cleartext/non-https broker (localhost dev excepted), and the device
|
|
34
|
+
verification URL is only auto-opened when it shares the `--via` origin — so a
|
|
35
|
+
long-lived pairing bearer and minted live-CRM tokens can't go over cleartext
|
|
36
|
+
or to an attacker-redirected URL.
|
|
37
|
+
- **Supply-chain: published dist is provably from source.** `npm run build` now
|
|
38
|
+
cleans first; release rebuilds from source and refuses to publish if the
|
|
39
|
+
committed `dist/` doesn't match (catching a poisoned or *orphaned* dist file),
|
|
40
|
+
and a CI `dist-integrity` job enforces the same on every push — protecting
|
|
41
|
+
`npm install github:` consumers who run the committed dist unbuilt.
|
|
42
|
+
- npx peer resolution from the current directory is now logged (running the MCP
|
|
43
|
+
server in an untrusted directory could otherwise silently load a peer from it).
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- **Salesforce merge is documented as unsupported**, with a connector
|
|
48
|
+
capability matrix in the README — no REST merge exists (SOAP/Apex only), so
|
|
49
|
+
`merge_records` is refused honestly; deduplicate in the UI or archive the
|
|
50
|
+
non-survivors. No silent half-merge, no demo surprise.
|
|
51
|
+
|
|
8
52
|
## [0.27.0] — 2026-06-16
|
|
9
53
|
|
|
10
54
|
Trust, compliance & transparency — the artifacts a skeptical buyer's security
|
package/DATA-FLOWS.md
CHANGED
|
@@ -38,8 +38,8 @@ Commands not listed (`plans`, `rules`, `doctor`, `schedule`, `audit-log`,
|
|
|
38
38
|
CRM-only.
|
|
39
39
|
- **No data is sent for training.** Anthropic, OpenAI, and Apollo are reached
|
|
40
40
|
with your own API keys under your own agreements; their data-handling terms
|
|
41
|
-
(and any DPA you have with them) govern that traffic. Full Stack GTM is
|
|
42
|
-
in that path and is not a sub-processor for the open-source CLI.
|
|
41
|
+
(and any DPA you have with them) govern that traffic. Full Stack GTM LLC is
|
|
42
|
+
not in that path and is not a sub-processor for the open-source CLI.
|
|
43
43
|
|
|
44
44
|
## Sub-processors
|
|
45
45
|
|
|
@@ -49,4 +49,4 @@ controllers are you and the providers whose keys you supply.
|
|
|
49
49
|
For the **hosted application** (a separate, proprietary product — not this
|
|
50
50
|
package): a sub-processor list and DPA are provided through that product's
|
|
51
51
|
agreement. If you are evaluating the hosted product, request them from
|
|
52
|
-
|
|
52
|
+
ryan@fullstackgtm.com.
|
package/NOTICE
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
fullstackgtm
|
|
2
|
-
Copyright 2026 Full Stack GTM
|
|
2
|
+
Copyright 2026 Full Stack GTM LLC
|
|
3
3
|
|
|
4
|
-
This product is developed and maintained by Full Stack GTM (https://fullstackgtm.com).
|
|
4
|
+
This product is developed and maintained by Full Stack GTM LLC (https://fullstackgtm.com).
|
|
5
5
|
Licensed under the Apache License, Version 2.0 (see LICENSE).
|
package/README.md
CHANGED
|
@@ -127,7 +127,7 @@ fullstackgtm reassign --from 411 --to 902 --except-deal-stage closing --save #
|
|
|
127
127
|
fullstackgtm fix --rule missing-deal-owner --provider hubspot --yes # audit one rule → suggest → approve → apply, one command
|
|
128
128
|
```
|
|
129
129
|
|
|
130
|
-
`bulk-update` filters the snapshot (`=`, `!=`, `~` substring, `!~` not-substring, `:empty`/`:notempty`, `|` any-of, relational pseudo-fields like `account.domain` or `openDealStages`) into a dry-run patch plan — and **the full filter is re-verified per record at apply time**, with mid-apply rechecks, so a record that stopped matching between audit and apply is skipped, not clobbered. Equality filters double as preconditions; `--require` adds explicit ones; `--guard` asserts cross-record conditions; `--max-operations` caps blast radius. `--set field=from:<sourceField>` derives values per record; `--create-task <text>` is the third change mode, emitting approval-gated `create_task` operations instead of field writes; `--archive` refuses records whose identity key (account domain, contact email) is shared with another record — that's a duplicate, and duplicates are merged with `dedupe`, not archived around (`--force-archive-duplicates` overrides that refusal explicitly).
|
|
130
|
+
`bulk-update` filters the snapshot (`=`, `!=`, `~` substring, `!~` not-substring, `:empty`/`:notempty`, type-aware comparisons `<` `>` `<=` `>=` where `today` resolves to the policy date — e.g. `closeDate<today` — and date/numeric fields coerce by value form, `|` any-of, relational pseudo-fields like `account.domain` or `openDealStages`) into a dry-run patch plan — and **the full filter is re-verified per record at apply time**, with mid-apply rechecks, so a record that stopped matching between audit and apply is skipped, not clobbered. For date/count hygiene (past close dates, stale deals, missing accounts, duplicates), prefer the rule-backed `fix --rule <id>` — the rule encodes the open-deal + date logic deterministically; use `bulk-update` only when no rule covers the task. Equality filters double as preconditions; `--require` adds explicit ones; `--guard` asserts cross-record conditions; `--max-operations` caps blast radius. `--set field=from:<sourceField>` derives values per record; `--create-task <text>` is the third change mode, emitting approval-gated `create_task` operations instead of field writes; `--archive` refuses records whose identity key (account domain, contact email) is shared with another record — that's a duplicate, and duplicates are merged with `dedupe`, not archived around (`--force-archive-duplicates` overrides that refusal explicitly).
|
|
131
131
|
|
|
132
132
|
`dedupe` finds duplicate groups by normalized identity key and emits one `merge_records` operation per group with a deterministic survivor (`richest` = most populated fields, ties to lowest id; `oldest`). Merges stay irreversible-and-therefore-low-confidence-capped on approval, exactly like merge suggestions from the audit. `reassign` is the ownership-handoff playbook: one plan per object type, extra scoping account-lifted to deals and contacts, and `--except-deal-stage` excludes both deals in that stage and every record whose account has an open deal in it. `fix` is the one-shot composite for a single rule: audit → save → suggest → approve suggestion-backed operations at the confidence bar → with `--yes`, apply and print the stage-by-stage summary; without it, stop after approval and print the apply command.
|
|
133
133
|
|
|
@@ -271,6 +271,20 @@ A direct `login hubspot` always wins over a broker pairing, so an operator can o
|
|
|
271
271
|
|
|
272
272
|
What each provider actually requires before `audit --provider <name>` works on your data.
|
|
273
273
|
|
|
274
|
+
### Connector capabilities
|
|
275
|
+
|
|
276
|
+
Connectors differ in what the provider's API allows — stated up front so nothing surprises you mid-evaluation:
|
|
277
|
+
|
|
278
|
+
| Operation | HubSpot | Salesforce | Stripe |
|
|
279
|
+
| --- | --- | --- | --- |
|
|
280
|
+
| Read / snapshot / audit | ✅ | ✅ | ✅ (read-only) |
|
|
281
|
+
| Field writes (`set_field`, `clear_field`, `link_record`) | ✅ | ✅ | — |
|
|
282
|
+
| `create_task` | ✅ | ✅ | — |
|
|
283
|
+
| `archive_record` | ✅ | ✅ | — |
|
|
284
|
+
| `merge_records` (`dedupe`) | ✅ | ❌ **not supported** | — |
|
|
285
|
+
|
|
286
|
+
**Salesforce merge** has no REST resource — it exists only in the SOAP API / Apex (Lead, Contact, Account, Case; max 3 records). This connector refuses a Salesforce `merge_records` operation honestly rather than half-merging; on Salesforce, deduplicate in the UI (or via SOAP/Apex), or use `bulk-update --archive` for the non-survivors. Native Salesforce merge is tracked future work, not a silent gap.
|
|
287
|
+
|
|
274
288
|
### HubSpot: create a private app (~2 minutes, needs super-admin)
|
|
275
289
|
|
|
276
290
|
1. In HubSpot: **Settings → Integrations → Private Apps → Create a private app.**
|
package/SECURITY.md
CHANGED
|
@@ -7,7 +7,7 @@ security reviewer needs.
|
|
|
7
7
|
|
|
8
8
|
## Reporting a vulnerability
|
|
9
9
|
|
|
10
|
-
Email **
|
|
10
|
+
Email **ryan@fullstackgtm.com** with a description and, ideally, a
|
|
11
11
|
reproduction. Please do not open a public issue for a security report. We aim
|
|
12
12
|
to acknowledge within 3 business days and to ship a fix or mitigation before
|
|
13
13
|
any public disclosure. There is no bounty program yet; credit is given in the
|
|
@@ -24,7 +24,20 @@ environment variable or stdin only, and are stored `0600` under a `0700` home
|
|
|
24
24
|
(`$FSGTM_HOME`, default `~/.fullstackgtm`), re-tightened on read. This is the
|
|
25
25
|
same custody model as the `gcloud`/`aws` CLIs. The hosted broker
|
|
26
26
|
(`login --via`) exists so a team can connect a CRM once, server-side, and hand
|
|
27
|
-
laptops only a revocable pairing token instead of a long-lived super-admin key
|
|
27
|
+
laptops only a revocable pairing token instead of a long-lived super-admin key;
|
|
28
|
+
the broker URL must be https (cleartext is refused except for localhost dev).
|
|
29
|
+
|
|
30
|
+
**OS keychain (opt-in).** Set `FSGTM_KEYCHAIN=1` to store the credential blob
|
|
31
|
+
in the OS secret store instead of a plaintext file — macOS Keychain (via
|
|
32
|
+
`security`) or Linux libsecret (via `secret-tool`); no native dependency. When
|
|
33
|
+
enabled, a pre-existing `credentials.json` is migrated into the keychain and the
|
|
34
|
+
plaintext file is removed, so a cloned home or restored backup finds no token at
|
|
35
|
+
rest. Caveat: on macOS, `security add-generic-password` only accepts the secret
|
|
36
|
+
via an argv flag, so it is briefly visible to a same-user `ps` during the write
|
|
37
|
+
— a transient exposure strictly smaller than a persistent plaintext file, but a
|
|
38
|
+
real one (Linux `secret-tool` reads the secret from stdin, with no such window).
|
|
39
|
+
Backups or clones taken *before* enabling keychain already captured the file;
|
|
40
|
+
rotate those credentials if that is a concern.
|
|
28
41
|
|
|
29
42
|
**Writes are approval-gated.** Reads are safe by default. Every change is a
|
|
30
43
|
typed patch operation in a dry-run plan that a human must approve before
|
package/dist/bulkUpdate.d.ts
CHANGED
|
@@ -33,10 +33,17 @@ export type BulkUpdateOptions = {
|
|
|
33
33
|
reason?: string;
|
|
34
34
|
/** refuse to build plans larger than this (default 500 operations) */
|
|
35
35
|
maxOperations?: number;
|
|
36
|
+
/**
|
|
37
|
+
* Date the comparison `today` literal resolves to (ISO yyyy-mm-dd). Set from
|
|
38
|
+
* the policy/--today date at the CLI; defaults to the system date. Stored on
|
|
39
|
+
* plan.filter so apply-time filter re-verification resolves `today`
|
|
40
|
+
* identically to plan time.
|
|
41
|
+
*/
|
|
42
|
+
today?: string;
|
|
36
43
|
};
|
|
37
44
|
type WhereClause = {
|
|
38
45
|
field: string;
|
|
39
|
-
op: "eq" | "neq" | "contains" | "notcontains" | "empty" | "notempty";
|
|
46
|
+
op: "eq" | "neq" | "contains" | "notcontains" | "empty" | "notempty" | "lt" | "gt" | "lte" | "gte";
|
|
40
47
|
value?: string;
|
|
41
48
|
raw: string;
|
|
42
49
|
};
|
|
@@ -44,9 +51,14 @@ export declare function parseWhere(expr: string): WhereClause;
|
|
|
44
51
|
/** True when `field` is filterable for this object type (relational pseudo-fields included). */
|
|
45
52
|
export declare function isFilterableField(objectType: BulkUpdateOptions["objectType"], field: string): boolean;
|
|
46
53
|
export declare function parseGuard(raw: string): PlanGuard;
|
|
47
|
-
/**
|
|
48
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Ids of records matching a filter — used for apply-time filter
|
|
56
|
+
* re-verification. `today` resolves the comparison `today` literal; apply-time
|
|
57
|
+
* callers pass the value the plan was built with (stored on plan.filter.today)
|
|
58
|
+
* so re-verification uses the SAME today, defaulting to the system date.
|
|
59
|
+
*/
|
|
60
|
+
export declare function eligibleIds(snapshot: CanonicalGtmSnapshot, objectType: BulkUpdateOptions["objectType"], where: string[], today?: string): Set<string>;
|
|
49
61
|
/** Evaluate a plan guard against a snapshot. Returns null when satisfied, else a failure detail. */
|
|
50
|
-
export declare function evaluateGuard(snapshot: CanonicalGtmSnapshot, guard: PlanGuard): string | null;
|
|
62
|
+
export declare function evaluateGuard(snapshot: CanonicalGtmSnapshot, guard: PlanGuard, today?: string): string | null;
|
|
51
63
|
export declare function buildBulkUpdatePlan(snapshot: CanonicalGtmSnapshot, options: BulkUpdateOptions): PatchPlan;
|
|
52
64
|
export {};
|
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;
|
package/dist/cli.js
CHANGED
|
@@ -2029,6 +2029,9 @@ async function bulkUpdateCommand(args) {
|
|
|
2029
2029
|
guard: repeatedOption(rest, "--guard"),
|
|
2030
2030
|
reason: option(rest, "--reason") ?? undefined,
|
|
2031
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,
|
|
2032
2035
|
});
|
|
2033
2036
|
await emitPlan(plan, rest);
|
|
2034
2037
|
}
|
|
@@ -2618,7 +2621,30 @@ function rejectArgvSecret(args, ...flags) {
|
|
|
2618
2621
|
}
|
|
2619
2622
|
}
|
|
2620
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
|
+
}
|
|
2621
2646
|
async function brokerLogin(baseUrl) {
|
|
2647
|
+
const viaUrl = assertSecureBrokerUrl(baseUrl);
|
|
2622
2648
|
const base = baseUrl.replace(/\/$/, "");
|
|
2623
2649
|
const os = await import("node:os");
|
|
2624
2650
|
// Self-reported, shown to the approver so they can recognize this request
|
|
@@ -2640,8 +2666,22 @@ async function brokerLogin(baseUrl) {
|
|
|
2640
2666
|
throw new Error(`Could not start pairing with ${base} (${startResponse.status}). Is this a FullStackGTM deployment?`);
|
|
2641
2667
|
}
|
|
2642
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
|
+
}
|
|
2643
2678
|
console.error(`\nPairing code: ${start.userCode}\n\nApprove this CLI ("${requesterLabel}") in your dashboard:\n\n ${start.verificationUrl}\n`);
|
|
2644
|
-
|
|
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
|
+
}
|
|
2645
2685
|
const deadline = Date.now() + (start.expiresInSeconds ?? 600) * 1000;
|
|
2646
2686
|
const intervalMs = Math.max(0, (start.intervalSeconds ?? 3) * 1000);
|
|
2647
2687
|
while (Date.now() < deadline) {
|
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;
|