fullstackgtm 0.48.0 → 0.49.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 +38 -0
- package/CONTRIBUTING.md +23 -4
- package/DATA-FLOWS.md +7 -2
- package/INSTALL_FOR_AGENTS.md +13 -4
- package/README.md +12 -1
- package/SECURITY.md +27 -3
- package/dist/cli/audit.js +10 -7
- package/dist/cli/auth.js +8 -4
- package/dist/cli/fix.js +20 -7
- package/dist/cli/help.js +10 -6
- package/dist/cli/market.js +180 -2
- package/dist/cli/plans.js +56 -5
- package/dist/cli/ui.js +2 -0
- package/dist/cli.js +1 -1
- package/dist/config.d.ts +13 -1
- package/dist/config.js +23 -2
- package/dist/connectors/prospectSources.js +4 -11
- package/dist/connectors/salesforce.js +12 -5
- package/dist/connectors/salesforceAuth.d.ts +9 -0
- package/dist/connectors/salesforceAuth.js +47 -5
- package/dist/connectors/theirstack.d.ts +0 -19
- package/dist/connectors/theirstack.js +3 -10
- package/dist/credentials.js +36 -26
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/market.d.ts +1 -1
- package/dist/market.js +7 -127
- package/dist/marketClassify.d.ts +10 -0
- package/dist/marketClassify.js +52 -1
- package/dist/marketSourcing.js +7 -45
- package/dist/mcp.js +67 -115
- package/dist/planStore.d.ts +28 -1
- package/dist/planStore.js +195 -49
- package/dist/providerError.d.ts +21 -0
- package/dist/providerError.js +30 -0
- package/dist/publicHttp.d.ts +28 -0
- package/dist/publicHttp.js +143 -0
- package/dist/secureFile.d.ts +15 -0
- package/dist/secureFile.js +164 -0
- package/dist/types.d.ts +1 -1
- package/docs/api.md +29 -2
- package/docs/architecture.md +13 -2
- package/llms.txt +7 -0
- package/package.json +5 -3
- package/skills/fullstackgtm/SKILL.md +7 -1
- package/src/cli/audit.ts +10 -7
- package/src/cli/auth.ts +8 -4
- package/src/cli/fix.ts +28 -7
- package/src/cli/help.ts +10 -6
- package/src/cli/market.ts +181 -2
- package/src/cli/plans.ts +66 -5
- package/src/cli/ui.ts +1 -0
- package/src/cli.ts +1 -1
- package/src/config.ts +39 -1
- package/src/connectors/prospectSources.ts +4 -11
- package/src/connectors/salesforce.ts +12 -5
- package/src/connectors/salesforceAuth.ts +46 -6
- package/src/connectors/theirstack.ts +3 -10
- package/src/credentials.ts +47 -28
- package/src/index.ts +4 -0
- package/src/market.ts +7 -111
- package/src/marketClassify.ts +61 -1
- package/src/marketSourcing.ts +7 -43
- package/src/mcp.ts +93 -136
- package/src/planStore.ts +235 -57
- package/src/providerError.ts +37 -0
- package/src/publicHttp.ts +147 -0
- package/src/secureFile.ts +169 -0
- package/src/types.ts +1 -0
package/dist/cli/market.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { createFilePlanStore } from "../planStore.js";
|
|
5
|
-
import { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, loadCaptureTexts, loadMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans } from "../market.js";
|
|
5
|
+
import { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, loadCaptureTexts, loadMarketConfig, observationId, starterMarketConfig, validateObservationSet, verifyEvidenceSpans } from "../market.js";
|
|
6
6
|
import { assessAxes, axesReportToText } from "../marketAxes.js";
|
|
7
7
|
import { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown } from "../marketOverlay.js";
|
|
8
8
|
import { computeScaleIndex, scaleReportToText } from "../marketScale.js";
|
|
@@ -24,6 +24,7 @@ import { unknownSubcommandError } from "./suggest.js";
|
|
|
24
24
|
*/
|
|
25
25
|
const MARKET_SUBCOMMANDS = [
|
|
26
26
|
"init", "capture", "classify", "worksheet", "observe", "fronts", "axes", "overlay", "scale", "report", "refresh",
|
|
27
|
+
"schema", "hints", "observe-template", "review",
|
|
27
28
|
];
|
|
28
29
|
export async function marketCommand(args) {
|
|
29
30
|
const [subcommand, ...rest] = args;
|
|
@@ -35,9 +36,13 @@ export async function marketCommand(args) {
|
|
|
35
36
|
market init --category <name> [--out <path>] write a starter market.config.json
|
|
36
37
|
market init --category <name> --auto --vendor <url> [--vendor <url>...] [--anchor <url>] [--max-claims n]
|
|
37
38
|
LLM-propose vendors + claim taxonomy from seed pages (needs an API key)
|
|
39
|
+
market schema print exact MarketConfig + ObservationSet shapes and a tiny example
|
|
38
40
|
market capture [--config <path>] [--run <label>]
|
|
39
41
|
market classify [--run <label>] [--capture-run <label>] [--vendor <id>] [--model m] [--out <path>]
|
|
40
42
|
market worksheet --vendor <id> [--capture-run <label>] [--out <path>]
|
|
43
|
+
market hints [--vendor <id>] [--capture-run <label>] [--json]
|
|
44
|
+
market observe-template [--run <label>] [--out <path>]
|
|
45
|
+
market review --from <observations.json> [--json]
|
|
41
46
|
market observe --from <observations.json|sets.jsonl|spool-dir> [--unverified]
|
|
42
47
|
market fronts [--config <path>] [--run <label>] [--diff <prior-run>] [--json]
|
|
43
48
|
market axes [--config <path>] [--run <label>] [--json]
|
|
@@ -78,6 +83,63 @@ one client's category intel never bleeds into another's). Front states are
|
|
|
78
83
|
recomputed deterministically on every invocation — never stored.`);
|
|
79
84
|
return;
|
|
80
85
|
}
|
|
86
|
+
if (subcommand === "schema") {
|
|
87
|
+
console.log(`MarketConfig shape:
|
|
88
|
+
{
|
|
89
|
+
"category": "string",
|
|
90
|
+
"anchorVendor": "optional vendor id",
|
|
91
|
+
"vendors": [{
|
|
92
|
+
"id": "stable-vendor-id",
|
|
93
|
+
"name": "Vendor Name",
|
|
94
|
+
"urls": { "home": "https://...", "pricing": null, "product": ["https://..."] },
|
|
95
|
+
"aliases": ["optional alternate names"]
|
|
96
|
+
}],
|
|
97
|
+
"claims": [{
|
|
98
|
+
"id": "stable-claim-id",
|
|
99
|
+
"capability": "Specific differentiating capability",
|
|
100
|
+
"icp": "buyer/segment this claim matters to",
|
|
101
|
+
"pricingStructure": "pricing motion implied by the claim, or unknown",
|
|
102
|
+
"definition": "How to judge LOUD vs QUIET vs ABSENT from page text",
|
|
103
|
+
"terms": ["buyer/search terms for deterministic mention matching"]
|
|
104
|
+
}],
|
|
105
|
+
"surfaceRule": "LOUD = hero/primary positioning; QUIET = supported but secondary; ABSENT = no support; UNOBSERVABLE = no usable capture."
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
ObservationSet shape:
|
|
109
|
+
{
|
|
110
|
+
"id": "set_run1",
|
|
111
|
+
"category": "same category as config",
|
|
112
|
+
"runLabel": "run-1",
|
|
113
|
+
"runAt": "2026-06-11T00:00:00.000Z",
|
|
114
|
+
"extractor": "manual|agent:<model>|llm:<provider>:<model>",
|
|
115
|
+
"observations": [{
|
|
116
|
+
"id": "stable observation id (any unique string is accepted)",
|
|
117
|
+
"vendorId": "vendor id from config",
|
|
118
|
+
"claimId": "claim id from config",
|
|
119
|
+
"observedAt": "2026-06-11",
|
|
120
|
+
"intensity": "loud|quiet|absent|unobservable",
|
|
121
|
+
"confidence": "high|medium|low",
|
|
122
|
+
"reason": "one reviewer-facing sentence",
|
|
123
|
+
"evidence": [{
|
|
124
|
+
"id": "ev1",
|
|
125
|
+
"sourceSystem": "web",
|
|
126
|
+
"sourceObjectType": "page",
|
|
127
|
+
"sourceObjectId": "source URL",
|
|
128
|
+
"text": "VERBATIM quote from captured page text",
|
|
129
|
+
"metadata": { "url": "source URL", "captureHash": "hash from worksheet" }
|
|
130
|
+
}]
|
|
131
|
+
}]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
Rules:
|
|
135
|
+
- exactly one observation per vendor × claim cell
|
|
136
|
+
- loud/quiet require ≥1 verbatim evidence quote
|
|
137
|
+
- absent/unobservable use [] evidence
|
|
138
|
+
- unobservable means no usable captured page exists for the vendor; if pages were captured but the claim is unsupported, use absent
|
|
139
|
+
- submit with: fullstackgtm market observe --from observations.json
|
|
140
|
+
- derive outputs with: fullstackgtm market fronts --json; fullstackgtm market report --format md --out market-report.md`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
81
143
|
if (subcommand === "init") {
|
|
82
144
|
const category = option(rest, "--category");
|
|
83
145
|
if (!category)
|
|
@@ -148,7 +210,20 @@ recomputed deterministically on every invocation — never stored.`);
|
|
|
148
210
|
return;
|
|
149
211
|
}
|
|
150
212
|
if (!rest.includes("--unverified")) {
|
|
151
|
-
const { textByHash } = loadCaptureTexts(config.category);
|
|
213
|
+
const { entries, textByHash } = loadCaptureTexts(config.category);
|
|
214
|
+
const usableVendorIds = new Set(entries
|
|
215
|
+
.filter((entry) => entry.runLabel === set.runLabel && entry.captureHash && textByHash.has(entry.captureHash))
|
|
216
|
+
.map((entry) => entry.vendorId));
|
|
217
|
+
const unsupportedUnobservable = set.observations.filter((obs) => obs.intensity === "unobservable" && usableVendorIds.has(obs.vendorId));
|
|
218
|
+
if (unsupportedUnobservable.length > 0) {
|
|
219
|
+
console.error(`Rejected: ${unsupportedUnobservable.length} unobservable reading(s) for vendors with usable captures`);
|
|
220
|
+
for (const obs of unsupportedUnobservable.slice(0, 20)) {
|
|
221
|
+
console.error(` - ${obs.vendorId} × ${obs.claimId}: vendor has captured page text; unsupported claims should be absent, not unobservable`);
|
|
222
|
+
}
|
|
223
|
+
console.error("Use unobservable only when no usable captured page exists for that vendor/run. (--unverified skips this gate when captures genuinely live elsewhere.)");
|
|
224
|
+
process.exitCode = 1;
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
152
227
|
const failures = verifyEvidenceSpans(set.observations, textByHash);
|
|
153
228
|
if (failures.length > 0) {
|
|
154
229
|
console.error(`Rejected: ${failures.length} evidence span(s) failed verification against the stored captures`);
|
|
@@ -181,6 +256,109 @@ recomputed deterministically on every invocation — never stored.`);
|
|
|
181
256
|
}
|
|
182
257
|
return;
|
|
183
258
|
}
|
|
259
|
+
if (subcommand === "hints") {
|
|
260
|
+
const vendorFilter = option(rest, "--vendor");
|
|
261
|
+
const vendorIds = vendorFilter ? [vendorFilter] : config.vendors.map((vendor) => vendor.id);
|
|
262
|
+
const worksheets = vendorIds.map((vendorId) => buildWorksheet(config, vendorId, { captureRun: option(rest, "--capture-run") ?? undefined }));
|
|
263
|
+
if (rest.includes("--json")) {
|
|
264
|
+
console.log(JSON.stringify({
|
|
265
|
+
category: config.category,
|
|
266
|
+
captureRun: worksheets[0]?.captureRun ?? null,
|
|
267
|
+
vendors: worksheets.map((worksheet) => ({ vendor: worksheet.vendor, claimHints: worksheet.claimHints })),
|
|
268
|
+
}, null, 2));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
for (const worksheet of worksheets) {
|
|
272
|
+
console.log(`\n## ${worksheet.vendor.id} — ${worksheet.vendor.name}`);
|
|
273
|
+
if (worksheet.pages.length === 0) {
|
|
274
|
+
console.log("No usable captures: classify every claim as unobservable.");
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
for (const hint of worksheet.claimHints) {
|
|
278
|
+
console.log(`\n${hint.claimId}`);
|
|
279
|
+
if (hint.matches.length === 0) {
|
|
280
|
+
console.log(" no lexical matches in captured text (usually absent; still inspect pages for synonyms)");
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
for (const match of hint.matches)
|
|
284
|
+
console.log(` - ${match.term} @ ${match.url}: ${match.quote}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (subcommand === "observe-template") {
|
|
290
|
+
const runLabel = option(rest, "--run") ?? "run-1";
|
|
291
|
+
const now = new Date().toISOString();
|
|
292
|
+
const observations = config.vendors.flatMap((vendor) => {
|
|
293
|
+
const worksheet = buildWorksheet(config, vendor.id, { captureRun: runLabel });
|
|
294
|
+
const noPages = worksheet.pages.length === 0;
|
|
295
|
+
return config.claims.map((claim) => ({
|
|
296
|
+
id: observationId(config.category, runLabel, vendor.id, claim.id),
|
|
297
|
+
vendorId: vendor.id,
|
|
298
|
+
claimId: claim.id,
|
|
299
|
+
observedAt: now.slice(0, 10),
|
|
300
|
+
intensity: noPages ? "unobservable" : "absent",
|
|
301
|
+
confidence: "low",
|
|
302
|
+
reason: noPages ? `No usable captures for ${vendor.name}; cannot judge.` : "TODO: inspect worksheet/hints and classify this cell.",
|
|
303
|
+
evidence: [],
|
|
304
|
+
_claimHints: worksheet.claimHints.find((hint) => hint.claimId === claim.id)?.matches ?? [],
|
|
305
|
+
}));
|
|
306
|
+
});
|
|
307
|
+
const set = { id: `set_${runLabel}`, category: config.category, runLabel, runAt: now, extractor: "manual-template", observations };
|
|
308
|
+
const payload = `${JSON.stringify(set, null, 2)}\n`;
|
|
309
|
+
const outPath = option(rest, "--out");
|
|
310
|
+
if (outPath) {
|
|
311
|
+
writeFileSync(resolve(process.cwd(), outPath), payload);
|
|
312
|
+
console.log(`Wrote ${outPath}: ${observations.length} template observations. Fill TODO cells, then run market review --from ${outPath}`);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
console.log(payload);
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (subcommand === "review") {
|
|
320
|
+
const fromPath = option(rest, "--from");
|
|
321
|
+
if (!fromPath)
|
|
322
|
+
throw new Error("market review requires --from <observations.json>");
|
|
323
|
+
const set = JSON.parse(readFileSync(resolve(process.cwd(), fromPath), "utf8"));
|
|
324
|
+
const errors = validateObservationSet(config, set).map((detail) => ({ code: "invalid_observation_set", detail }));
|
|
325
|
+
const warnings = [];
|
|
326
|
+
const { entries, textByHash } = loadCaptureTexts(config.category);
|
|
327
|
+
const usableVendorIds = new Set(entries
|
|
328
|
+
.filter((entry) => entry.runLabel === set.runLabel && entry.captureHash && textByHash.has(entry.captureHash))
|
|
329
|
+
.map((entry) => entry.vendorId));
|
|
330
|
+
for (const obs of set.observations) {
|
|
331
|
+
if (obs.intensity === "unobservable" && usableVendorIds.has(obs.vendorId)) {
|
|
332
|
+
errors.push({ code: "unobservable_with_captures", detail: `${obs.vendorId} × ${obs.claimId}: vendor has captured page text; use absent when unsupported.` });
|
|
333
|
+
}
|
|
334
|
+
if ((obs.intensity === "absent" || obs.intensity === "unobservable") && usableVendorIds.has(obs.vendorId)) {
|
|
335
|
+
const hints = buildWorksheet(config, obs.vendorId, { captureRun: set.runLabel }).claimHints.find((hint) => hint.claimId === obs.claimId)?.matches ?? [];
|
|
336
|
+
if (hints.length > 0) {
|
|
337
|
+
warnings.push({
|
|
338
|
+
code: "support_candidate_marked_absent",
|
|
339
|
+
detail: `${obs.vendorId} × ${obs.claimId}: ${hints.length} candidate snippet(s) matched terms but cell is ${obs.intensity}; verify absent vs quiet. Example: ${hints[0].quote}`,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
for (const failure of verifyEvidenceSpans(set.observations, textByHash)) {
|
|
345
|
+
errors.push({ code: "bad_evidence", detail: `${failure.vendorId} × ${failure.claimId}: ${failure.problem}` });
|
|
346
|
+
}
|
|
347
|
+
const summary = { ok: errors.length === 0, errors, warnings };
|
|
348
|
+
if (rest.includes("--json")) {
|
|
349
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
console.log(`${summary.ok ? "OK" : "FAILED"}: ${errors.length} error(s), ${warnings.length} warning(s)`);
|
|
353
|
+
for (const err of errors.slice(0, 20))
|
|
354
|
+
console.log(`ERROR ${err.code}: ${err.detail}`);
|
|
355
|
+
for (const warn of warnings.slice(0, 20))
|
|
356
|
+
console.log(`WARN ${warn.code}: ${warn.detail}`);
|
|
357
|
+
}
|
|
358
|
+
if (errors.length > 0)
|
|
359
|
+
process.exitCode = 1;
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
184
362
|
if (subcommand === "classify") {
|
|
185
363
|
const credential = await requireLlmCredential("market classify");
|
|
186
364
|
const vendorFilter = option(rest, "--vendor");
|
package/dist/cli/plans.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { auditSnapshot, defaultPolicy } from "../audit.js";
|
|
5
|
-
import { loadConfig, mergePolicy, resolveConfiguredRules } from "../config.js";
|
|
5
|
+
import { loadConfig, mergePolicy, resolveConfiguredRules, rulePackageTrustFromCli } from "../config.js";
|
|
6
6
|
import { applyPatchPlan } from "../connector.js";
|
|
7
7
|
import { diffFindings, diffSnapshots, diffToMarkdown } from "../diff.js";
|
|
8
8
|
import { createChannelConnector } from "../connectors/outboxChannel.js";
|
|
@@ -210,6 +210,19 @@ export async function apply(args) {
|
|
|
210
210
|
// A channel (e.g. outbox) renders approved ops to a local artifact and
|
|
211
211
|
// transmits nothing; a CRM provider writes records. Same governed apply path.
|
|
212
212
|
const connector = channel ? createChannelConnector(channel) : await connectorFor(provider, args);
|
|
213
|
+
let applyClaimId;
|
|
214
|
+
if (planId && store) {
|
|
215
|
+
const claimed = await store.claimApply(planId, { provider: provider ?? `channel:${channel}`, source: "cli" });
|
|
216
|
+
applyClaimId = claimed.claimId;
|
|
217
|
+
const claimedVerification = verifyApprovalDigests(claimed.stored.plan.operations, claimed.stored.approvedOperationIds, claimed.stored.valueOverrides, claimed.stored.approvalDigests);
|
|
218
|
+
if (!claimedVerification.ok) {
|
|
219
|
+
await store.abortApplyPreflight(planId, claimed.claimId, "Approval integrity verification failed after the claim and before provider I/O.");
|
|
220
|
+
throw new Error(`Refusing to apply plan ${planId}: approval changed while acquiring the apply claim.`);
|
|
221
|
+
}
|
|
222
|
+
plan = claimed.stored.plan;
|
|
223
|
+
approvedOperationIds = claimed.stored.approvedOperationIds;
|
|
224
|
+
valueOverrides = claimed.stored.valueOverrides;
|
|
225
|
+
}
|
|
213
226
|
// Interactive terminals get a live apply board on stderr while the run
|
|
214
227
|
// executes (preflight → operations → results, with a per-op safety ticker);
|
|
215
228
|
// piped runs render nothing. Either way the emitter streams heartbeats to
|
|
@@ -224,11 +237,18 @@ export async function apply(args) {
|
|
|
224
237
|
progress,
|
|
225
238
|
});
|
|
226
239
|
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
// Once applyPatchPlan starts, an exception cannot prove that the provider
|
|
242
|
+
// performed no write. Keep the claim fail-closed for operator reconciliation.
|
|
243
|
+
if (planId && store && applyClaimId)
|
|
244
|
+
await store.markApplyUncertain(planId, applyClaimId);
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
227
247
|
finally {
|
|
228
248
|
renderer.done();
|
|
229
249
|
}
|
|
230
250
|
if (planId && store) {
|
|
231
|
-
await store.recordRun(planId, run);
|
|
251
|
+
await store.recordRun(planId, run, applyClaimId);
|
|
232
252
|
}
|
|
233
253
|
// Charge the acquire meter for the creates that actually landed.
|
|
234
254
|
if (createOps.length > 0) {
|
|
@@ -272,8 +292,9 @@ export async function diffCommand(args) {
|
|
|
272
292
|
}
|
|
273
293
|
const before = JSON.parse(readFileSync(resolve(process.cwd(), beforePath), "utf8"));
|
|
274
294
|
const after = JSON.parse(readFileSync(resolve(process.cwd(), afterPath), "utf8"));
|
|
275
|
-
const
|
|
276
|
-
const
|
|
295
|
+
const explicitConfig = option(args, "--config") ?? undefined;
|
|
296
|
+
const loaded = loadConfig(explicitConfig);
|
|
297
|
+
const rules = selectedRules(args, await resolveConfiguredRules(loaded, undefined, rulePackageTrustFromCli(args, explicitConfig)));
|
|
277
298
|
const policy = mergePolicy(defaultPolicy(), loaded?.config);
|
|
278
299
|
const today = option(args, "--today");
|
|
279
300
|
if (today)
|
|
@@ -397,6 +418,14 @@ export async function plansCommand(args) {
|
|
|
397
418
|
console.log(`Status: ${planStatusWord(stored.status, showPaint)}`);
|
|
398
419
|
console.log(`Approved operations: ${stored.approvedOperationIds.join(", ") || "none"}`);
|
|
399
420
|
console.log(`Runs: ${stored.runs.length}`);
|
|
421
|
+
if (stored.applyAttempts?.length) {
|
|
422
|
+
console.log("Apply attempts:");
|
|
423
|
+
for (const attempt of stored.applyAttempts) {
|
|
424
|
+
console.log(` ${attempt.id} ${attempt.status} ${attempt.provider} via ${attempt.source} ${attempt.claimedAt}`);
|
|
425
|
+
if (attempt.note)
|
|
426
|
+
console.log(` ${attempt.note}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
400
429
|
console.log("");
|
|
401
430
|
console.log(stylizePlanMarkdown(patchPlanToMarkdown(stored.plan), showPaint));
|
|
402
431
|
return;
|
|
@@ -452,5 +481,27 @@ export async function plansCommand(args) {
|
|
|
452
481
|
console.log(`Rejected ${planId}.`);
|
|
453
482
|
return;
|
|
454
483
|
}
|
|
455
|
-
|
|
484
|
+
if (subcommand === "recover") {
|
|
485
|
+
const planId = rest.find((arg) => !arg.startsWith("--"));
|
|
486
|
+
if (!planId) {
|
|
487
|
+
throw new Error("Usage: fullstackgtm plans recover <planId> --acknowledge-uncertain-writes");
|
|
488
|
+
}
|
|
489
|
+
const stored = await store.get(planId);
|
|
490
|
+
if (!stored)
|
|
491
|
+
throw new Error(`No stored plan with id ${planId}.`);
|
|
492
|
+
if (stored.status !== "applying" || !stored.applyClaim) {
|
|
493
|
+
throw new Error(`Plan ${planId} has no unresolved apply attempt.`);
|
|
494
|
+
}
|
|
495
|
+
const attempt = stored.applyAttempts?.find((entry) => entry.id === stored.applyClaim?.id);
|
|
496
|
+
if (!rest.includes("--acknowledge-uncertain-writes")) {
|
|
497
|
+
throw new Error(`Plan ${planId} has an unresolved ${attempt?.provider ?? "provider"} apply attempt (${stored.applyClaim.id}). ` +
|
|
498
|
+
"Inspect the affected records in the provider; do not replay automatically. If you accept that some writes may already have landed, " +
|
|
499
|
+
`run \`fullstackgtm plans recover ${planId} --acknowledge-uncertain-writes\`. This clears every approval and requires a fresh review and approval before retrying.`);
|
|
500
|
+
}
|
|
501
|
+
await store.recoverApply(planId);
|
|
502
|
+
console.log(`Released ${planId} to needs_approval after acknowledging uncertain provider state. ` +
|
|
503
|
+
`No writes were replayed. Re-audit provider state, review the plan, then approve operations again before any apply.`);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
throw unknownSubcommandError("plans", subcommand, ["list", "show", "approve", "reject", "recover"]);
|
|
456
507
|
}
|
package/dist/cli/ui.js
CHANGED
|
@@ -374,6 +374,8 @@ export function planStatusWord(status, p) {
|
|
|
374
374
|
return p.yellow(status);
|
|
375
375
|
if (status === "approved" || status === "applied")
|
|
376
376
|
return p.green(status);
|
|
377
|
+
if (status === "applying")
|
|
378
|
+
return p.yellow(status);
|
|
377
379
|
if (status === "rejected")
|
|
378
380
|
return p.red(status);
|
|
379
381
|
return status;
|
package/dist/cli.js
CHANGED
|
@@ -88,7 +88,7 @@ export async function runCli(argv) {
|
|
|
88
88
|
}
|
|
89
89
|
// Commands without bespoke help get focused per-command help on --help
|
|
90
90
|
// instead of executing (audit used to silently run the sample audit) or
|
|
91
|
-
// dumping the whole surface. call/market/enrich/
|
|
91
|
+
// dumping the whole surface. call/market/enrich/schedule print
|
|
92
92
|
// their own richer help. `--full` always escapes to the complete reference.
|
|
93
93
|
if (!BESPOKE_HELP.includes(command) && (args.includes("--help") || args.includes("-h"))) {
|
|
94
94
|
console.log(args.includes("--full") ? usage() : commandHelp(command));
|
package/dist/config.d.ts
CHANGED
|
@@ -26,6 +26,18 @@ export type LoadedConfig = {
|
|
|
26
26
|
config: FullstackgtmConfig;
|
|
27
27
|
path: string;
|
|
28
28
|
};
|
|
29
|
+
export type RulePackageTrust = {
|
|
30
|
+
/** Execute rule-package modules. Only set after an explicit trust decision. */
|
|
31
|
+
allowRulePackages?: boolean;
|
|
32
|
+
/** Deliberately ignore rule packages while still applying declarative config. */
|
|
33
|
+
disableRulePackages?: boolean;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Translate the CLI's explicit trust flags into the library trust contract.
|
|
37
|
+
* Plugin execution requires both an explicit config path and --allow-plugins;
|
|
38
|
+
* discovering a config in the current directory is never a trust decision.
|
|
39
|
+
*/
|
|
40
|
+
export declare function rulePackageTrustFromCli(args: string[], explicitConfigPath?: string): RulePackageTrust;
|
|
29
41
|
export declare function loadConfig(explicitPath?: string, cwd?: string): LoadedConfig | null;
|
|
30
42
|
/** Overlay config policy values onto a base policy; defined values win. */
|
|
31
43
|
export declare function mergePolicy(base: GtmPolicy, config?: FullstackgtmConfig): GtmPolicy;
|
|
@@ -33,4 +45,4 @@ export declare function mergePolicy(base: GtmPolicy, config?: FullstackgtmConfig
|
|
|
33
45
|
* Build the effective rule set: built-ins plus rule-package exports, then
|
|
34
46
|
* `enabled` (allow-list) and `disabled` filters.
|
|
35
47
|
*/
|
|
36
|
-
export declare function resolveConfiguredRules(loaded?: LoadedConfig | null, baseRules?: GtmAuditRule[]): Promise<GtmAuditRule[]>;
|
|
48
|
+
export declare function resolveConfiguredRules(loaded?: LoadedConfig | null, baseRules?: GtmAuditRule[], trust?: RulePackageTrust): Promise<GtmAuditRule[]>;
|
package/dist/config.js
CHANGED
|
@@ -18,6 +18,22 @@ import { builtinAuditRules } from "./rules.js";
|
|
|
18
18
|
* in version control.
|
|
19
19
|
*/
|
|
20
20
|
export const CONFIG_FILE_NAME = "fullstackgtm.config.json";
|
|
21
|
+
/**
|
|
22
|
+
* Translate the CLI's explicit trust flags into the library trust contract.
|
|
23
|
+
* Plugin execution requires both an explicit config path and --allow-plugins;
|
|
24
|
+
* discovering a config in the current directory is never a trust decision.
|
|
25
|
+
*/
|
|
26
|
+
export function rulePackageTrustFromCli(args, explicitConfigPath) {
|
|
27
|
+
const allow = args.includes("--allow-plugins");
|
|
28
|
+
const disable = args.includes("--no-plugins");
|
|
29
|
+
if (allow && disable) {
|
|
30
|
+
throw new Error("--allow-plugins and --no-plugins cannot be used together.");
|
|
31
|
+
}
|
|
32
|
+
if (allow && !explicitConfigPath) {
|
|
33
|
+
throw new Error("--allow-plugins requires --config <path>. Plugin code is never trusted from an implicitly discovered config.");
|
|
34
|
+
}
|
|
35
|
+
return { allowRulePackages: allow, disableRulePackages: disable };
|
|
36
|
+
}
|
|
21
37
|
export function loadConfig(explicitPath, cwd = process.cwd()) {
|
|
22
38
|
const path = explicitPath ? resolve(cwd, explicitPath) : resolve(cwd, CONFIG_FILE_NAME);
|
|
23
39
|
let raw;
|
|
@@ -51,9 +67,14 @@ export function mergePolicy(base, config) {
|
|
|
51
67
|
* Build the effective rule set: built-ins plus rule-package exports, then
|
|
52
68
|
* `enabled` (allow-list) and `disabled` filters.
|
|
53
69
|
*/
|
|
54
|
-
export async function resolveConfiguredRules(loaded, baseRules = builtinAuditRules) {
|
|
70
|
+
export async function resolveConfiguredRules(loaded, baseRules = builtinAuditRules, trust = {}) {
|
|
55
71
|
let rules = [...baseRules];
|
|
56
|
-
|
|
72
|
+
const packages = loaded?.config.rulePackages ?? [];
|
|
73
|
+
if (packages.length && !trust.allowRulePackages && !trust.disableRulePackages) {
|
|
74
|
+
throw new Error(`Config ${loaded.path} declares executable rulePackages. Refusing to load plugin code without explicit trust. ` +
|
|
75
|
+
"For the CLI, pass --config <path> --allow-plugins after reviewing the modules, or pass --no-plugins to use only declarative policy and built-in rules.");
|
|
76
|
+
}
|
|
77
|
+
for (const specifier of trust.disableRulePackages ? [] : packages) {
|
|
57
78
|
const resolvedSpecifier = specifier.startsWith(".") || isAbsolute(specifier)
|
|
58
79
|
? pathToFileURL(resolve(dirname(loaded.path), specifier)).href
|
|
59
80
|
: specifier;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ProviderHttpError } from "../providerError.js";
|
|
1
2
|
function splitName(full) {
|
|
2
3
|
if (!full)
|
|
3
4
|
return {};
|
|
@@ -26,7 +27,7 @@ export async function fetchExploriumProspects(opts) {
|
|
|
26
27
|
body: JSON.stringify({ mode: "full", size, page_size: size, page: 1, filters: opts.filters }),
|
|
27
28
|
});
|
|
28
29
|
if (!response.ok) {
|
|
29
|
-
throw new
|
|
30
|
+
throw new ProviderHttpError("Explorium", "prospect search", response.status);
|
|
30
31
|
}
|
|
31
32
|
const data = (await response.json());
|
|
32
33
|
return (data.data ?? []).map((row) => ({
|
|
@@ -58,7 +59,7 @@ export async function fetchPipe0CrustdataProspects(opts) {
|
|
|
58
59
|
}),
|
|
59
60
|
});
|
|
60
61
|
if (!response.ok) {
|
|
61
|
-
throw new
|
|
62
|
+
throw new ProviderHttpError("pipe0", "prospect search", response.status);
|
|
62
63
|
}
|
|
63
64
|
const body = (await response.json());
|
|
64
65
|
// Surface upstream provider errors (e.g. CreditBalanceInsufficient) instead of
|
|
@@ -119,7 +120,7 @@ export async function probeExploriumBusinessCount(opts) {
|
|
|
119
120
|
body: JSON.stringify({ mode: "full", page_size: 1, page: 1, filters: opts.filters }),
|
|
120
121
|
});
|
|
121
122
|
if (!response.ok) {
|
|
122
|
-
throw new
|
|
123
|
+
throw new ProviderHttpError("Explorium", "business count", response.status);
|
|
123
124
|
}
|
|
124
125
|
const body = (await response.json());
|
|
125
126
|
const total = body.total_results;
|
|
@@ -293,14 +294,6 @@ function fieldValue(field) {
|
|
|
293
294
|
const v = field?.value;
|
|
294
295
|
return typeof v === "string" && v.trim() ? v : undefined;
|
|
295
296
|
}
|
|
296
|
-
async function safeText(response) {
|
|
297
|
-
try {
|
|
298
|
-
return (await response.text()).slice(0, 300);
|
|
299
|
-
}
|
|
300
|
-
catch {
|
|
301
|
-
return "";
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
297
|
// ---------------------------------------------------------------------------
|
|
305
298
|
// Pre-email dedup: identity keys shared between prospects and CRM contacts, so
|
|
306
299
|
// we can drop already-in-CRM / already-seen people BEFORE paying for emails.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, readMappedValue, } from "../mappings.js";
|
|
2
|
+
import { validateSalesforceOrigin } from "./salesforceAuth.js";
|
|
2
3
|
import { SNAPSHOT_PULL_STAGES } from "../progress.js";
|
|
3
4
|
const DEFAULT_API_VERSION = "v59.0";
|
|
4
5
|
const SOBJECT_TYPES = {
|
|
@@ -97,13 +98,17 @@ export function createSalesforceConnector(options) {
|
|
|
97
98
|
}
|
|
98
99
|
async function request(path, init = {}) {
|
|
99
100
|
const connection = await options.getConnection();
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
const instanceUrl = validateSalesforceOrigin(connection.instanceUrl, "Salesforce connection instance URL");
|
|
102
|
+
const resolved = new URL(path, `${instanceUrl}/`);
|
|
103
|
+
if (resolved.origin !== instanceUrl) {
|
|
104
|
+
throw new Error("Salesforce response attempted to send credentials to a different origin.");
|
|
105
|
+
}
|
|
106
|
+
const url = resolved.href;
|
|
103
107
|
let response;
|
|
104
108
|
try {
|
|
105
109
|
response = await fetchImpl(url, {
|
|
106
110
|
...init,
|
|
111
|
+
redirect: "manual",
|
|
107
112
|
headers: {
|
|
108
113
|
Authorization: `Bearer ${connection.accessToken}`,
|
|
109
114
|
"Content-Type": "application/json",
|
|
@@ -113,7 +118,7 @@ export function createSalesforceConnector(options) {
|
|
|
113
118
|
}
|
|
114
119
|
catch (error) {
|
|
115
120
|
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
116
|
-
throw new Error(`Cannot reach Salesforce at ${
|
|
121
|
+
throw new Error(`Cannot reach Salesforce at ${instanceUrl}${cause}. Check SALESFORCE_INSTANCE_URL (your My Domain URL, e.g. https://yourco.my.salesforce.com) and network access.`);
|
|
117
122
|
}
|
|
118
123
|
if (!response.ok) {
|
|
119
124
|
// Status line only — the body echoes submitted field values and the
|
|
@@ -526,8 +531,9 @@ export function createSalesforceConnector(options) {
|
|
|
526
531
|
*/
|
|
527
532
|
async function soapMerge(sobjectType, masterId, loserIds) {
|
|
528
533
|
const connection = await options.getConnection();
|
|
534
|
+
const instanceUrl = validateSalesforceOrigin(connection.instanceUrl, "Salesforce connection instance URL");
|
|
529
535
|
const version = apiVersion.replace(/^v/, ""); // SOAP path uses "59.0", not "v59.0"
|
|
530
|
-
const url = `${
|
|
536
|
+
const url = `${instanceUrl}/services/Soap/u/${version}`;
|
|
531
537
|
const toMerge = loserIds.map((id) => `<urn:recordToMergeIds>${escapeXml(id)}</urn:recordToMergeIds>`).join("");
|
|
532
538
|
const envelope = `<?xml version="1.0" encoding="UTF-8"?>` +
|
|
533
539
|
`<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:urn="urn:partner.soap.sforce.com" xmlns:urn1="urn:sobject.partner.soap.sforce.com">` +
|
|
@@ -540,6 +546,7 @@ export function createSalesforceConnector(options) {
|
|
|
540
546
|
try {
|
|
541
547
|
response = await fetchImpl(url, {
|
|
542
548
|
method: "POST",
|
|
549
|
+
redirect: "manual",
|
|
543
550
|
headers: { "Content-Type": "text/xml; charset=UTF-8", SOAPAction: '""' },
|
|
544
551
|
body: envelope,
|
|
545
552
|
});
|
|
@@ -6,6 +6,15 @@
|
|
|
6
6
|
* user confirms on any device. Requires a Connected App with device flow
|
|
7
7
|
* enabled; only its consumer key (client id) is needed.
|
|
8
8
|
*/
|
|
9
|
+
/**
|
|
10
|
+
* Normalize and validate a Salesforce credential-bearing origin.
|
|
11
|
+
*
|
|
12
|
+
* Salesforce API hosts are either the standard login/sandbox hosts or a
|
|
13
|
+
* Salesforce-owned instance/My Domain below salesforce.com. Deliberately
|
|
14
|
+
* return an origin (not the input URL) so callers cannot accidentally retain
|
|
15
|
+
* paths, queries, credentials, or fragments from configuration.
|
|
16
|
+
*/
|
|
17
|
+
export declare function validateSalesforceOrigin(value: string, label?: string): string;
|
|
9
18
|
export type SalesforceTokenSet = {
|
|
10
19
|
accessToken: string;
|
|
11
20
|
refreshToken?: string;
|
|
@@ -7,9 +7,46 @@
|
|
|
7
7
|
* enabled; only its consumer key (client id) is needed.
|
|
8
8
|
*/
|
|
9
9
|
const DEFAULT_LOGIN_URL = "https://login.salesforce.com";
|
|
10
|
+
/**
|
|
11
|
+
* Normalize and validate a Salesforce credential-bearing origin.
|
|
12
|
+
*
|
|
13
|
+
* Salesforce API hosts are either the standard login/sandbox hosts or a
|
|
14
|
+
* Salesforce-owned instance/My Domain below salesforce.com. Deliberately
|
|
15
|
+
* return an origin (not the input URL) so callers cannot accidentally retain
|
|
16
|
+
* paths, queries, credentials, or fragments from configuration.
|
|
17
|
+
*/
|
|
18
|
+
export function validateSalesforceOrigin(value, label = "Salesforce URL") {
|
|
19
|
+
let url;
|
|
20
|
+
try {
|
|
21
|
+
url = new URL(value);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new Error(`${label} must be a valid HTTPS Salesforce URL.`);
|
|
25
|
+
}
|
|
26
|
+
if (url.protocol !== "https:")
|
|
27
|
+
throw new Error(`${label} must use HTTPS.`);
|
|
28
|
+
if (url.username || url.password)
|
|
29
|
+
throw new Error(`${label} must not contain user information.`);
|
|
30
|
+
if (url.hash)
|
|
31
|
+
throw new Error(`${label} must not contain a fragment.`);
|
|
32
|
+
if (url.search)
|
|
33
|
+
throw new Error(`${label} must not contain a query string.`);
|
|
34
|
+
if (url.pathname !== "/" && url.pathname !== "")
|
|
35
|
+
throw new Error(`${label} must be an origin without a path.`);
|
|
36
|
+
if (url.port && url.port !== "443")
|
|
37
|
+
throw new Error(`${label} must use the standard HTTPS port.`);
|
|
38
|
+
const hostname = url.hostname.toLowerCase();
|
|
39
|
+
const trusted = hostname === "login.salesforce.com" ||
|
|
40
|
+
hostname === "test.salesforce.com" ||
|
|
41
|
+
(hostname.endsWith(".salesforce.com") && hostname.length > ".salesforce.com".length);
|
|
42
|
+
if (!trusted) {
|
|
43
|
+
throw new Error(`${label} must use login.salesforce.com, test.salesforce.com, or a Salesforce instance/My Domain host.`);
|
|
44
|
+
}
|
|
45
|
+
return url.origin;
|
|
46
|
+
}
|
|
10
47
|
const SESSION_TTL_MS = 2 * 60 * 60 * 1000;
|
|
11
48
|
function tokenUrl(loginUrl) {
|
|
12
|
-
return `${loginUrl
|
|
49
|
+
return `${validateSalesforceOrigin(loginUrl, "Salesforce login URL")}/services/oauth2/token`;
|
|
13
50
|
}
|
|
14
51
|
/**
|
|
15
52
|
* OAuth error responses can echo request parameters. Surface only the
|
|
@@ -32,6 +69,7 @@ export async function startSalesforceDeviceLogin(options) {
|
|
|
32
69
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
33
70
|
const response = await fetchImpl(tokenUrl(options.loginUrl ?? DEFAULT_LOGIN_URL), {
|
|
34
71
|
method: "POST",
|
|
72
|
+
redirect: "manual",
|
|
35
73
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
36
74
|
body: new URLSearchParams({
|
|
37
75
|
response_type: "device_code",
|
|
@@ -59,6 +97,7 @@ export async function pollSalesforceDeviceLogin(options) {
|
|
|
59
97
|
await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
|
|
60
98
|
const response = await fetchImpl(url, {
|
|
61
99
|
method: "POST",
|
|
100
|
+
redirect: "manual",
|
|
62
101
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
63
102
|
body: new URLSearchParams({
|
|
64
103
|
grant_type: "device",
|
|
@@ -68,10 +107,11 @@ export async function pollSalesforceDeviceLogin(options) {
|
|
|
68
107
|
});
|
|
69
108
|
const data = await response.json().catch(() => ({}));
|
|
70
109
|
if (response.ok && data.access_token && data.instance_url) {
|
|
110
|
+
const instanceUrl = validateSalesforceOrigin(String(data.instance_url), "Salesforce token instance_url");
|
|
71
111
|
return {
|
|
72
112
|
accessToken: data.access_token,
|
|
73
113
|
refreshToken: data.refresh_token,
|
|
74
|
-
instanceUrl
|
|
114
|
+
instanceUrl,
|
|
75
115
|
expiresAt: Date.now() + SESSION_TTL_MS,
|
|
76
116
|
};
|
|
77
117
|
}
|
|
@@ -89,6 +129,7 @@ export async function refreshSalesforceToken(options) {
|
|
|
89
129
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
90
130
|
const response = await fetchImpl(tokenUrl(options.loginUrl ?? DEFAULT_LOGIN_URL), {
|
|
91
131
|
method: "POST",
|
|
132
|
+
redirect: "manual",
|
|
92
133
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
93
134
|
body: new URLSearchParams({
|
|
94
135
|
grant_type: "refresh_token",
|
|
@@ -106,20 +147,21 @@ export async function refreshSalesforceToken(options) {
|
|
|
106
147
|
return {
|
|
107
148
|
accessToken: data.access_token,
|
|
108
149
|
refreshToken: data.refresh_token ?? options.refreshToken,
|
|
109
|
-
instanceUrl: data.instance_url,
|
|
150
|
+
instanceUrl: validateSalesforceOrigin(String(data.instance_url), "Salesforce token instance_url"),
|
|
110
151
|
expiresAt: Date.now() + SESSION_TTL_MS,
|
|
111
152
|
};
|
|
112
153
|
}
|
|
113
154
|
export async function validateSalesforceToken(accessToken, instanceUrl, fetchImpl = fetch) {
|
|
155
|
+
const trustedInstanceUrl = validateSalesforceOrigin(instanceUrl, "Salesforce instance URL");
|
|
114
156
|
let response;
|
|
115
157
|
try {
|
|
116
|
-
response = await fetchImpl(`${
|
|
158
|
+
response = await fetchImpl(`${trustedInstanceUrl}/services/oauth2/userinfo`, { redirect: "manual", headers: { Authorization: `Bearer ${accessToken}` } });
|
|
117
159
|
}
|
|
118
160
|
catch (error) {
|
|
119
161
|
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
120
162
|
return {
|
|
121
163
|
ok: false,
|
|
122
|
-
detail: `Cannot reach Salesforce at ${
|
|
164
|
+
detail: `Cannot reach Salesforce at ${trustedInstanceUrl}${cause}. Check the --instance-url (your My Domain URL, e.g. https://yourco.my.salesforce.com) and network access.`,
|
|
123
165
|
};
|
|
124
166
|
}
|
|
125
167
|
if (response.ok) {
|