salesprompter-cli 0.1.71 → 0.1.73

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/README.md CHANGED
@@ -175,12 +175,14 @@ the app's CLI imports view.
175
175
  - Respect provider terms and customer data boundaries.
176
176
  - `affiliate:grow` is an alias for `affiliate:run`. It excludes repeated `--seed-profile` customers, removes profiles already stored in prior workspace affiliate audiences, and verifies the final persisted count before preparing Instantly.
177
177
  - If every collected profile is known or excluded, no audience or Instantly campaign is created. A stale Sales Navigator session also fails before persistence with a safe retry instruction.
178
- - Affiliate outreach uses direct email enrichment first, then Phantombuster Email Finder for unresolved people before creating a draft Instantly campaign.
178
+ - Affiliate outreach uses Hunter first, requires Hunter's `valid` verdict before import, and sends recovered addresses through Hunter Email Verifier before creating a draft Instantly campaign. Catch-all, disposable, webmail, invalid, unknown, claimed, and unresolved addresses are excluded. Instantly verification remains enabled as a second gate.
179
179
  - Affiliate preparation uses exactly three emails with three observable variants per step; `affiliate:regenerate-sequence` rebuilds and syncs them through the Salesprompter app.
180
+ - For reviewed Gojiberry copy, use `affiliate:copy <run-id> --out review.json --html preview.html`. This creates nine distinct variants without changing the campaign. Subjects, greetings, value lines, CTAs, link labels, and opt-outs use native Instantly spintax; step URLs keep their exact tracking parameters. Edit the structured `draft`, then run `--draft review.json --out reviewed.json` to validate it again. Apply only an unchanged saved review with `--apply reviewed.json`; active campaigns also require `--allow-active`. Newer Salesprompter or Instantly edits invalidate old reviews. Sending limits and audience membership are untouched.
181
+ - The copy pack keeps a concise `(paid link)` disclosure. Wording and spintax cannot guarantee inbox delivery. Preview the actual email in Instantly, especially with plain-text sending enabled. Semantic relevance, factual claims, reading level and final mobile wrapping still need human review.
180
182
  - `affiliate:list` shows safe workspace summaries. `affiliate:analytics` shows aggregate and step/variant outcomes without exposing lead data; unsupported per-step meeting and won fields render as `n/a`.
181
183
  - `affiliate:run` defaults to `--timing-mode custom`, so its daily limit and weekday sending window are honored. Use `--timing-mode auto` only when capacity-derived scheduling is intended.
182
184
  - `affiliate:enrich` starts durable email recovery and adds only newly found addresses to an existing campaign. It returns a background-processing status for large audiences instead of blocking the terminal. Pass `--source-audience-run-id` to merge a newer compatible audience into that campaign without creating a duplicate campaign.
183
- - `affiliate:finish` closes the loop: it repairs stalled verification in 1,000-lead batches, quarantines stale pending or invalid leads outside the sending campaign, accepts an explicitly audited recovery fallback, activates only a fully verified campaign, and reads the live state back. Use `--requeue-pending` only after restoring Instantly verification capacity.
185
+ - `affiliate:finish` closes the loop: it requires persisted Hunter-valid evidence for new campaigns, repairs stalled Instantly verification in 1,000-lead batches, quarantines stale pending or invalid leads outside the sending campaign, accepts an explicitly audited recovery fallback, activates only a fully verified campaign, and reads the live state back. Use `--requeue-pending` only after restoring Instantly verification capacity.
184
186
  - A draft campaign does not send until `affiliate:activate` is run.
185
187
  - The CLI is designed for interactive users and agent-assisted workflows.
186
188
  - A repo-local Chrome extension compatibility copy is available in `chrome-extension/` for local LinkedIn session sync and popup copy/debug flows.
