indiecrm-cli 0.1.0 → 0.2.1

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 (45) hide show
  1. package/CHANGELOG.md +447 -0
  2. package/CODE_OF_CONDUCT.md +35 -0
  3. package/CONTRIBUTING.md +7 -0
  4. package/README.md +14 -2
  5. package/RESEARCH.md +346 -0
  6. package/SECURITY.md +35 -0
  7. package/dist/affiliate-copy.js +49 -0
  8. package/dist/auth.js +453 -0
  9. package/dist/bigquery.js +199 -0
  10. package/dist/chrome-browser.js +231 -0
  11. package/dist/cli.js +19652 -0
  12. package/dist/company-identity-review.js +78 -0
  13. package/dist/company-leads.js +422 -0
  14. package/dist/company-recovery.js +84 -0
  15. package/dist/deel-outreach.js +469 -0
  16. package/dist/deel-salesnav.js +368 -0
  17. package/dist/direct-path.js +326 -0
  18. package/dist/domain.js +53 -0
  19. package/dist/domainfinder.js +764 -0
  20. package/dist/engine.js +216 -0
  21. package/dist/historical-queries.js +189 -0
  22. package/dist/hunter-emailfinder.js +252 -0
  23. package/dist/icp-templates.js +171 -0
  24. package/dist/indiecrm/commands.js +108 -0
  25. package/dist/indiecrm-cli.js +2 -104
  26. package/dist/instantly.js +136 -0
  27. package/dist/io.js +21 -0
  28. package/dist/leadlists-funnel.js +148 -0
  29. package/dist/linkedin-companies.js +562 -0
  30. package/dist/linkedin-product-details.js +1203 -0
  31. package/dist/linkedin-product-search.js +1081 -0
  32. package/dist/linkedin-products.js +786 -0
  33. package/dist/linkedin-session-contracts.js +3 -0
  34. package/dist/linkedin-session.js +846 -0
  35. package/dist/providers.js +1 -0
  36. package/dist/research-browser-preference.js +37 -0
  37. package/dist/sales-navigator.js +1231 -0
  38. package/dist/salesnav-backfill.js +710 -0
  39. package/dist/sample-data.js +34 -0
  40. package/dist/session-recovery.js +62 -0
  41. package/dist/vendor/salesprompter-shared/extension-session-contracts.js +29 -0
  42. package/dist/vendor/salesprompter-shared/linkedin-session.js +22 -0
  43. package/dist/vendor/salesprompter-shared/phantombuster-contracts.js +16 -0
  44. package/dist/vendor/salesprompter-shared/session-vault-contracts.js +17 -0
  45. package/package.json +73 -14
