salesprompter-cli 0.1.72 → 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
@@ -177,6 +177,8 @@ the app's CLI imports view.
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
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.
@@ -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";
@@ -730,6 +731,7 @@ const cliPacks = [
730
731
  "affiliate:finish",
731
732
  "affiliate:analytics",
732
733
  "affiliate:regenerate-sequence",
734
+ "affiliate:copy",
733
735
  "affiliate:enrich",
734
736
  "affiliate:activate",
735
737
  "affiliate:launch",
@@ -791,6 +793,7 @@ const helpVisibleCommandNames = new Set([
791
793
  "affiliate:finish",
792
794
  "affiliate:analytics",
793
795
  "affiliate:regenerate-sequence",
796
+ "affiliate:copy",
794
797
  "affiliate:enrich",
795
798
  "affiliate:activate",
796
799
  "affiliate:launch",
@@ -15327,6 +15330,45 @@ program
15327
15330
  const analytics = await getAffiliateOutreachAnalyticsViaApp(session, z.string().uuid().parse(runId));
15328
15331
  printOutput(formatAffiliateAnalyticsForOutput(analytics));
15329
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
+ });
15330
15372
  program
15331
15373
  .command("affiliate:regenerate-sequence <run-id>")
15332
15374
  .description("Regenerate observable sequence variants, then sync them to Instantly.")
@@ -19383,5 +19425,7 @@ main()
19383
19425
  })
19384
19426
  .finally(async () => {
19385
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()))));
19386
19430
  process.exit(process.exitCode ?? 0);
19387
19431
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "salesprompter-cli",
3
- "version": "0.1.72",
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",