@@ -0,0 +1,49 @@
1
+ import { z } from "zod";
2
+ const hash = z.string().regex(/^[a-f0-9]{64}$/);
3
+ export const AffiliateCopyReviewSchema = z.object({
4
+ status: z.literal("ok"),
5
+ applied: z.boolean(),
6
+ runId: z.string().uuid(),
7
+ campaignId: z.string(),
8
+ campaignName: z.string().nullable(),
9
+ sourceHash: hash,
10
+ remoteSequenceHash: hash,
11
+ reviewHash: hash,
12
+ draft: z
13
+ .object({ version: z.literal(1), steps: z.array(z.unknown()).length(3) })
14
+ .passthrough(),
15
+ sequence: z.array(z.unknown()).length(3),
16
+ previews: z
17
+ .array(z.object({
18
+ step: z.number(),
19
+ variant: z.string(),
20
+ angle: z.string(),
21
+ maxWords: z.number(),
22
+ combinations: z.number(),
23
+ examples: z
24
+ .array(z.object({
25
+ subject: z.string(),
26
+ body: z.string(),
27
+ html: z.string(),
28
+ }))
29
+ .min(1),
30
+ }))
31
+ .length(9),
32
+ warnings: z.array(z.string()),
33
+ sources: z.array(z.string()),
34
+ });
35
+ const escapeHtml = (value) => value.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
36
+ /** Render escaped data, never trusted remote HTML; 360 px cards emulate a phone. */
37
+ export function renderAffiliateCopyReview(review) {
38
+ return `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"><title>Gojiberry copy review</title><style>body{font:16px/1.55 system-ui;background:#f3f5f7;color:#17222f;margin:24px}main{max-width:1200px;margin:auto}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr));gap:24px}article{max-width:360px;background:white;border:1px solid #d9e1e8;border-radius:12px;padding:20px;overflow-wrap:anywhere}h1{font-size:24px}h2{font-size:18px}small{color:#536171}p{margin:0 0 20px}a{color:#17655b}summary{cursor:pointer}section{margin-top:20px}</style><main><h1>${escapeHtml(review.campaignName ?? "Affiliate copy review")}</h1><p>9 variants · native Instantly spintax · review only${review.applied ? " (this review was applied)" : " — no campaign changes"}</p><div class="grid">${review.previews
39
+ .map((preview) => `<article><small>STEP ${preview.step} · ${escapeHtml(preview.variant)} · ${escapeHtml(preview.angle)}</small><h2>${escapeHtml(preview.examples[0].subject)}</h2><small>Up to ${preview.maxWords} words with fallback values · ${preview.combinations.toLocaleString("en-US")} combinations</small>${preview.examples
40
+ .map((example, index) => `${index ? `<details><summary>${index === 1 ? "Missing-name fallback" : "Long-name sample"}</summary>` : ""}<section>${example.body
41
+ .split("\n\n")
42
+ .map((line) => {
43
+ const link = line.match(/^\[([^\]]+)\]\((https:\/\/[^\s]+)\) \(paid link\)$/);
44
+ return `<p>${link ? `<a href="${escapeHtml(link[2])}" rel="noreferrer sponsored">${escapeHtml(link[1])}</a> (paid link)` : escapeHtml(line)}</p>`;
45
+ })
46
+ .join("")}</section>${index ? "</details>" : ""}`)
47
+ .join("")}</article>`)
48
+ .join("")}</div><h2>Review notes</h2><ul>${review.warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join("")}</ul><p>Changing this file does not change the saved JSON review. Preview edited drafts again before applying.</p></main></html>`;
49
+ }
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@ import { createClient } from "@supabase/supabase-js";
14
14
  import pg from "pg";
15
15
  import { Command } from "commander";
16
16
  import { z } from "zod";
17
+ import { AffiliateCopyReviewSchema, renderAffiliateCopyReview } from "./affiliate-copy.js";
17
18
  import { clearAuthSession, loginWithBrowserConnect, loginWithDeviceFlow, loginWithToken, readAuthSession, requireAuthSession, shouldBypassAuth, verifySession, writeAuthSession } from "./auth.js";
18
19
  import { buildBigQueryLeadLookupSql, executeBigQuerySql, normalizeBigQueryLeadRows, runBigQueryQuery, runBigQueryRows } from "./bigquery.js";
19
20
  import { AccountProfileSchema, EnrichedLeadSchema, IcpSchema, LeadSchema, ScoredLeadSchema, SyncTargetSchema } from "./domain.js";