package/RESEARCH.md ADDED
@@ -0,0 +1,346 @@
1
+ # Research commands inherited from Salesprompter
2
+
3
+ The commands below are included in `indiecrm-cli`. They still use Salesprompter accounts, workspaces, and research services. Run `indiecrm auth:login` to sign in for research. IndieCRM CRM commands use a separate `indiecrm login` session. This distinction also applies to existing saved checkpoints and organization IDs.
4
+
5
+ Requires Node.js 22.12 or newer (Node.js 24 LTS recommended). Chrome research also requires an installed Google Chrome.
6
+
7
+ The IndieCRM CLI preserves the Salesprompter terminal workflow for lead generation, enrichment, scoring, and sync.
8
+
9
+ It helps operators and agents go from company, market, or product input to qualified outbound-ready leads with a product-facing command surface and machine-readable output.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install -g indiecrm-cli
15
+ ```
16
+
17
+ or run directly:
18
+
19
+ ```bash
20
+ npx -y indiecrm-cli@latest
21
+ ```
22
+
23
+ ## Quickstart
24
+
25
+ ```bash
26
+ # Run first-time setup
27
+ indiecrm setup
28
+
29
+ # Run a local health check
30
+ indiecrm doctor
31
+
32
+ # Sign in from the CLI
33
+ indiecrm auth:login
34
+
35
+ # Verify your session
36
+ indiecrm auth:whoami
37
+ ```
38
+
39
+ New here? Create your account at `https://salesprompter.ai/sign-up`, then run `indiecrm auth:login`.
40
+
41
+ For headless or automation use, generate a CLI token in the app and run `indiecrm auth:login --token <token>`.
42
+
43
+ ## What it does
44
+
45
+ ### Contacts at a named list of companies
46
+
47
+ `leads:at-companies` researches Director, Head-of, VP and C-level contacts across functions. It exports a shortlist for review without starting email enrichment or outreach.
48
+
49
+ Start with company names; no LinkedIn IDs are needed in your brief:
50
+
51
+ ```json
52
+ { "companies": [{ "name": "Your target company", "maxContacts": 20 }] }
53
+ ```
54
+
55
+ ```bash
56
+ indiecrm auth:login
57
+ indiecrm browser:connect
58
+ indiecrm leads:at-companies --brief companies.json --out-dir ./research --resolve-companies --all
59
+ ```
60
+
61
+ After a successful connection, Chrome is remembered for company research. If names are ambiguous, an interactive terminal shows verified candidate locations, websites and evidence links. Choose a number, then confirm; nothing is preselected. The CLI saves the decision and resumes without editing your brief. Use `--review` to review a saved ambiguity later. Enter 0 or press Enter to leave it unresolved.
62
+
63
+ JSON, quiet and non-interactive runs never prompt. Their `outcome` is `complete`, `incomplete` or `needs_review`; `nextCommand` gives the terminal command for pending collection or identity review. `collectionComplete` refers only to collection, so it can be true while role review remains. Add `--require-complete` for exit code 2 whenever collection or review is unfinished. Without it, exit 0 means the command ran, not that all research is complete. `--no-interactive` disables prompts in a terminal too.
64
+
65
+ Guided review fetches native details for at most ten exact-name candidates. Non-exact, composite or larger identity choices still need independently checked `--review-file` decisions. Missing/mismatched details cannot be approved. Explicit connection flags override the remembered choice; failed Chrome authentication never falls back silently to another identity.
66
+
67
+ The checkpoint, shortlist and review exports refresh after every collected page, so long runs expose saved progress immediately. Company labels with punctuation, such as `(TKMS)`, are escaped safely in search queries.
68
+
69
+ Use `--report-only` to refresh saved reports without contacting LinkedIn. The workspace-bound checkpoint is still checked. `progress` separates unique shortlisted people, review rows, review people, review-only people, pending searches, partial/unknown coverage and unresolved companies. `collectionComplete` is false while any coverage gap remains; `nextActions` explains what is left. Unrestricted geography is labeled explicitly.
70
+
71
+ ### Recover saved research without recollecting
72
+
73
+ Version 0.1.83 separates collection evidence from review decisions. Keep the original brief and output directory:
74
+
75
+ ```bash
76
+ # Recheck saved roles and write an actionable recovery.json; no LinkedIn requests.
77
+ indiecrm leads:at-companies --brief companies.json --out-dir ./research --report-only
78
+ # Verify canonical employer names against native company details for selected targets.
79
+ indiecrm leads:at-companies --brief companies.json --out-dir ./research \
80
+ --browser chrome --verify-employers --company 'Example (CH)'
81
+ # Continue partial searches until company shortlist ceilings are filled.
82
+ indiecrm leads:at-companies --brief companies.json --out-dir ./research \
83
+ --browser chrome --continue-partial quota --all
84
+ # Or request all accessible pages for one exact brief company name.
85
+ indiecrm leads:at-companies --brief companies.json --out-dir ./research \
86
+ --browser chrome --continue-partial exhaustive --company 'Example (CH)'
87
+ ```
88
+
89
+ `--company` is repeatable and limits network work, not the overall report. `--verify-employers` checks scoped employers with saved candidates and caches their canonical-name evidence. Only equivalent presentation/legal-suffix names (for example, `BioNTech` / `BioNTech SE`) qualify automatically; group, division and subsidiary differences still require review. The exact numeric current-employer ID check always applies.
90
+
91
+ `recovery.json` contains alias proposals, identity-search attempts and candidate evidence, explicit unresolved reasons, and a fingerprint-bound review template. Proposals are **not** approvals. Copy its `reviewFileTemplate` into a private JSON file and add only independently verified decisions:
92
+
93
+ ```json
94
+ {
95
+ "fingerprint": "COPY_THE_64_CHARACTER_FINGERPRINT_FROM_RECOVERY_JSON",
96
+ "employerAliases": [
97
+ { "companyId": "123", "name": "Exact source name", "evidenceUrl": "https://official.example/evidence" }
98
+ ],
99
+ "identities": [
100
+ { "targetName": "Exact name in brief", "companyId": "456", "canonicalName": "Verified source name", "evidenceUrl": "https://official.example/evidence" }
101
+ ]
102
+ }
103
+ ```
104
+
105
+ ```bash
106
+ indiecrm leads:at-companies --brief companies.json --out-dir ./research \
107
+ --review-file ./verified-decisions.json --report-only
108
+ # Then collect any newly resolved identities through the same checkpoint.
109
+ indiecrm leads:at-companies --brief companies.json --out-dir ./research --browser chrome --all
110
+ ```
111
+
112
+ The CLI validates and persists the complete review file as `review-decisions.json`, checks its workspace/brief fingerprint, and rejects conflicting/shared identities. Supplied evidence URLs record human decisions; their contents are not independently verified by `--review-file`. Later runs reuse the decisions. An alias never approves an ambiguous role. For a new collection, `verifiedEmployerAliases` in the company brief remains supported; changing the original targeting brief still requires a new output directory.
113
+
114
+ Partial continuation checkpoints the raw next offset and deduplicates people. Old checkpoints without an offset replay the first page once before advancing. `quota` stops at the shortlist ceiling; `exhaustive` means attempting the accessible 2,500-result window, **not** a promise of exhaustive coverage. Count changes, empty/repeated pages and larger result sets remain explicitly incomplete. Stopped searches require investigation; the CLI does not automatically clear `stoppedReason`. `--all` controls the number of jobs, not these safety limits. Make a private copy of the output directory before comparing old and new role-policy results.
115
+
116
+ For standalone browser research, sign in once to the CLI-owned Chrome profile:
117
+
118
+ ```bash
119
+ indiecrm browser:connect
120
+ indiecrm leads:at-companies --brief companies.json --out-dir ./research \
121
+ --resolve-companies --all
122
+ ```
123
+
124
+ `browser:connect` opens a separate Chrome window and waits up to ten minutes for your manual Sales Navigator sign-in (change with `--wait <seconds>`). It confirms a successful native authenticated request, then closes its own browser. Google Chrome must be installed. The private profile lives at `~/.config/salesprompter/chrome-research` (or under `SALESPROMPTER_CONFIG_DIR`). Your everyday Chrome profile stays untouched. The control connection uses a private OS pipe, not a debugging port. No cookies or browser storage are read/exported. The CLI keeps only allowlisted native request headers in memory.
125
+
126
+ Research runs reuse that profile in background Chrome, execute the GET requests themselves, and close their own browser when finished. No Codex task, relay worker, extension, or manual request polling is needed. A first sign-in, expired login, or security challenge still requires you: rerun `browser:connect`, then repeat the research command with the same output directory. Rate limits stop immediately without switching accounts. `--browser chrome`, `--browser-relay-port`, and `--wait-for-session` are mutually exclusive. Chrome connects lazily, so completed checkpoints and report-only runs never launch it.
127
+
128
+ `browser:connect` remembers Chrome only after confirming a successful authenticated request. `browser:connect --check` checks an existing sign-in in background and also remembers it; it does not open a sign-in window. The preference is private and scoped to the CLI configuration directory. Explicit `--browser session` selects extension credentials; without a remembered choice, session remains the fallback. Explicit relay/wait flags take precedence over the preference. Credentials load only for pending requests; on an auth failure the session route tries changed credentials for the same identity once. Add `--wait-for-session 300` for a bounded refresh wait. The optional Codex `--browser-relay-port` route still needs its external worker. Salesprompter workspace authentication applies in every mode.
129
+
130
+ Ctrl+C, SIGTERM and terminal hangup stop Chrome research gracefully: completed searches stay saved, the owned browser closes, and the research lock is released before exit. Repeat the same command to resume only pending work. A forced kill or power loss cannot run cleanup; if a stale lock remains, confirm that its process stopped before removing only `.research-lock` as the CLI error explains.
131
+
132
+ Create a JSON brief with company names or verified numeric LinkedIn company IDs. Use `--resolve-companies` for names. Each subsidiary must resolve to its own ID; unresolved identities stay in the coverage report and are never guessed.
133
+
134
+ For a name-only list, the executable end-to-end research mode is:
135
+
136
+ ```bash
137
+ indiecrm leads:at-companies --brief companies.json --out-dir ./research --resolve-companies --all
138
+ ```
139
+
140
+ The CLI first collects prepared IDs, then resolves missing IDs through paginated Account Search and immediately collects their contacts. It tries up to three name queries, normalizing only presentation/legal-suffix differences. Automatic resolution requires a unique matching identity in a complete result set of at most 1,000 companies plus matching native company-detail evidence. Fuzzy, shared-ID, oversized, ambiguous and composite parent/subsidiary targets remain unresolved with diagnostics in `recovery.json`. Resolutions and misses are saved in the workspace-bound checkpoint and `company-resolutions.json`, without editing the input brief. Repeat the command to resume. Add `--retry-unresolved --resolve-companies` to intentionally retry saved identity misses without repeating successful work; add `--company` to narrow the retry. `--all` removes the invocation's job-count limit, not LinkedIn pacing or per-function candidate limits. A working LinkedIn session is required for new requests; use `--browser chrome` to eliminate the external browser-worker dependency.
141
+
142
+ ```bash
143
+ indiecrm leads:at-companies --brief companies.json --out-dir ./research \
144
+ --resolve-companies --retry-unresolved --all --wait-for-session 300
145
+ ```
146
+
147
+ ```json
148
+ {
149
+ "companies": [
150
+ { "name": "Your target company", "companyId": "123", "maxContacts": 20 },
151
+ { "name": "Company awaiting identity review", "maxContacts": 10 }
152
+ ],
153
+ "candidatesPerDepartment": 50
154
+ }
155
+ ```
156
+
157
+ ```bash
158
+ indiecrm leads:at-companies --brief companies.json --dry-run
159
+ indiecrm leads:at-companies --brief companies.json --out-dir ./company-research
160
+ # Repeat to resume the next batch; completed searches are reused.
161
+ indiecrm leads:at-companies --brief companies.json --out-dir ./company-research
162
+ ```
163
+
164
+ The default six functions are Digital Marketing & CRM, Digital Product & UX, Software Development, IT, Data & AI, and HR. Override them with `departments: [{ "name": "Security", "terms": ["security", "ciso"] }]`. Optional `regionIds` applies explicit Sales Navigator geography filters; no country is inferred from a company name or headquarters.
165
+
166
+ Contacts must match the current company ID, a senior title and the requested function. Selection alternates between functions, deduplicates profile URLs and never pads a company to its ceiling. The default ceiling is 20; each company can override it. Head-of roles classified as experienced managers are searched, but generic manager titles do not pass the final seniority check.
167
+
168
+ Default role matching includes talent acquisition, business intelligence, digital workplace and German department-head titles. Role policy 2 requires software-specific context for Software Development and digital/UX context for Digital Product & UX; generic engineering, physical-vehicle product roles and technical flight/hydraulic data do not automatically qualify. Broad digital/transformation, communications and operational-technology matches go into `review.csv`, not the selected contacts. Mixed-role headlines cannot borrow seniority from an assistant or former role. Current-position evidence overrides a conflicting summary employer or headline. Reports include `rolePolicyVersion`; review rows retain every applicable `reasons` entry as well as the primary `reason`.
169
+
170
+ The export retains the source employer name and accepted alias evidence. Unverified employer-name differences go to review: verify equivalent canonical names with `--verify-employers`, or supply an evidence-backed review decision for non-equivalent names. Parent/subsidiary scope is never inferred.
171
+
172
+ For custom functions, add `reviewTerms` (a subset of `terms`) for discovery terms that should require manual review: `{"name":"IT","terms":["it","digital"],"reviewTerms":["digital"]}`. A specific non-review term in the same senior-role clause can qualify a contact.
173
+
174
+ Outputs are private local `contacts.csv`, `review.csv`, `coverage.json` (including review candidates, rejected/unresolved matches and per-function counts), `recovery.json`, `company-resolutions.json`, and `checkpoint.json`. Review counts are candidate/function pairs, not additional unique leads. A completed search batch is not exhaustive coverage: ordinary collection stops at `candidatesPerDepartment`; use `--continue-partial` explicitly to advance. The report records LinkedIn's reported total and any shortfall. Default `--max-searches 12` bounds collection jobs and resolution attempts; employer-detail verification is scoped separately with `--company`. Increase job limits explicitly for larger batches. HTTP 429/999 stops the run and preserves saved pages; resume only after cooldown.
175
+
176
+ Live research requires Salesprompter workspace login and a LinkedIn session. `--browser-relay-port` uses the existing signed-in browser relay. Checkpoints are bound to both the brief and workspace; changed criteria require a new output directory. A concurrent run is refused; after a crash, remove only `.research-lock` after confirming the process stopped. This command does not import the shortlist into the workspace, find emails, or create campaigns. Review company identity, role fit and coverage before downstream use.
177
+
178
+ - guided setup and auth
179
+ - product and market discovery
180
+ - Sales Navigator search orchestration
181
+ - affiliate audience campaigns from affiliate links and LinkedIn searches
182
+ - contact and company enrichment
183
+ - email-enrichment batch preparation
184
+ - scored-lead sync into downstream systems
185
+
186
+ ## Common commands
187
+
188
+ ```bash
189
+ # Start the guided workflow
190
+ indiecrm wizard
191
+
192
+ # Show machine-readable output
193
+ indiecrm --json auth:whoami
194
+
195
+ # Resolve profile URLs for a pasted contact list
196
+ indiecrm contacts:find-linkedin-urls --in ./contacts.tsv --out ./contacts.enriched.json
197
+
198
+ # Resolve LinkedIn company URLs for a pasted company list
199
+ indiecrm companies:resolve-linkedin-urls --in ./companies.txt --out ./companies.enriched.json
200
+
201
+ # Build and start email enrichment from pasted company/contact rows
202
+ # When authenticated, Salesprompter first enriches company domains in the app, then the CLI builds the email batch.
203
+ indiecrm contacts:resolve-emails --in ./contacts.tsv --out-dir ./email-run --dry-run
204
+
205
+ # Collect a Sales Navigator people search into the active workspace
206
+ # Normal people searches use 2,500-result slices; Connections-of searches use 1,000.
207
+ # Oversized searches split by company headcount and deduplicate by canonical profile URL.
208
+ # LinkedIn headline drift is retained as metadata after the complete partition is proven.
209
+ # Exhaustive runs resume completed slices automatically after interruptions or rate limits.
210
+ indiecrm leads:collect \
211
+ --linkedin-url "$SALES_NAV_PEOPLE_URL"
212
+
213
+ # For large discovery runs, store the leads now and enrich selected imports later
214
+ indiecrm leads:collect \
215
+ --linkedin-url "$SALES_NAV_PEOPLE_URL" \
216
+ --defer-email-enrichment
217
+
218
+ # Enrich one or more stored imports: Sales Navigator company -> official domain -> Hunter email
219
+ # This updates workspace data only. It does not create or start outreach.
220
+ # Stale ids and raw legal-name misses use conservative exact-name recovery.
221
+ # Hunter prefers official domains and can fall back to the exact company name.
222
+ indiecrm leads:enrich-import \
223
+ --run-id "$CLI_IMPORT_RUN_ID"
224
+
225
+ indiecrm affiliate:launch \
226
+ --affiliate-link "https://example.com/?ref=you" \
227
+ --linkedin-url "https://www.linkedin.com/sales/search/people?query=..." \
228
+ --max-results 100 \
229
+ --dry-run
230
+
231
+ # Collect a Sales Navigator people audience locally and create its campaign page
232
+ indiecrm affiliate:launch \
233
+ --affiliate-link "https://example.com/?ref=you" \
234
+ --linkedin-url "https://www.linkedin.com/sales/search/people?query=..." \
235
+ --max-results 100
236
+
237
+ # End to end: collect, exclude customers, deduplicate, verify, enrich, and create a draft
238
+ indiecrm affiliate:grow \
239
+ --affiliate-link "https://example.com/?ref=you" \
240
+ --linkedin-url "https://www.linkedin.com/sales/search/people?query=..." \
241
+ --max-results 100 \
242
+ --seed-profile "https://www.linkedin.com/in/existing-customer" \
243
+ --steps 3 \
244
+ --timing-mode custom \
245
+ --daily-limit 25 \
246
+ --send-from 09:00 \
247
+ --send-to 16:00
248
+
249
+ # Verify live campaign state, lead safety, and contact activity, then activate explicitly
250
+ indiecrm affiliate:list
251
+ indiecrm affiliate:status "$AFFILIATE_RUN_ID"
252
+ indiecrm affiliate:finish "$AFFILIATE_RUN_ID"
253
+ indiecrm affiliate:analytics "$AFFILIATE_RUN_ID"
254
+ indiecrm affiliate:regenerate-sequence "$AFFILIATE_RUN_ID"
255
+ indiecrm affiliate:enrich "$AFFILIATE_RUN_ID"
256
+ indiecrm affiliate:enrich "$AFFILIATE_RUN_ID" \
257
+ --source-audience-run-id "$NEW_AUDIENCE_RUN_ID"
258
+ indiecrm affiliate:activate "$AFFILIATE_RUN_ID"
259
+
260
+ # See included capability packs
261
+ indiecrm packs:list
262
+
263
+ # Explore the full command surface
264
+ indiecrm --help
265
+ ```
266
+
267
+ Collect an exact signed-in LinkedIn product-search order locally, without uploading it:
268
+
269
+ ```bash
270
+ indiecrm products:collect \
271
+ --query-url "$LINKEDIN_PRODUCTS_URL" \
272
+ --checkpoint ./data/products.checkpoint.json \
273
+ --raw-jsonl ./data/products.raw.jsonl \
274
+ --out ./data/products.complete.json \
275
+ --relay-port 43117
276
+ ```
277
+
278
+ Use a signed-in in-app-browser worker with the loopback `GET /task` and `POST /page` protocol. Interrupted, bounded, challenged, or rate-limited runs remain checkpointed and do not create the complete artifact.
279
+
280
+ Enrich that proven order with public product-page details, without changing its ranks or uploading it:
281
+
282
+ ```bash
283
+ indiecrm products:collect-details \
284
+ --catalog ./data/products.complete.json \
285
+ --checkpoint ./data/products.details.checkpoint.json \
286
+ --raw-jsonl ./data/products.details.raw.jsonl \
287
+ --out ./data/products.with-details.json \
288
+ --relay-port 43118
289
+ ```
290
+
291
+ The detail worker uses the same local `GET /task` lease protocol and posts only allowlisted product fields to `POST /detail`. Public URLs must be HTTPS; product images are capped at 2,048 characters, other URLs at 4,096, descriptions at 10,000, other strings at 200, roles/features at 100 entries, and customer companies at 50. The final artifact is created only after every rank has either a complete detail page or explicit unavailable-page proof.
292
+
293
+ Download stored workspace leads without starting a new Sales Navigator scrape:
294
+
295
+ ```bash
296
+ indiecrm leads:download --out ./salesprompter-leads.csv
297
+ indiecrm leads:download --run-id <uuid> --emails-only --out ./run-leads.csv
298
+ ```
299
+
300
+ Use `--search <value>` to match a person, title, or company. The command uses the
301
+ active Salesprompter workspace and downloads the same filtered data available in
302
+ the app's CLI imports view.
303
+
304
+ ## Output modes
305
+
306
+ - `--json` for machine-readable output
307
+ - `--quiet` to suppress successful payload output
308
+
309
+ ## Notes
310
+
311
+ - Use your own authorized data access and workspace credentials.
312
+ - Respect provider terms and customer data boundaries.
313
+ - `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.
314
+ - 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.
315
+ - 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.
316
+ - Affiliate preparation uses exactly three emails with three observable variants per step; `affiliate:regenerate-sequence` rebuilds and syncs them through the Salesprompter app.
317
+ - 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.
318
+ - 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.
319
+ - `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`.
320
+ - `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.
321
+ - `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.
322
+ - `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.
323
+ - A draft campaign does not send until `affiliate:activate` is run.
324
+ - The CLI is designed for interactive users and agent-assisted workflows.
325
+ - A repo-local Chrome extension compatibility copy is available in `chrome-extension/` for local LinkedIn session sync and popup copy/debug flows.
326
+
327
+ ## Development
328
+
329
+ ```bash
330
+ npm install
331
+ npm run build
332
+ npm test
333
+ ```
334
+
335
+ For contribution and security processes, see:
336
+
337
+ - [CONTRIBUTING.md](./CONTRIBUTING.md)
338
+ - [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md)
339
+ - [SECURITY.md](./SECURITY.md)
340
+
341
+ ## Links
342
+
343
+ - Docs: https://salesprompter.ai/docs
344
+ - App: https://salesprompter.ai
345
+ - CLI repository: https://github.com/danielsinewe/indiecrm-cli
346
+ - Original research source: https://github.com/danielsinewe/salesprompter-cli
package/SECURITY.md ADDED
@@ -0,0 +1,35 @@
1
+ # Security Policy
2
+
3
+ ## Supported versions
4
+
5
+ Security fixes are applied to the latest published CLI version.
6
+
7
+ If you are reporting a vulnerability, please verify it against the latest release first when possible.
8
+
9
+ ## Reporting a vulnerability
10
+
11
+ Please do not report security vulnerabilities through public GitHub issues.
12
+
13
+ Send reports to:
14
+
15
+ - `hello@danielsinewe.com`
16
+
17
+ Include:
18
+
19
+ - affected version
20
+ - reproduction steps
21
+ - impact assessment
22
+ - logs, screenshots, or proof of concept if available
23
+
24
+ ## Response expectations
25
+
26
+ We aim to:
27
+
28
+ - acknowledge receipt promptly
29
+ - reproduce and assess the report
30
+ - ship a fix or mitigation when confirmed
31
+ - coordinate disclosure when appropriate
32
+
33
+ ## Sensitive data
34
+
35
+ This CLI may operate on CRM data, research outputs, workspace metadata, and separate IndieCRM and Salesprompter authenticated sessions. Please avoid sending real customer data unless it is necessary to reproduce the issue.
@@ -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
+ }