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 +4 -2
- package/dist/affiliate-copy.js +49 -0
- package/dist/cli.js +66 -5
- package/package.json +1 -1
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
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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}
|
|
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}
|
|
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:
|
|
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", "
|
|
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