@@ -283,6 +284,8 @@ const AffiliateOutreachHealthSchema = z.object({
283
284
  pendingVerification: z.number().int().nonnegative(),
284
285
  invalid: z.number().int().nonnegative(),
285
286
  unsafe: z.number().int().nonnegative(),
287
+ hunterVerified: z.number().int().nonnegative().default(0),
288
+ hunterEvidenceMissing: z.number().int().nonnegative().default(0),
286
289
  contacted: z.number().int().nonnegative(),
287
290
  replied: z.number().int().nonnegative(),
288
291
  statusCounts: z.record(z.string(), z.number().int().nonnegative()),
@@ -728,6 +731,7 @@ const cliPacks = [
728
731
  "affiliate:finish",
729
732
  "affiliate:analytics",
730
733
  "affiliate:regenerate-sequence",
734
+ "affiliate:copy",
731
735
  "affiliate:enrich",
732
736
  "affiliate:activate",
733
737
  "affiliate:launch",
@@ -789,6 +793,7 @@ const helpVisibleCommandNames = new Set([
789
793
  "affiliate:finish",
790
794
  "affiliate:analytics",
791
795
  "affiliate:regenerate-sequence",
796
+ "affiliate:copy",
792
797
  "affiliate:enrich",
793
798
  "affiliate:activate",
794
799
  "affiliate:launch",
@@ -6903,18 +6908,23 @@ async function finishAffiliateOutreachViaApp(session, audienceRunId, options) {
6903
6908
  }
6904
6909
  function describeAffiliateOutreachHealth(health) {
6905
6910
  const leads = health.leads;
6911
+ const hunterSummary = leads
6912
+ ? leads.hunterEvidenceMissing > 0
6913
+ ? `Hunter evidence recorded for ${leads.hunterVerified}/${leads.total} leads (${leads.hunterEvidenceMissing} legacy or missing)`
6914
+ : `${leads.hunterVerified}/${leads.total} Hunter-valid`
6915
+ : null;
6906
6916
  if (health.done && leads) {
6907
- return `Done: the campaign is running with ${leads.verified}/${leads.total} verified leads.`;
6917
+ return `Done: the campaign is running with ${leads.verified}/${leads.total} Instantly-verified leads; ${hunterSummary}.`;
6908
6918
  }
6909
6919
  if (health.verdict === "unsafe_running" && leads) {
6910
6920
  return `Attention: the campaign is running with ${leads.unsafe} unsafe leads.`;
6911
6921
  }
6912
6922
  if (health.verdict === "ready_to_activate" && leads) {
6913
- return `Ready to activate: ${leads.verified}/${leads.total} leads are verified.`;
6923
+ return `Ready to activate: ${leads.verified}/${leads.total} Instantly-verified; ${hunterSummary}.`;
6914
6924
  }
6915
6925
  if (health.liveChecked && leads) {
6916
6926
  const campaignState = health.campaign?.statusLabel ?? "unknown";
6917
- return `Not done: the campaign is ${campaignState}; ${leads.verified}/${leads.total} leads are verified; ${leads.contacted} contacted.`;
6927
+ return `Not done: the campaign is ${campaignState}; ${leads.verified}/${leads.total} Instantly-verified; ${hunterSummary}; ${leads.contacted} contacted.`;
6918
6928
  }
6919
6929
  return health.blockers[0]?.message ?? "Live campaign status could not be verified.";
6920
6930
  }
@@ -13985,6 +13995,9 @@ function parseAffiliateOutreachCommandOptions(options) {
13985
13995
  if (!parsedTimingMode.success) {
13986
13996
  throw new Error("--timing-mode must be either auto or custom.");
13987
13997
  }
13998
+ if (options.allowAcceptAll) {
13999
+ throw new Error("--allow-accept-all is no longer supported. Affiliate outreach requires Hunter status valid.");
14000
+ }
13988
14001
  const trimmedCampaignName = options.campaignName?.trim();
13989
14002
  const campaignName = trimmedCampaignName
13990
14003
  ? z.string().max(120, "--campaign-name must be 120 characters or fewer.").parse(trimmedCampaignName)
@@ -13994,7 +14007,7 @@ function parseAffiliateOutreachCommandOptions(options) {
13994
14007
  language: z.string().trim().min(1).max(40).parse(options.language),
13995
14008
  numberOfSteps: 3,
13996
14009
  minEmailScore: z.coerce.number().int().min(0).max(100).parse(options.minEmailScore),
13997
- allowAcceptAll: Boolean(options.allowAcceptAll),
14010
+ allowAcceptAll: false,
13998
14011
  timingMode: parsedTimingMode.data,
13999
14012
  dailyLimit: z.coerce.number().int().min(1).max(1000).parse(options.dailyLimit),
14000
14013
  timezone: z.string().trim().min(1).max(100).parse(options.timezone),
@@ -15169,7 +15182,7 @@ addAffiliateAudienceOptions(program
15169
15182
  .option("--language <language>", "Sequence language", "English")
15170
15183
  .option("--steps <number>", "Exactly 3 sequence emails (fixed)", "3")
15171
15184
  .option("--min-email-score <number>", "Minimum Hunter confidence score", "80")
15172
- .option("--allow-accept-all", "Include catch-all domains that meet the score threshold", false)
15185
+ .option("--allow-accept-all", "Deprecated: catch-all emails are rejected", false)
15173
15186
  .option("--timing-mode <mode>", "Schedule mode: custom honors the daily limit and sending window; auto derives them from capacity", "custom")
15174
15187
  .option("--daily-limit <number>", "Maximum new leads contacted per day", "25")
15175
15188
  .option("--timezone <timezone>", "Instantly sending timezone", "America/Detroit")
@@ -15243,6 +15256,13 @@ addAffiliateAudienceOptions(program
15243
15256
  ? "complete"
15244
15257
  : "not_reported",
15245
15258
  persistence: "verified",
15259
+ emailVerification: {
15260
+ provider: "hunter",
15261
+ policy: "valid_only",
15262
+ verified: Number(finalOutreach.stats.hunterVerifiedEmails ?? 0),
15263
+ rejected: Number(finalOutreach.stats.hunterRejectedEmails ?? 0),
15264
+ secondaryProvider: "instantly"
15265
+ },
15246
15266
  outreach: finalOutreach.status,
15247
15267
  activation: finalOutreach.status === "active" ? "complete" : "not_requested",
15248
15268
  },
@@ -15310,6 +15330,45 @@ program
15310
15330
  const analytics = await getAffiliateOutreachAnalyticsViaApp(session, z.string().uuid().parse(runId));
15311
15331
  printOutput(formatAffiliateAnalyticsForOutput(analytics));
15312
15332
  });
15333
+ program
15334
+ .command("affiliate:copy <run-id>")
15335
+ .description("Review 9 short variants with subject, body and link-text spintax; apply only a saved review.")
15336
+ .option("--draft <file>", "Preview a custom structured draft (or an edited review's draft)")
15337
+ .option("--out <file>", "Save the complete JSON review")
15338
+ .option("--html <file>", "Save a mobile-width HTML preview")
15339
+ .option("--apply <review-file>", "Apply a saved, unchanged review after server-side stale checks")
15340
+ .option("--allow-active", "Allow the reviewed copy to replace an active campaign's sequence")
15341
+ .action(async (runId, options) => {
15342
+ const audienceRunId = z.string().uuid().parse(runId);
15343
+ if (options.apply && options.draft)
15344
+ throw new Error("Use --draft to preview edits first, then --apply the saved review.");
15345
+ if (options.allowActive && !options.apply)
15346
+ throw new Error("--allow-active is only valid with --apply.");
15347
+ if (options.out && options.html && path.resolve(options.out) === path.resolve(options.html))
15348
+ throw new Error("JSON and HTML output paths must differ.");
15349
+ const request = { apply: false };
15350
+ if (options.draft) {
15351
+ const file = JSON.parse(await readFile(path.resolve(options.draft), "utf8"));
15352
+ request.draft = file.draft ?? file;
15353
+ }
15354
+ if (options.apply) {
15355
+ const review = AffiliateCopyReviewSchema.parse(JSON.parse(await readFile(path.resolve(options.apply), "utf8")));
15356
+ if (review.runId !== audienceRunId)
15357
+ throw new Error("The review belongs to a different affiliate run.");
15358
+ if (review.applied)
15359
+ throw new Error("This review was already applied. Preview again before another change.");
15360
+ Object.assign(request, { apply: true, allowActive: Boolean(options.allowActive), draft: review.draft, sourceHash: review.sourceHash, remoteSequenceHash: review.remoteSequenceHash, reviewHash: review.reviewHash });
15361
+ }
15362
+ const session = await requireAuthSession();
15363
+ const { value } = await fetchCliJson(session, currentSession => fetch(`${currentSession.apiBaseUrl}/api/cli/affiliate-outreach/${encodeURIComponent(audienceRunId)}/copy`, {
15364
+ method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${currentSession.accessToken}` }, body: JSON.stringify(request)
15365
+ }), AffiliateCopyReviewSchema);
15366
+ if (options.out)
15367
+ await writeJsonFile(path.resolve(options.out), value);
15368
+ if (options.html)
15369
+ await writeTextFile(path.resolve(options.html), renderAffiliateCopyReview(value));
15370
+ printOutput(options.out ? { status: "ok", applied: value.applied, campaignId: value.campaignId, variants: value.previews.length, maxWords: Math.max(...value.previews.map(v => v.maxWords)), review: path.resolve(options.out), html: options.html ? path.resolve(options.html) : null, warnings: value.warnings, next: value.applied ? "Copy was read back from Instantly; audience and sending limits were not changed." : `Review the file, then: salesprompter affiliate:copy ${audienceRunId} --apply ${JSON.stringify(path.resolve(options.out))} (add --allow-active only if intended)` } : value);
15371
+ });
15313
15372
  program
15314
15373
  .command("affiliate:regenerate-sequence <run-id>")
15315
15374
  .description("Regenerate observable sequence variants, then sync them to Instantly.")
@@ -19366,5 +19425,7 @@ main()
19366
19425
  })
19367
19426
  .finally(async () => {
19368
19427
  await closeGlobalHttpDispatcher();
19428
+ // Flush piped JSON, including large copy reviews, before terminating.
19429
+ await Promise.all([process.stdout, process.stderr].map(stream => new Promise(resolve => stream.write("", () => resolve()))));
19369
19430
  process.exit(process.exitCode ?? 0);
19370
19431
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "salesprompter-cli",
3
- "version": "0.1.71",
3
+ "version": "0.1.73",
4
4
  "description": "Sales workflow CLI for guided lead generation, enrichment, scoring, and sync.",
5
5
  "author": "Daniel Sinewe <hello@danielsinewe.com>",
6
6
  "type": "module",