fullstackgtm 0.48.0 → 0.50.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 +91 -0
- package/CONTRIBUTING.md +23 -4
- package/DATA-FLOWS.md +8 -2
- package/INSTALL_FOR_AGENTS.md +13 -4
- package/README.md +53 -1
- package/SECURITY.md +27 -3
- package/dist/acquireCheckpoint.d.ts +33 -0
- package/dist/acquireCheckpoint.js +149 -0
- package/dist/acquireLinkedIn.d.ts +2 -0
- package/dist/acquireLinkedIn.js +1 -1
- package/dist/cli/audit.js +10 -7
- package/dist/cli/auth.js +8 -4
- package/dist/cli/enrich.js +283 -79
- package/dist/cli/fix.js +20 -7
- package/dist/cli/help.js +23 -15
- package/dist/cli/market.js +180 -2
- package/dist/cli/plans.js +154 -9
- package/dist/cli/shared.d.ts +3 -1
- package/dist/cli/shared.js +2 -2
- package/dist/cli/ui.d.ts +10 -5
- package/dist/cli/ui.js +18 -5
- package/dist/cli.js +1 -1
- package/dist/config.d.ts +13 -1
- package/dist/config.js +23 -2
- package/dist/connectors/linkedin.d.ts +2 -0
- package/dist/connectors/linkedin.js +5 -0
- package/dist/connectors/prospectSources.d.ts +23 -0
- package/dist/connectors/prospectSources.js +25 -14
- 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/enrich.d.ts +44 -1
- package/dist/hostedAcquireCheckpoint.d.ts +43 -0
- package/dist/hostedAcquireCheckpoint.js +129 -0
- package/dist/hostedPatchPlan.d.ts +87 -0
- package/dist/hostedPatchPlan.js +270 -0
- package/dist/icp.js +13 -2
- package/dist/index.d.ts +7 -3
- package/dist/index.js +6 -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 +42 -1
- package/dist/planStore.js +268 -49
- package/dist/progress.d.ts +2 -2
- package/dist/progress.js +2 -2
- 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 +53 -2
- package/docs/architecture.md +22 -2
- package/llms.txt +7 -0
- package/package.json +5 -3
- package/skills/fullstackgtm/SKILL.md +8 -2
- package/src/acquireCheckpoint.ts +186 -0
- package/src/acquireLinkedIn.ts +3 -1
- package/src/cli/audit.ts +10 -7
- package/src/cli/auth.ts +8 -4
- package/src/cli/enrich.ts +322 -78
- package/src/cli/fix.ts +28 -7
- package/src/cli/help.ts +24 -15
- package/src/cli/market.ts +181 -2
- package/src/cli/plans.ts +159 -9
- package/src/cli/shared.ts +2 -1
- package/src/cli/ui.ts +19 -9
- package/src/cli.ts +1 -1
- package/src/config.ts +39 -1
- package/src/connectors/linkedin.ts +6 -0
- package/src/connectors/prospectSources.ts +50 -15
- 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/enrich.ts +47 -1
- package/src/hostedAcquireCheckpoint.ts +156 -0
- package/src/hostedPatchPlan.ts +286 -0
- package/src/icp.ts +14 -2
- package/src/index.ts +24 -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 +322 -57
- package/src/progress.ts +2 -2
- 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/src/cli/plans.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
import { auditSnapshot, defaultPolicy } from "../audit.ts";
|
|
6
|
-
import { loadConfig, mergePolicy, resolveConfiguredRules } from "../config.ts";
|
|
6
|
+
import { loadConfig, mergePolicy, resolveConfiguredRules, rulePackageTrustFromCli } from "../config.ts";
|
|
7
7
|
import { applyPatchPlan } from "../connector.ts";
|
|
8
8
|
import { diffFindings, diffSnapshots, diffToMarkdown } from "../diff.ts";
|
|
9
9
|
import { createChannelConnector } from "../connectors/outboxChannel.ts";
|
|
@@ -15,6 +15,7 @@ import { createFilePlanStore } from "../planStore.ts";
|
|
|
15
15
|
import { ENRICH_CONFIG_FILE_NAME, loadEnrichConfig, type EnrichConfig } from "../enrich.ts";
|
|
16
16
|
import { loadMeter, recordConsumption, remaining } from "../acquireMeter.ts";
|
|
17
17
|
import { progressReporter, reportCounts } from "../runReport.ts";
|
|
18
|
+
import { claimHostedPlanApply, reconcileHostedPatchPlan, releaseHostedPlanApply, reportHostedPlanLifecycle, uploadHostedPatchPlan } from "../hostedPatchPlan.ts";
|
|
18
19
|
import { APPLY_STAGES, composeListeners, createProgressEmitter } from "../progress.ts";
|
|
19
20
|
import type { ValueSuggestion } from "../suggest.ts";
|
|
20
21
|
import type { CanonicalGtmSnapshot, CreateRecordPayload, PatchOperation, PatchPlan, PatchPlanRun } from "../types.ts";
|
|
@@ -141,8 +142,18 @@ export async function apply(args: string[]) {
|
|
|
141
142
|
const store = planId ? createFilePlanStore() : null;
|
|
142
143
|
|
|
143
144
|
if (planId && store) {
|
|
144
|
-
|
|
145
|
+
let stored = await store.get(planId);
|
|
145
146
|
if (!stored) throw new Error(`No stored plan with id ${planId}.`);
|
|
147
|
+
const reconciliation = await reconcileHostedPatchPlan(store, stored);
|
|
148
|
+
if (reconciliation.status === "conflict") throw new Error(`Refusing to apply plan ${planId}: ${reconciliation.reason}.`);
|
|
149
|
+
if (reconciliation.status === "updated") {
|
|
150
|
+
stored = reconciliation.stored;
|
|
151
|
+
console.error(`Synced hosted ${reconciliation.remoteStatus} state for ${planId}.`);
|
|
152
|
+
}
|
|
153
|
+
if (stored.status === "applied") {
|
|
154
|
+
console.log(`Plan ${planId} was already applied by another replica; imported its execution receipt. No provider writes were attempted.`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
146
157
|
if (stored.status !== "approved") {
|
|
147
158
|
throw new Error(
|
|
148
159
|
`Plan ${planId} is ${stored.status}; approve operations first with \`fullstackgtm plans approve ${planId} --operations <ids|all>\`.`,
|
|
@@ -234,6 +245,58 @@ export async function apply(args: string[]) {
|
|
|
234
245
|
// A channel (e.g. outbox) renders approved ops to a local artifact and
|
|
235
246
|
// transmits nothing; a CRM provider writes records. Same governed apply path.
|
|
236
247
|
const connector = channel ? createChannelConnector(channel) : await connectorFor(provider!, args);
|
|
248
|
+
let applyClaimId: string | undefined;
|
|
249
|
+
let hostedApplyClaimId: string | undefined;
|
|
250
|
+
if (planId && store) {
|
|
251
|
+
const claimed = await store.claimApply(planId, { provider: provider ?? `channel:${channel}`, source: "cli" });
|
|
252
|
+
applyClaimId = claimed.claimId;
|
|
253
|
+
const hostedClaim = await claimHostedPlanApply(claimed.stored);
|
|
254
|
+
if (hostedClaim.status === "applied") {
|
|
255
|
+
await store.abortApplyPreflight(planId, claimed.claimId, "Another replica completed apply before provider I/O.");
|
|
256
|
+
const refreshed = await store.get(planId);
|
|
257
|
+
if (refreshed) await reconcileHostedPatchPlan(store, refreshed);
|
|
258
|
+
console.log(`Plan ${planId} was already applied by another replica. No provider writes were attempted.`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (hostedClaim.status === "conflict") {
|
|
262
|
+
await store.abortApplyPreflight(planId, claimed.claimId, `Hosted execution claim failed: ${hostedClaim.reason}`);
|
|
263
|
+
throw new Error(`Refusing to apply plan ${planId}: could not coordinate with its hosted replica (${hostedClaim.reason}).`);
|
|
264
|
+
}
|
|
265
|
+
if (hostedClaim.status === "unavailable") {
|
|
266
|
+
console.error(`Hosted replica is unavailable (${hostedClaim.reason}); continuing local-first. Resolve-before-create and provider conflict guards remain active, and the execution receipt will sync on a later check-in.`);
|
|
267
|
+
}
|
|
268
|
+
if (hostedClaim.status === "claimed") {
|
|
269
|
+
hostedApplyClaimId = hostedClaim.claimId;
|
|
270
|
+
await store.recordHostedClaim(planId, claimed.claimId, hostedClaim.claimId);
|
|
271
|
+
}
|
|
272
|
+
const claimedVerification = verifyApprovalDigests(
|
|
273
|
+
claimed.stored.plan.operations,
|
|
274
|
+
claimed.stored.approvedOperationIds,
|
|
275
|
+
claimed.stored.valueOverrides,
|
|
276
|
+
claimed.stored.approvalDigests,
|
|
277
|
+
);
|
|
278
|
+
if (!claimedVerification.ok) {
|
|
279
|
+
if (hostedApplyClaimId) {
|
|
280
|
+
const released = await releaseHostedPlanApply(
|
|
281
|
+
claimed.stored,
|
|
282
|
+
hostedApplyClaimId,
|
|
283
|
+
"Approval integrity verification failed after claim and before provider I/O.",
|
|
284
|
+
);
|
|
285
|
+
if (released.status === "conflict" || released.status === "unavailable") {
|
|
286
|
+
console.error(`Hosted apply claim may require recovery: ${released.reason}.`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
await store.abortApplyPreflight(
|
|
290
|
+
planId,
|
|
291
|
+
claimed.claimId,
|
|
292
|
+
"Approval integrity verification failed after the claim and before provider I/O.",
|
|
293
|
+
);
|
|
294
|
+
throw new Error(`Refusing to apply plan ${planId}: approval changed while acquiring the apply claim.`);
|
|
295
|
+
}
|
|
296
|
+
plan = claimed.stored.plan;
|
|
297
|
+
approvedOperationIds = claimed.stored.approvedOperationIds;
|
|
298
|
+
valueOverrides = claimed.stored.valueOverrides;
|
|
299
|
+
}
|
|
237
300
|
// Interactive terminals get a live apply board on stderr while the run
|
|
238
301
|
// executes (preflight → operations → results, with a per-op safety ticker);
|
|
239
302
|
// piped runs render nothing. Either way the emitter streams heartbeats to
|
|
@@ -249,11 +312,21 @@ export async function apply(args: string[]) {
|
|
|
249
312
|
valueOverrides,
|
|
250
313
|
progress,
|
|
251
314
|
});
|
|
315
|
+
} catch (error) {
|
|
316
|
+
// Once applyPatchPlan starts, an exception cannot prove that the provider
|
|
317
|
+
// performed no write. Keep the claim fail-closed for operator reconciliation.
|
|
318
|
+
if (planId && store && applyClaimId) await store.markApplyUncertain(planId, applyClaimId);
|
|
319
|
+
throw error;
|
|
252
320
|
} finally {
|
|
253
321
|
renderer.done();
|
|
254
322
|
}
|
|
255
323
|
if (planId && store) {
|
|
256
|
-
await store.recordRun(planId, run);
|
|
324
|
+
const recorded = await store.recordRun(planId, run, applyClaimId!);
|
|
325
|
+
const durableHostedClaimId = recorded.applyAttempts?.find((attempt) => attempt.id === applyClaimId)?.hostedClaimId;
|
|
326
|
+
const mirrored = await reportHostedPlanLifecycle(recorded, { claimId: durableHostedClaimId ?? hostedApplyClaimId });
|
|
327
|
+
if (mirrored.status === "unavailable" || mirrored.status === "conflict") {
|
|
328
|
+
console.error(`Hosted plan status is stale: ${mirrored.reason}. Local apply history remains authoritative.`);
|
|
329
|
+
}
|
|
257
330
|
}
|
|
258
331
|
|
|
259
332
|
// Charge the acquire meter for the creates that actually landed.
|
|
@@ -307,8 +380,9 @@ export async function diffCommand(args: string[]) {
|
|
|
307
380
|
readFileSync(resolve(process.cwd(), afterPath), "utf8"),
|
|
308
381
|
) as CanonicalGtmSnapshot;
|
|
309
382
|
|
|
310
|
-
const
|
|
311
|
-
const
|
|
383
|
+
const explicitConfig = option(args, "--config") ?? undefined;
|
|
384
|
+
const loaded = loadConfig(explicitConfig);
|
|
385
|
+
const rules = selectedRules(args, await resolveConfiguredRules(loaded, undefined, rulePackageTrustFromCli(args, explicitConfig)));
|
|
312
386
|
const policy = mergePolicy(defaultPolicy(), loaded?.config);
|
|
313
387
|
const today = option(args, "--today");
|
|
314
388
|
if (today) policy.today = today;
|
|
@@ -380,7 +454,13 @@ export async function plansCommand(args: string[]) {
|
|
|
380
454
|
const status = option(rest, "--status") as
|
|
381
455
|
| "draft" | "needs_approval" | "approved" | "rejected" | "applied"
|
|
382
456
|
| null;
|
|
383
|
-
|
|
457
|
+
let plans = await store.list(status ?? undefined);
|
|
458
|
+
const synced = await Promise.all(plans.map(async (stored) => {
|
|
459
|
+
const result = await reconcileHostedPatchPlan(store, stored);
|
|
460
|
+
if (result.status === "updated") return result.stored;
|
|
461
|
+
return stored;
|
|
462
|
+
}));
|
|
463
|
+
plans = status ? synced.filter((stored) => stored.status === status) : synced;
|
|
384
464
|
if (rest.includes("--json") || args.includes("--json")) {
|
|
385
465
|
console.log(
|
|
386
466
|
JSON.stringify(
|
|
@@ -445,8 +525,15 @@ export async function plansCommand(args: string[]) {
|
|
|
445
525
|
if (subcommand === "show") {
|
|
446
526
|
const planId = rest.find((arg) => !arg.startsWith("--"));
|
|
447
527
|
if (!planId) throw new Error("Usage: fullstackgtm plans show <planId>");
|
|
448
|
-
|
|
528
|
+
let stored = await store.get(planId);
|
|
449
529
|
if (!stored) throw new Error(`No stored plan with id ${planId}.`);
|
|
530
|
+
const reconciliation = await reconcileHostedPatchPlan(store, stored);
|
|
531
|
+
if (reconciliation.status === "updated") {
|
|
532
|
+
stored = reconciliation.stored;
|
|
533
|
+
console.error(`Synced hosted ${reconciliation.remoteStatus} state for ${planId}.`);
|
|
534
|
+
} else if (reconciliation.status === "conflict") {
|
|
535
|
+
console.error(`Hosted sync conflict: ${reconciliation.reason}.`);
|
|
536
|
+
}
|
|
450
537
|
if (rest.includes("--json")) {
|
|
451
538
|
console.log(JSON.stringify(stored, null, 2));
|
|
452
539
|
return;
|
|
@@ -455,6 +542,16 @@ export async function plansCommand(args: string[]) {
|
|
|
455
542
|
console.log(`Status: ${planStatusWord(stored.status, showPaint)}`);
|
|
456
543
|
console.log(`Approved operations: ${stored.approvedOperationIds.join(", ") || "none"}`);
|
|
457
544
|
console.log(`Runs: ${stored.runs.length}`);
|
|
545
|
+
const mirror = await uploadHostedPatchPlan(stored);
|
|
546
|
+
if (mirror.status === "saved") console.log(`Hosted review: ${mirror.state.url}`);
|
|
547
|
+
else if (mirror.status === "unavailable" || mirror.status === "conflict") console.log(`Hosted review: sync pending (${mirror.reason})`);
|
|
548
|
+
if (stored.applyAttempts?.length) {
|
|
549
|
+
console.log("Apply attempts:");
|
|
550
|
+
for (const attempt of stored.applyAttempts) {
|
|
551
|
+
console.log(` ${attempt.id} ${attempt.status} ${attempt.provider} via ${attempt.source} ${attempt.claimedAt}`);
|
|
552
|
+
if (attempt.note) console.log(` ${attempt.note}`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
458
555
|
console.log("");
|
|
459
556
|
console.log(stylizePlanMarkdown(patchPlanToMarkdown(stored.plan), showPaint));
|
|
460
557
|
return;
|
|
@@ -506,6 +603,10 @@ export async function plansCommand(args: string[]) {
|
|
|
506
603
|
...fileOverrides,
|
|
507
604
|
...explicitOverrides,
|
|
508
605
|
});
|
|
606
|
+
const mirror = await reportHostedPlanLifecycle(updated);
|
|
607
|
+
if (mirror.status === "unavailable" || mirror.status === "conflict") {
|
|
608
|
+
console.error(`Hosted plan status is stale: ${mirror.reason}. Local approval remains authoritative.`);
|
|
609
|
+
}
|
|
509
610
|
console.log(
|
|
510
611
|
`Approved ${updated.approvedOperationIds.length} operation(s) on ${planId}. Apply with \`fullstackgtm apply --plan-id ${planId} --provider <name>\`.`,
|
|
511
612
|
);
|
|
@@ -515,10 +616,59 @@ export async function plansCommand(args: string[]) {
|
|
|
515
616
|
if (subcommand === "reject") {
|
|
516
617
|
const planId = rest.find((arg) => !arg.startsWith("--"));
|
|
517
618
|
if (!planId) throw new Error("Usage: fullstackgtm plans reject <planId>");
|
|
518
|
-
await store.reject(planId);
|
|
619
|
+
const rejected = await store.reject(planId);
|
|
620
|
+
const mirror = await reportHostedPlanLifecycle(rejected);
|
|
621
|
+
if (mirror.status === "unavailable" || mirror.status === "conflict") {
|
|
622
|
+
console.error(`Hosted plan status is stale: ${mirror.reason}. Local rejection remains authoritative.`);
|
|
623
|
+
}
|
|
519
624
|
console.log(`Rejected ${planId}.`);
|
|
520
625
|
return;
|
|
521
626
|
}
|
|
522
627
|
|
|
523
|
-
|
|
628
|
+
if (subcommand === "recover") {
|
|
629
|
+
const planId = rest.find((arg) => !arg.startsWith("--"));
|
|
630
|
+
if (!planId) {
|
|
631
|
+
throw new Error("Usage: fullstackgtm plans recover <planId> --acknowledge-uncertain-writes");
|
|
632
|
+
}
|
|
633
|
+
const stored = await store.get(planId);
|
|
634
|
+
if (!stored) throw new Error(`No stored plan with id ${planId}.`);
|
|
635
|
+
if (stored.status !== "applying" || !stored.applyClaim) {
|
|
636
|
+
throw new Error(`Plan ${planId} has no unresolved apply attempt.`);
|
|
637
|
+
}
|
|
638
|
+
const attempt = stored.applyAttempts?.find((entry) => entry.id === stored.applyClaim?.id);
|
|
639
|
+
if (!rest.includes("--acknowledge-uncertain-writes")) {
|
|
640
|
+
throw new Error(
|
|
641
|
+
`Plan ${planId} has an unresolved ${attempt?.provider ?? "provider"} apply attempt (${stored.applyClaim.id}). ` +
|
|
642
|
+
"Inspect the affected records in the provider; do not replay automatically. If you accept that some writes may already have landed, " +
|
|
643
|
+
`run \`fullstackgtm plans recover ${planId} --acknowledge-uncertain-writes\`. This clears every approval and requires a fresh review and approval before retrying.`,
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
await store.recoverApply(planId);
|
|
647
|
+
console.log(
|
|
648
|
+
`Released ${planId} to needs_approval after acknowledging uncertain provider state. ` +
|
|
649
|
+
`No writes were replayed. Re-audit provider state, review the plan, then approve operations again before any apply.`,
|
|
650
|
+
);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (subcommand === "sync") {
|
|
655
|
+
const plans = await store.list();
|
|
656
|
+
let updated = 0;
|
|
657
|
+
const warnings: string[] = [];
|
|
658
|
+
for (const stored of plans) {
|
|
659
|
+
const result = await reconcileHostedPatchPlan(store, stored);
|
|
660
|
+
if (result.status === "updated") updated += 1;
|
|
661
|
+
else if (result.status === "conflict" || result.status === "unavailable") warnings.push(`${stored.plan.id}: ${result.reason}`);
|
|
662
|
+
if (stored.status === "applied") {
|
|
663
|
+
const hostedClaimId = [...(stored.applyAttempts ?? [])].reverse().find((attempt) => attempt.hostedClaimId)?.hostedClaimId;
|
|
664
|
+
const pushed = await reportHostedPlanLifecycle(stored, { claimId: hostedClaimId });
|
|
665
|
+
if (pushed.status === "conflict" || pushed.status === "unavailable") warnings.push(`${stored.plan.id} receipt: ${pushed.reason}`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
console.log(`Plan replicas synchronized: ${updated} updated, ${plans.length - updated} unchanged.`);
|
|
669
|
+
for (const warning of warnings) console.error(` ${warning}`);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
throw unknownSubcommandError("plans", subcommand, ["list", "show", "sync", "approve", "reject", "recover"]);
|
|
524
674
|
}
|
package/src/cli/shared.ts
CHANGED
|
@@ -164,6 +164,7 @@ export async function connectorFor(
|
|
|
164
164
|
export async function readSnapshot(
|
|
165
165
|
args: string[],
|
|
166
166
|
progress?: ProgressEmitter,
|
|
167
|
+
options: { persistProgress?: boolean } = {},
|
|
167
168
|
): Promise<CanonicalGtmSnapshot> {
|
|
168
169
|
const provider = option(args, "--provider");
|
|
169
170
|
if (provider) {
|
|
@@ -189,7 +190,7 @@ export async function readSnapshot(
|
|
|
189
190
|
try {
|
|
190
191
|
return await connector.fetchSnapshot();
|
|
191
192
|
} finally {
|
|
192
|
-
renderer.done();
|
|
193
|
+
renderer.done({ persist: options.persistProgress });
|
|
193
194
|
}
|
|
194
195
|
}
|
|
195
196
|
if (args.includes("--demo")) {
|
package/src/cli/ui.ts
CHANGED
|
@@ -286,8 +286,8 @@ export function startElapsedStatus(
|
|
|
286
286
|
|
|
287
287
|
export type Checklist = {
|
|
288
288
|
update(id: string, state: "pending" | "running" | "ok" | "warn" | "fail", note?: string): void;
|
|
289
|
-
/** Stop animating
|
|
290
|
-
done(): void;
|
|
289
|
+
/** Stop animating; optionally leave the final board in the terminal history. */
|
|
290
|
+
done(options?: { persist?: boolean }): void;
|
|
291
291
|
readonly active: boolean;
|
|
292
292
|
};
|
|
293
293
|
|
|
@@ -296,7 +296,8 @@ const NOOP_CHECKLIST: Checklist = { update() {}, done() {}, active: false };
|
|
|
296
296
|
/**
|
|
297
297
|
* A multi-line live board — one line per item, each flipping ○ → ⠹ → ✓ as work
|
|
298
298
|
* progresses (the audit rule registry renders through this). Repaints with
|
|
299
|
-
* cursor-up; erases
|
|
299
|
+
* cursor-up; done() normally erases it, while done({ persist: true }) leaves a
|
|
300
|
+
* final static history for multi-phase human workflows.
|
|
300
301
|
*/
|
|
301
302
|
export function createChecklist(
|
|
302
303
|
items: Array<{ id: string; label: string }>,
|
|
@@ -343,13 +344,20 @@ export function createChecklist(
|
|
|
343
344
|
const entry = states.get(id);
|
|
344
345
|
if (!entry) return;
|
|
345
346
|
entry.state = state;
|
|
346
|
-
entry.note = note;
|
|
347
|
+
if (note !== undefined) entry.note = note;
|
|
347
348
|
start();
|
|
348
349
|
render();
|
|
349
350
|
},
|
|
350
|
-
done() {
|
|
351
|
+
done(options = {}) {
|
|
351
352
|
if (timer) clearInterval(timer);
|
|
352
353
|
timer = null;
|
|
354
|
+
if (options.persist) {
|
|
355
|
+
// The caller's final state update already painted the completed board.
|
|
356
|
+
// Freeze that frame in scrollback; repainting here can leave both the
|
|
357
|
+
// last live frame and the final frame visible in some terminals.
|
|
358
|
+
painted = 0;
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
353
361
|
if (painted > 0) {
|
|
354
362
|
// Erase the board: cursor up over every painted line, clear each.
|
|
355
363
|
stream.write(`\u001b[${painted}A${"\u001b[2K\n".repeat(painted)}\u001b[${painted}A`);
|
|
@@ -366,8 +374,8 @@ export type ProgressRenderer = {
|
|
|
366
374
|
* listener via `composeListeners` when the run should also heartbeat).
|
|
367
375
|
*/
|
|
368
376
|
listener: ProgressListener;
|
|
369
|
-
/** Stop animating
|
|
370
|
-
done(): void;
|
|
377
|
+
/** Stop animating; optionally leave the completed board in terminal history. */
|
|
378
|
+
done(options?: { persist?: boolean }): void;
|
|
371
379
|
readonly active: boolean;
|
|
372
380
|
};
|
|
373
381
|
|
|
@@ -423,8 +431,9 @@ export function createProgressRenderer(
|
|
|
423
431
|
// larger flow) are ignored by createChecklist's unknown-id no-op.
|
|
424
432
|
if (current) board.update(current, "running", noteFor(event, snapshot));
|
|
425
433
|
},
|
|
426
|
-
done() {
|
|
427
|
-
board.
|
|
434
|
+
done(options) {
|
|
435
|
+
if (current) board.update(current, "ok");
|
|
436
|
+
board.done(options);
|
|
428
437
|
},
|
|
429
438
|
active: true,
|
|
430
439
|
};
|
|
@@ -449,6 +458,7 @@ export function severityWord(word: string, p: Paint): string {
|
|
|
449
458
|
export function planStatusWord(status: string, p: Paint): string {
|
|
450
459
|
if (status === "needs_approval") return p.yellow(status);
|
|
451
460
|
if (status === "approved" || status === "applied") return p.green(status);
|
|
461
|
+
if (status === "applying") return p.yellow(status);
|
|
452
462
|
if (status === "rejected") return p.red(status);
|
|
453
463
|
return status;
|
|
454
464
|
}
|
package/src/cli.ts
CHANGED
|
@@ -91,7 +91,7 @@ export async function runCli(argv: string[]) {
|
|
|
91
91
|
}
|
|
92
92
|
// Commands without bespoke help get focused per-command help on --help
|
|
93
93
|
// instead of executing (audit used to silently run the sample audit) or
|
|
94
|
-
// dumping the whole surface. call/market/enrich/
|
|
94
|
+
// dumping the whole surface. call/market/enrich/schedule print
|
|
95
95
|
// their own richer help. `--full` always escapes to the complete reference.
|
|
96
96
|
if (!BESPOKE_HELP.includes(command) && (args.includes("--help") || args.includes("-h"))) {
|
|
97
97
|
console.log(args.includes("--full") ? usage() : commandHelp(command));
|
package/src/config.ts
CHANGED
|
@@ -35,6 +35,35 @@ export type LoadedConfig = {
|
|
|
35
35
|
path: string;
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
export type RulePackageTrust = {
|
|
39
|
+
/** Execute rule-package modules. Only set after an explicit trust decision. */
|
|
40
|
+
allowRulePackages?: boolean;
|
|
41
|
+
/** Deliberately ignore rule packages while still applying declarative config. */
|
|
42
|
+
disableRulePackages?: boolean;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Translate the CLI's explicit trust flags into the library trust contract.
|
|
47
|
+
* Plugin execution requires both an explicit config path and --allow-plugins;
|
|
48
|
+
* discovering a config in the current directory is never a trust decision.
|
|
49
|
+
*/
|
|
50
|
+
export function rulePackageTrustFromCli(
|
|
51
|
+
args: string[],
|
|
52
|
+
explicitConfigPath?: string,
|
|
53
|
+
): RulePackageTrust {
|
|
54
|
+
const allow = args.includes("--allow-plugins");
|
|
55
|
+
const disable = args.includes("--no-plugins");
|
|
56
|
+
if (allow && disable) {
|
|
57
|
+
throw new Error("--allow-plugins and --no-plugins cannot be used together.");
|
|
58
|
+
}
|
|
59
|
+
if (allow && !explicitConfigPath) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
"--allow-plugins requires --config <path>. Plugin code is never trusted from an implicitly discovered config.",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return { allowRulePackages: allow, disableRulePackages: disable };
|
|
65
|
+
}
|
|
66
|
+
|
|
38
67
|
export function loadConfig(
|
|
39
68
|
explicitPath?: string,
|
|
40
69
|
cwd = process.cwd(),
|
|
@@ -73,10 +102,19 @@ export function mergePolicy(base: GtmPolicy, config?: FullstackgtmConfig): GtmPo
|
|
|
73
102
|
export async function resolveConfiguredRules(
|
|
74
103
|
loaded?: LoadedConfig | null,
|
|
75
104
|
baseRules: GtmAuditRule[] = builtinAuditRules,
|
|
105
|
+
trust: RulePackageTrust = {},
|
|
76
106
|
): Promise<GtmAuditRule[]> {
|
|
77
107
|
let rules = [...baseRules];
|
|
78
108
|
|
|
79
|
-
|
|
109
|
+
const packages = loaded?.config.rulePackages ?? [];
|
|
110
|
+
if (packages.length && !trust.allowRulePackages && !trust.disableRulePackages) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Config ${loaded!.path} declares executable rulePackages. Refusing to load plugin code without explicit trust. ` +
|
|
113
|
+
"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.",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const specifier of trust.disableRulePackages ? [] : packages) {
|
|
80
118
|
const resolvedSpecifier =
|
|
81
119
|
specifier.startsWith(".") || isAbsolute(specifier)
|
|
82
120
|
? pathToFileURL(resolve(dirname(loaded!.path), specifier)).href
|
|
@@ -41,6 +41,8 @@ export type FetchProspectsOptions = {
|
|
|
41
41
|
sourceId?: string;
|
|
42
42
|
/** Hard cap on prospects returned; also bounds pagination. */
|
|
43
43
|
max?: number;
|
|
44
|
+
/** Opaque continuation cursor. HeyReach uses the decimal list offset. */
|
|
45
|
+
cursor?: string;
|
|
44
46
|
};
|
|
45
47
|
|
|
46
48
|
export type ConnectionStatus = { ok: boolean; detail: string };
|
|
@@ -152,6 +154,10 @@ export function createHeyReachProvider(options: HeyReachProviderOptions): Linked
|
|
|
152
154
|
const max = opts.max ?? Number.POSITIVE_INFINITY;
|
|
153
155
|
const out: LinkedInProspect[] = [];
|
|
154
156
|
let offset = 0;
|
|
157
|
+
if (opts.cursor !== undefined) {
|
|
158
|
+
if (!/^\d+$/.test(opts.cursor)) throw new Error(`Invalid HeyReach cursor: ${opts.cursor}`);
|
|
159
|
+
offset = Number(opts.cursor);
|
|
160
|
+
}
|
|
155
161
|
while (out.length < max) {
|
|
156
162
|
const data = await call("/list/GetLeadsFromList", "POST", { listId, offset, limit: pageSize });
|
|
157
163
|
const items = toArray(data);
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* env/credential store, never argv.
|
|
15
15
|
*/
|
|
16
16
|
import type { CanonicalGtmSnapshot } from "../types.ts";
|
|
17
|
+
import { ProviderHttpError } from "../providerError.ts";
|
|
17
18
|
|
|
18
19
|
export type Prospect = {
|
|
19
20
|
firstName?: string;
|
|
@@ -67,6 +68,8 @@ export async function fetchExploriumProspects(opts: {
|
|
|
67
68
|
apiKey: string;
|
|
68
69
|
filters: ExploriumFilters;
|
|
69
70
|
size?: number;
|
|
71
|
+
/** One-based result page. */
|
|
72
|
+
page?: number;
|
|
70
73
|
apiBaseUrl?: string;
|
|
71
74
|
fetchImpl?: FetchImpl;
|
|
72
75
|
}): Promise<Prospect[]> {
|
|
@@ -76,10 +79,10 @@ export async function fetchExploriumProspects(opts: {
|
|
|
76
79
|
const response = await fetchImpl(`${base}/v1/prospects`, {
|
|
77
80
|
method: "POST",
|
|
78
81
|
headers: { "api_key": opts.apiKey, "Content-Type": "application/json" },
|
|
79
|
-
body: JSON.stringify({ mode: "full", size, page_size: size, page: 1, filters: opts.filters }),
|
|
82
|
+
body: JSON.stringify({ mode: "full", size, page_size: size, page: opts.page ?? 1, filters: opts.filters }),
|
|
80
83
|
});
|
|
81
84
|
if (!response.ok) {
|
|
82
|
-
throw new
|
|
85
|
+
throw new ProviderHttpError("Explorium", "prospect search", response.status);
|
|
83
86
|
}
|
|
84
87
|
const data = (await response.json()) as { data?: ExploriumRow[] };
|
|
85
88
|
return (data.data ?? []).map((row) => ({
|
|
@@ -118,25 +121,57 @@ export async function fetchPipe0CrustdataProspects(opts: {
|
|
|
118
121
|
apiKey: string;
|
|
119
122
|
filters: Record<string, unknown>;
|
|
120
123
|
limit?: number;
|
|
124
|
+
/** Opaque cursor returned by a previous page. */
|
|
125
|
+
cursor?: string;
|
|
121
126
|
apiBaseUrl?: string;
|
|
122
127
|
fetchImpl?: FetchImpl;
|
|
123
128
|
}): Promise<Prospect[]> {
|
|
129
|
+
return (await fetchPipe0CrustdataProspectPage(opts)).prospects;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A page from pipe0/Crustdata's cursor-based people search. */
|
|
133
|
+
export type Pipe0CrustdataProspectPage = {
|
|
134
|
+
prospects: Prospect[];
|
|
135
|
+
/** Opaque cursor for the next page, or null when the search is exhausted. */
|
|
136
|
+
nextCursor: string | null;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Fetch one page and expose its continuation cursor. The array-returning
|
|
141
|
+
* `fetchPipe0CrustdataProspects` remains available for existing consumers.
|
|
142
|
+
*/
|
|
143
|
+
export async function fetchPipe0CrustdataProspectPage(opts: {
|
|
144
|
+
apiKey: string;
|
|
145
|
+
filters: Record<string, unknown>;
|
|
146
|
+
limit?: number;
|
|
147
|
+
/** Opaque cursor returned by a previous page. */
|
|
148
|
+
cursor?: string;
|
|
149
|
+
apiBaseUrl?: string;
|
|
150
|
+
fetchImpl?: FetchImpl;
|
|
151
|
+
}): Promise<Pipe0CrustdataProspectPage> {
|
|
124
152
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
125
153
|
const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
|
|
126
154
|
const limit = Math.min(opts.limit ?? 25, 100);
|
|
155
|
+
const config: Record<string, unknown> = { limit, filters: opts.filters };
|
|
156
|
+
if (opts.cursor !== undefined) config.cursor = opts.cursor;
|
|
127
157
|
const response = await fetchImpl(`${base}/v1/searches/run/sync`, {
|
|
128
158
|
method: "POST",
|
|
129
159
|
headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
|
|
130
160
|
body: JSON.stringify({
|
|
131
|
-
searches: [{ search_id: "people:profiles:crustdata@1", config
|
|
161
|
+
searches: [{ search_id: "people:profiles:crustdata@1", config }],
|
|
132
162
|
}),
|
|
133
163
|
});
|
|
134
164
|
if (!response.ok) {
|
|
135
|
-
throw new
|
|
165
|
+
throw new ProviderHttpError("pipe0", "prospect search", response.status);
|
|
136
166
|
}
|
|
137
167
|
const body = (await response.json()) as {
|
|
138
168
|
results?: Array<Record<string, { value?: unknown }>>;
|
|
139
|
-
|
|
169
|
+
next_cursor?: unknown;
|
|
170
|
+
search_statuses?: Array<{
|
|
171
|
+
errors?: Array<{ code?: string; message?: string }>;
|
|
172
|
+
next_cursor?: unknown;
|
|
173
|
+
cursor?: unknown;
|
|
174
|
+
}>;
|
|
140
175
|
};
|
|
141
176
|
// Surface upstream provider errors (e.g. CreditBalanceInsufficient) instead of
|
|
142
177
|
// silently returning [] — a throttle/credit failure must not look like "no ICP matches".
|
|
@@ -146,7 +181,7 @@ export async function fetchPipe0CrustdataProspects(opts: {
|
|
|
146
181
|
`pipe0/Crustdata search error: ${upstreamErrors.map((e) => `${e.code ?? "error"}: ${e.message ?? ""}`).join("; ")}`,
|
|
147
182
|
);
|
|
148
183
|
}
|
|
149
|
-
|
|
184
|
+
const prospects = (body.results ?? []).map((row) => {
|
|
150
185
|
const fullName = strField(row.name);
|
|
151
186
|
const { firstName, lastName } = splitName(fullName);
|
|
152
187
|
return {
|
|
@@ -159,6 +194,14 @@ export async function fetchPipe0CrustdataProspects(opts: {
|
|
|
159
194
|
sourceId: strField(row.profile_url) ?? fullName,
|
|
160
195
|
};
|
|
161
196
|
});
|
|
197
|
+
// Current pipe0 responses expose `next_cursor` at the top level. Accept the
|
|
198
|
+
// status-envelope variants too so older deployments remain usable.
|
|
199
|
+
const status = body.search_statuses?.[0];
|
|
200
|
+
const rawCursor = body.next_cursor ?? status?.next_cursor ?? status?.cursor;
|
|
201
|
+
return {
|
|
202
|
+
prospects,
|
|
203
|
+
nextCursor: typeof rawCursor === "string" && rawCursor.length > 0 ? rawCursor : null,
|
|
204
|
+
};
|
|
162
205
|
}
|
|
163
206
|
|
|
164
207
|
function strField(field: { value?: unknown } | undefined): string | undefined {
|
|
@@ -214,7 +257,7 @@ export async function probeExploriumBusinessCount(opts: {
|
|
|
214
257
|
body: JSON.stringify({ mode: "full", page_size: 1, page: 1, filters: opts.filters }),
|
|
215
258
|
});
|
|
216
259
|
if (!response.ok) {
|
|
217
|
-
throw new
|
|
260
|
+
throw new ProviderHttpError("Explorium", "business count", response.status);
|
|
218
261
|
}
|
|
219
262
|
const body = (await response.json()) as { total_results?: unknown };
|
|
220
263
|
const total = body.total_results;
|
|
@@ -427,14 +470,6 @@ function fieldValue(field: Pipe0Field | undefined): string | undefined {
|
|
|
427
470
|
return typeof v === "string" && v.trim() ? v : undefined;
|
|
428
471
|
}
|
|
429
472
|
|
|
430
|
-
async function safeText(response: Response): Promise<string> {
|
|
431
|
-
try {
|
|
432
|
-
return (await response.text()).slice(0, 300);
|
|
433
|
-
} catch {
|
|
434
|
-
return "";
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
|
|
438
473
|
// ---------------------------------------------------------------------------
|
|
439
474
|
// Pre-email dedup: identity keys shared between prospects and CRM contacts, so
|
|
440
475
|
// we can drop already-in-CRM / already-seen people BEFORE paying for emails.
|
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
PatchOperationResult,
|
|
20
20
|
SnapshotProgress,
|
|
21
21
|
} from "../types.ts";
|
|
22
|
+
import { validateSalesforceOrigin } from "./salesforceAuth.ts";
|
|
22
23
|
import { SNAPSHOT_PULL_STAGES, type ProgressEmitter } from "../progress.ts";
|
|
23
24
|
|
|
24
25
|
const DEFAULT_API_VERSION = "v59.0";
|
|
@@ -152,13 +153,17 @@ export function createSalesforceConnector(
|
|
|
152
153
|
|
|
153
154
|
async function request(path: string, init: RequestInit = {}): Promise<any> {
|
|
154
155
|
const connection = await options.getConnection();
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
const instanceUrl = validateSalesforceOrigin(connection.instanceUrl, "Salesforce connection instance URL");
|
|
157
|
+
const resolved = new URL(path, `${instanceUrl}/`);
|
|
158
|
+
if (resolved.origin !== instanceUrl) {
|
|
159
|
+
throw new Error("Salesforce response attempted to send credentials to a different origin.");
|
|
160
|
+
}
|
|
161
|
+
const url = resolved.href;
|
|
158
162
|
let response: Response;
|
|
159
163
|
try {
|
|
160
164
|
response = await fetchImpl(url, {
|
|
161
165
|
...init,
|
|
166
|
+
redirect: "manual",
|
|
162
167
|
headers: {
|
|
163
168
|
Authorization: `Bearer ${connection.accessToken}`,
|
|
164
169
|
"Content-Type": "application/json",
|
|
@@ -168,7 +173,7 @@ export function createSalesforceConnector(
|
|
|
168
173
|
} catch (error) {
|
|
169
174
|
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
170
175
|
throw new Error(
|
|
171
|
-
`Cannot reach Salesforce at ${
|
|
176
|
+
`Cannot reach Salesforce at ${instanceUrl}${cause}. Check SALESFORCE_INSTANCE_URL (your My Domain URL, e.g. https://yourco.my.salesforce.com) and network access.`,
|
|
172
177
|
);
|
|
173
178
|
}
|
|
174
179
|
if (!response.ok) {
|
|
@@ -644,8 +649,9 @@ export function createSalesforceConnector(
|
|
|
644
649
|
loserIds: string[],
|
|
645
650
|
): Promise<{ ok: boolean; detail?: string }> {
|
|
646
651
|
const connection = await options.getConnection();
|
|
652
|
+
const instanceUrl = validateSalesforceOrigin(connection.instanceUrl, "Salesforce connection instance URL");
|
|
647
653
|
const version = apiVersion.replace(/^v/, ""); // SOAP path uses "59.0", not "v59.0"
|
|
648
|
-
const url = `${
|
|
654
|
+
const url = `${instanceUrl}/services/Soap/u/${version}`;
|
|
649
655
|
const toMerge = loserIds.map((id) => `<urn:recordToMergeIds>${escapeXml(id)}</urn:recordToMergeIds>`).join("");
|
|
650
656
|
const envelope =
|
|
651
657
|
`<?xml version="1.0" encoding="UTF-8"?>` +
|
|
@@ -659,6 +665,7 @@ export function createSalesforceConnector(
|
|
|
659
665
|
try {
|
|
660
666
|
response = await fetchImpl(url, {
|
|
661
667
|
method: "POST",
|
|
668
|
+
redirect: "manual",
|
|
662
669
|
headers: { "Content-Type": "text/xml; charset=UTF-8", SOAPAction: '""' },
|
|
663
670
|
body: envelope,
|
|
664
671
|
});
|