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.
Files changed (98) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/CONTRIBUTING.md +23 -4
  3. package/DATA-FLOWS.md +8 -2
  4. package/INSTALL_FOR_AGENTS.md +13 -4
  5. package/README.md +53 -1
  6. package/SECURITY.md +27 -3
  7. package/dist/acquireCheckpoint.d.ts +33 -0
  8. package/dist/acquireCheckpoint.js +149 -0
  9. package/dist/acquireLinkedIn.d.ts +2 -0
  10. package/dist/acquireLinkedIn.js +1 -1
  11. package/dist/cli/audit.js +10 -7
  12. package/dist/cli/auth.js +8 -4
  13. package/dist/cli/enrich.js +283 -79
  14. package/dist/cli/fix.js +20 -7
  15. package/dist/cli/help.js +23 -15
  16. package/dist/cli/market.js +180 -2
  17. package/dist/cli/plans.js +154 -9
  18. package/dist/cli/shared.d.ts +3 -1
  19. package/dist/cli/shared.js +2 -2
  20. package/dist/cli/ui.d.ts +10 -5
  21. package/dist/cli/ui.js +18 -5
  22. package/dist/cli.js +1 -1
  23. package/dist/config.d.ts +13 -1
  24. package/dist/config.js +23 -2
  25. package/dist/connectors/linkedin.d.ts +2 -0
  26. package/dist/connectors/linkedin.js +5 -0
  27. package/dist/connectors/prospectSources.d.ts +23 -0
  28. package/dist/connectors/prospectSources.js +25 -14
  29. package/dist/connectors/salesforce.js +12 -5
  30. package/dist/connectors/salesforceAuth.d.ts +9 -0
  31. package/dist/connectors/salesforceAuth.js +47 -5
  32. package/dist/connectors/theirstack.d.ts +0 -19
  33. package/dist/connectors/theirstack.js +3 -10
  34. package/dist/credentials.js +36 -26
  35. package/dist/enrich.d.ts +44 -1
  36. package/dist/hostedAcquireCheckpoint.d.ts +43 -0
  37. package/dist/hostedAcquireCheckpoint.js +129 -0
  38. package/dist/hostedPatchPlan.d.ts +87 -0
  39. package/dist/hostedPatchPlan.js +270 -0
  40. package/dist/icp.js +13 -2
  41. package/dist/index.d.ts +7 -3
  42. package/dist/index.js +6 -2
  43. package/dist/market.d.ts +1 -1
  44. package/dist/market.js +7 -127
  45. package/dist/marketClassify.d.ts +10 -0
  46. package/dist/marketClassify.js +52 -1
  47. package/dist/marketSourcing.js +7 -45
  48. package/dist/mcp.js +67 -115
  49. package/dist/planStore.d.ts +42 -1
  50. package/dist/planStore.js +268 -49
  51. package/dist/progress.d.ts +2 -2
  52. package/dist/progress.js +2 -2
  53. package/dist/providerError.d.ts +21 -0
  54. package/dist/providerError.js +30 -0
  55. package/dist/publicHttp.d.ts +28 -0
  56. package/dist/publicHttp.js +143 -0
  57. package/dist/secureFile.d.ts +15 -0
  58. package/dist/secureFile.js +164 -0
  59. package/dist/types.d.ts +1 -1
  60. package/docs/api.md +53 -2
  61. package/docs/architecture.md +22 -2
  62. package/llms.txt +7 -0
  63. package/package.json +5 -3
  64. package/skills/fullstackgtm/SKILL.md +8 -2
  65. package/src/acquireCheckpoint.ts +186 -0
  66. package/src/acquireLinkedIn.ts +3 -1
  67. package/src/cli/audit.ts +10 -7
  68. package/src/cli/auth.ts +8 -4
  69. package/src/cli/enrich.ts +322 -78
  70. package/src/cli/fix.ts +28 -7
  71. package/src/cli/help.ts +24 -15
  72. package/src/cli/market.ts +181 -2
  73. package/src/cli/plans.ts +159 -9
  74. package/src/cli/shared.ts +2 -1
  75. package/src/cli/ui.ts +19 -9
  76. package/src/cli.ts +1 -1
  77. package/src/config.ts +39 -1
  78. package/src/connectors/linkedin.ts +6 -0
  79. package/src/connectors/prospectSources.ts +50 -15
  80. package/src/connectors/salesforce.ts +12 -5
  81. package/src/connectors/salesforceAuth.ts +46 -6
  82. package/src/connectors/theirstack.ts +3 -10
  83. package/src/credentials.ts +47 -28
  84. package/src/enrich.ts +47 -1
  85. package/src/hostedAcquireCheckpoint.ts +156 -0
  86. package/src/hostedPatchPlan.ts +286 -0
  87. package/src/icp.ts +14 -2
  88. package/src/index.ts +24 -0
  89. package/src/market.ts +7 -111
  90. package/src/marketClassify.ts +61 -1
  91. package/src/marketSourcing.ts +7 -43
  92. package/src/mcp.ts +93 -136
  93. package/src/planStore.ts +322 -57
  94. package/src/progress.ts +2 -2
  95. package/src/providerError.ts +37 -0
  96. package/src/publicHttp.ts +147 -0
  97. package/src/secureFile.ts +169 -0
  98. 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
- const stored = await store.get(planId);
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 loaded = loadConfig(option(args, "--config") ?? undefined);
311
- const rules = selectedRules(args, await resolveConfiguredRules(loaded));
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
- const plans = await store.list(status ?? undefined);
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
- const stored = await store.get(planId);
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
- throw unknownSubcommandError("plans", subcommand, ["list", "show", "approve", "reject"]);
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 and erase the board (callers print their own summary). */
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 itself on done() so the verb's real output owns stdout.
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 and erase the board (callers print their own summary). */
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.done();
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/bulk-update/schedule print
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
- for (const specifier of loaded?.config.rulePackages ?? []) {
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 Error(`Explorium /v1/prospects failed: HTTP ${response.status} ${await safeText(response)}`);
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: { limit, filters: opts.filters } }],
161
+ searches: [{ search_id: "people:profiles:crustdata@1", config }],
132
162
  }),
133
163
  });
134
164
  if (!response.ok) {
135
- throw new Error(`pipe0 /v1/searches/run/sync failed: HTTP ${response.status} ${await safeText(response)}`);
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
- search_statuses?: Array<{ errors?: Array<{ code?: string; message?: string }> }>;
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
- return (body.results ?? []).map((row) => {
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 Error(`Explorium /v1/businesses count failed: HTTP ${response.status} ${await safeText(response)}`);
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 url = path.startsWith("http")
156
- ? path
157
- : `${connection.instanceUrl.replace(/\/$/, "")}${path}`;
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 ${connection.instanceUrl}${cause}. Check SALESFORCE_INSTANCE_URL (your My Domain URL, e.g. https://yourco.my.salesforce.com) and network access.`,
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 = `${connection.instanceUrl.replace(/\/$/, "")}/services/Soap/u/${version}`;
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
  });