argorant 0.12.0 → 0.14.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.
- package/README.md +106 -1
- package/bin/argorant.js +1317 -71
- package/package.json +3 -2
package/bin/argorant.js
CHANGED
|
@@ -181,6 +181,39 @@ function parseArgs(argv) {
|
|
|
181
181
|
return out;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
// ---- Idempotency-Key on every write to /api/v1 ----
|
|
185
|
+
// A retried command must never do the work twice (a second export, a second
|
|
186
|
+
// reply, a second charge). Every POST/PUT/PATCH/DELETE to /api/v1 carries a
|
|
187
|
+
// key: a fresh random one by default, or the one given with
|
|
188
|
+
// --idempotency-key. With an explicit key the first write of the command uses
|
|
189
|
+
// it as given and later writes in the same command use <key>-2, <key>-3, so
|
|
190
|
+
// running the same command again with the same key replays every step.
|
|
191
|
+
const IDEM = { override: null, count: 0 };
|
|
192
|
+
const IDEM_KEY_RE = /^[A-Za-z0-9._:-]{1,120}$/;
|
|
193
|
+
function nextIdempotencyKey() {
|
|
194
|
+
IDEM.count += 1;
|
|
195
|
+
if (IDEM.override) return IDEM.count === 1 ? IDEM.override : `${IDEM.override}-${IDEM.count}`;
|
|
196
|
+
return crypto.randomUUID();
|
|
197
|
+
}
|
|
198
|
+
// Pull --idempotency-key out of argv before any parser sees it (every command
|
|
199
|
+
// accepts it). Returns argv without the flag.
|
|
200
|
+
function takeIdempotencyFlag(argv) {
|
|
201
|
+
const out = [];
|
|
202
|
+
for (let i = 0; i < argv.length; i++) {
|
|
203
|
+
const a = argv[i];
|
|
204
|
+
let v = null;
|
|
205
|
+
if (a === "--idempotency-key") { v = flagValue(argv, i, a); i += 1; }
|
|
206
|
+
else if (a.startsWith("--idempotency-key=")) v = a.slice("--idempotency-key=".length);
|
|
207
|
+
else { out.push(a); continue; }
|
|
208
|
+
if (!IDEM_KEY_RE.test(v)) die(`--idempotency-key may use letters, digits and . _ : - (1 to 120 characters), got "${v}".`);
|
|
209
|
+
IDEM.override = v;
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
function isV1Write(method, pathname) {
|
|
214
|
+
return !["GET", "HEAD", "OPTIONS"].includes(String(method).toUpperCase()) && /^\/api\/v1(\/|$)/.test(pathname);
|
|
215
|
+
}
|
|
216
|
+
|
|
184
217
|
// ---- HTTP ----
|
|
185
218
|
function request(method, base, urlPath, { key, query, body, headers: extraHeaders } = {}) {
|
|
186
219
|
return new Promise((resolve, reject) => {
|
|
@@ -198,47 +231,92 @@ function request(method, base, urlPath, { key, query, body, headers: extraHeader
|
|
|
198
231
|
const payload = body ? Buffer.from(JSON.stringify(body)) : null;
|
|
199
232
|
const headers = { Accept: "application/json", "User-Agent": `argorant-cli/${VERSION}`, ...(extraHeaders || {}) };
|
|
200
233
|
if (key) headers["Authorization"] = `Bearer ${key}`;
|
|
234
|
+
// Lets the server's purchase audit say a purchase came from the command line.
|
|
235
|
+
if (/^\/api\/v1(\/|$)/.test(u.pathname)) headers["X-Argorant-Channel"] = "cli";
|
|
236
|
+
if (isV1Write(method, u.pathname)) {
|
|
237
|
+
const own = Object.keys(headers).find((h) => h.toLowerCase() === "idempotency-key");
|
|
238
|
+
if (!own || IDEM.override) {
|
|
239
|
+
if (own) delete headers[own];
|
|
240
|
+
headers["Idempotency-Key"] = nextIdempotencyKey();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
201
243
|
if (payload) {
|
|
202
244
|
headers["Content-Type"] = "application/json";
|
|
203
245
|
headers["Content-Length"] = payload.length;
|
|
204
246
|
}
|
|
205
247
|
const lib = u.protocol === "http:" ? http : https;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
248
|
+
// The per-minute request limit: wait for the time the server names and send
|
|
249
|
+
// the SAME request again (same Idempotency-Key, so a write never runs twice).
|
|
250
|
+
// Longer waits (a daily limit) are not waited out; they surface as exit 4.
|
|
251
|
+
const send = (attempt) => {
|
|
252
|
+
const req = lib.request(
|
|
253
|
+
u,
|
|
254
|
+
{ method, headers },
|
|
255
|
+
(res) => {
|
|
256
|
+
const chunks = [];
|
|
257
|
+
res.on("data", (d) => chunks.push(d));
|
|
258
|
+
res.on("end", () => {
|
|
259
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
260
|
+
let json = null;
|
|
261
|
+
try {
|
|
262
|
+
json = raw ? JSON.parse(raw) : null;
|
|
263
|
+
} catch {
|
|
264
|
+
/* non-JSON (e.g. CSV download) */
|
|
265
|
+
}
|
|
266
|
+
const wait = res.statusCode === 429 ? retryWaitSeconds(res.headers["retry-after"], attempt) : null;
|
|
267
|
+
if (wait !== null) {
|
|
268
|
+
warn(`too many requests this minute; waiting ${wait} second${wait === 1 ? "" : "s"}, then trying again.`);
|
|
269
|
+
setTimeout(() => send(attempt + 1), wait * 1000);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
resolve({ status: res.statusCode, json, raw, res });
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
);
|
|
276
|
+
req.on("error", reject);
|
|
277
|
+
if (payload) req.write(payload);
|
|
278
|
+
req.end();
|
|
279
|
+
};
|
|
280
|
+
send(0);
|
|
227
281
|
});
|
|
228
282
|
}
|
|
229
283
|
|
|
284
|
+
// How long to wait before trying a 429 again, or null to give up and report it.
|
|
285
|
+
// At most RATE_RETRY.attempts tries, each wait at most RATE_RETRY.maxWait
|
|
286
|
+
// seconds (ARGORANT_MAX_RETRY_WAIT; 0 switches the retry off).
|
|
287
|
+
const RATE_RETRY = {
|
|
288
|
+
attempts: 3,
|
|
289
|
+
maxWait: (() => {
|
|
290
|
+
const v = Number(process.env.ARGORANT_MAX_RETRY_WAIT);
|
|
291
|
+
return Number.isFinite(v) && v >= 0 ? v : 65;
|
|
292
|
+
})(),
|
|
293
|
+
};
|
|
294
|
+
function retryWaitSeconds(header, attempt) {
|
|
295
|
+
if (attempt >= RATE_RETRY.attempts || RATE_RETRY.maxWait <= 0) return null;
|
|
296
|
+
const n = Math.ceil(Number(header));
|
|
297
|
+
if (!Number.isFinite(n) || n < 0 || n > RATE_RETRY.maxWait) return null;
|
|
298
|
+
return Math.max(1, n);
|
|
299
|
+
}
|
|
300
|
+
|
|
230
301
|
function downloadTo(base, urlPath, key, dest) {
|
|
231
302
|
return new Promise((resolve, reject) => {
|
|
232
303
|
const u = new URL(urlPath, base);
|
|
233
304
|
const lib = u.protocol === "http:" ? http : https;
|
|
234
305
|
const req = lib.request(
|
|
235
306
|
u,
|
|
236
|
-
{ method: "GET", headers: { Authorization: `Bearer ${key}`, "User-Agent": `argorant-cli/${VERSION}
|
|
307
|
+
{ method: "GET", headers: { Authorization: `Bearer ${key}`, "User-Agent": `argorant-cli/${VERSION}`, ...(/^\/api\/v1(\/|$)/.test(u.pathname) ? { "X-Argorant-Channel": "cli" } : {}) } },
|
|
237
308
|
(res) => {
|
|
238
309
|
if (res.statusCode !== 200) {
|
|
239
310
|
const chunks = [];
|
|
240
311
|
res.on("data", (d) => chunks.push(d));
|
|
241
|
-
res.on("end", () =>
|
|
312
|
+
res.on("end", () => {
|
|
313
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
314
|
+
let json = null;
|
|
315
|
+
try { json = raw ? JSON.parse(raw) : null; } catch { /* plain text */ }
|
|
316
|
+
const msg = errorMessage(json) || raw.slice(0, 300);
|
|
317
|
+
const rid = requestIdOf({ res, json });
|
|
318
|
+
reject(new Error(`download failed (HTTP ${res.statusCode}): ${msg}${rid ? ` (request id ${rid})` : ""}`));
|
|
319
|
+
});
|
|
242
320
|
return;
|
|
243
321
|
}
|
|
244
322
|
const file = fs.createWriteStream(dest);
|
|
@@ -279,26 +357,82 @@ function detailMsg(detail) {
|
|
|
279
357
|
}
|
|
280
358
|
return JSON.stringify(detail);
|
|
281
359
|
}
|
|
360
|
+
// Errors from /api/v1 come as one envelope:
|
|
361
|
+
// {"error": {"code", "message", "details"}, "request_id": "req_...", "detail": <old value>}
|
|
362
|
+
// Older endpoints answer {"detail": ...} or a bare {"error": "<code>", "message"}.
|
|
363
|
+
// errorMessage() reads all three; the envelope wins when present.
|
|
364
|
+
function errorMessage(json) {
|
|
365
|
+
if (!json || typeof json !== "object" || Array.isArray(json)) return null;
|
|
366
|
+
const e = json.error;
|
|
367
|
+
if (e && typeof e === "object" && !Array.isArray(e)) {
|
|
368
|
+
let msg = e.message ? String(e.message) : e.code ? String(e.code).replace(/_/g, " ") : null;
|
|
369
|
+
const d = e.details && typeof e.details === "object" ? e.details : {};
|
|
370
|
+
const blockers = Array.isArray(d.blockers) ? d.blockers : [];
|
|
371
|
+
if (blockers.length) {
|
|
372
|
+
msg = (msg || "blocked") + "\n" + blockers.map((b) => ` · ${typeof b === "string" ? b : b && (b.message || b.label) ? b.message || b.label : JSON.stringify(b)}`).join("\n");
|
|
373
|
+
}
|
|
374
|
+
const fields = Array.isArray(d.fields) ? d.fields : [];
|
|
375
|
+
if (fields.length && !blockers.length) {
|
|
376
|
+
const named = fields
|
|
377
|
+
.map((f) => (typeof f === "string" ? f : f && (f.field || f.name) ? `${f.field || f.name}${f.message ? `: ${f.message}` : ""}` : null))
|
|
378
|
+
.filter(Boolean);
|
|
379
|
+
if (named.length && !(msg || "").includes(named[0])) msg = `${msg || "invalid request"} (${named.join("; ")})`;
|
|
380
|
+
}
|
|
381
|
+
if (msg) return msg;
|
|
382
|
+
}
|
|
383
|
+
if (typeof e === "string" && json.message) return String(json.message);
|
|
384
|
+
return detailMsg(json.detail) || (typeof e === "string" ? e.replace(/_/g, " ") : null);
|
|
385
|
+
}
|
|
386
|
+
function errorCode(json) {
|
|
387
|
+
if (!json || typeof json !== "object" || Array.isArray(json)) return null;
|
|
388
|
+
const e = json.error;
|
|
389
|
+
if (e && typeof e === "object" && !Array.isArray(e)) return e.code ? String(e.code) : null;
|
|
390
|
+
if (typeof e === "string") return e;
|
|
391
|
+
const d = json.detail;
|
|
392
|
+
if (d && typeof d === "object" && !Array.isArray(d)) return d.error || d.code || null;
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
// The id the server gave this request (header X-Request-Id, or request_id in
|
|
396
|
+
// the error body). Printed with every error so a user can quote it to support.
|
|
397
|
+
function requestIdOf(res) {
|
|
398
|
+
const h = res && res.res && res.res.headers;
|
|
399
|
+
const fromHeader = h && (h["x-request-id"] || h["X-Request-Id"]);
|
|
400
|
+
return String(fromHeader || (res && res.json && typeof res.json === "object" && res.json.request_id) || "") || null;
|
|
401
|
+
}
|
|
402
|
+
|
|
282
403
|
// Exit codes are part of the CLI's contract with agents/CI:
|
|
283
404
|
// 0 ok · 1 generic failure · 2 not authenticated · 3 forbidden (scope)
|
|
284
405
|
// 4 rate limit / daily quota · 5 plan upgrade required (402)
|
|
285
406
|
const EXIT = { OK: 0, ERROR: 1, AUTH: 2, FORBIDDEN: 3, RATE_LIMIT: 4, UPGRADE: 5 };
|
|
286
407
|
|
|
287
408
|
function need(res, what) {
|
|
409
|
+
if (res.status < 400) return res.json || {};
|
|
288
410
|
const detail = res.json && res.json.detail;
|
|
289
|
-
|
|
411
|
+
const msg = errorMessage(res.json);
|
|
412
|
+
const code = errorCode(res.json);
|
|
413
|
+
const rid = requestIdOf(res);
|
|
414
|
+
const ref = rid ? `\nRequest id: ${rid} (quote it if you contact support)` : "";
|
|
415
|
+
if (res.status === 401) {
|
|
416
|
+
const lead = code && code !== "unauthorized" && msg ? `${msg}\n` : "";
|
|
417
|
+
die(`${lead}not authenticated. Run \`argorant login\` or set ARGORANT_API_KEY.${ref}`, EXIT.AUTH);
|
|
418
|
+
}
|
|
290
419
|
if (res.status === 402) {
|
|
291
420
|
// Distinct from a hard error: nothing is broken, the plan just doesn't
|
|
292
421
|
// include this. Agents branch on exit 5 to surface an upgrade, not a bug.
|
|
293
422
|
const d = detail && typeof detail === "object" && !Array.isArray(detail) ? detail : {};
|
|
294
|
-
const
|
|
295
|
-
const
|
|
296
|
-
|
|
423
|
+
const details = res.json && res.json.error && typeof res.json.error === "object" ? res.json.error.details || {} : {};
|
|
424
|
+
const text = msg || `${what} requires a paid Argorant plan.`;
|
|
425
|
+
const url = details.upgrade_url || d.upgrade_url || d.url || "https://argorant.com/pricing";
|
|
426
|
+
die(`${text}${text.includes(url) ? "" : `\nUpgrade: ${url}`}${ref}`, EXIT.UPGRADE);
|
|
297
427
|
}
|
|
298
|
-
if (res.status === 403) die(
|
|
299
|
-
if (res.status === 429)
|
|
300
|
-
|
|
301
|
-
|
|
428
|
+
if (res.status === 403) die(`${msg || `forbidden - your key lacks the scope for ${what}.`}${ref}`, EXIT.FORBIDDEN);
|
|
429
|
+
if (res.status === 429) {
|
|
430
|
+
const h = res.res && res.res.headers;
|
|
431
|
+
const ra = Number((h && h["retry-after"]) || 0);
|
|
432
|
+
const wait = Number.isFinite(ra) && ra > 0 ? ` Try again in ${ra === 1 ? "1 second" : fmtWait(Math.ceil(ra))}.` : "";
|
|
433
|
+
die(`${msg || "too many requests right now, or today's limit is used up."}${wait}${ref}`, EXIT.RATE_LIMIT);
|
|
434
|
+
}
|
|
435
|
+
die(`${msg || `${what} failed (HTTP ${res.status}).`}${ref}`);
|
|
302
436
|
}
|
|
303
437
|
|
|
304
438
|
// A paid job must never die on a path problem AFTER it was billed: check the
|
|
@@ -598,11 +732,12 @@ async function addPhones(args, key, records) {
|
|
|
598
732
|
});
|
|
599
733
|
if (res.status !== 200 || !res.json) {
|
|
600
734
|
const detail = res.json && res.json.detail;
|
|
601
|
-
|
|
602
|
-
|
|
735
|
+
// The error envelope's stable code first, then the older detail.error (servers before the envelope).
|
|
736
|
+
const code = errorCode(res.json);
|
|
737
|
+
let message = errorMessage(res.json) || detailMsg(detail) || `phone numbers failed (HTTP ${res.status}).`;
|
|
603
738
|
if (code === "phones_not_in_plan") message = "Your plan does not include phone numbers, so none were added and nothing was charged for them.";
|
|
604
739
|
else if (code === "insufficient_credits") message = "Not enough credits for a phone number, so none were added and nothing was charged for them.";
|
|
605
|
-
else if (code === "mcp_quota_exceeded") message = "Today's reveal limit is used up, so no phone numbers were added. It resets at midnight UTC.";
|
|
740
|
+
else if (code === "mcp_quota_exceeded" || code === "daily_limit_reached") message = "Today's reveal limit is used up, so no phone numbers were added. It resets at midnight UTC.";
|
|
606
741
|
for (const r of records) r.phone_status = "not_added";
|
|
607
742
|
return { included: false, status: res.status, error: code || "error", message, summary: message };
|
|
608
743
|
}
|
|
@@ -818,7 +953,20 @@ async function pollExportBatch(args, key, statusPath, { quiet = false } = {}) {
|
|
|
818
953
|
}
|
|
819
954
|
}
|
|
820
955
|
|
|
821
|
-
|
|
956
|
+
// Paths of the export endpoints. `export` keeps the paths it always used;
|
|
957
|
+
// `exports` (0.13.0) uses the same operations under /api/v1.
|
|
958
|
+
const EXPORT_API_MCP = {
|
|
959
|
+
job: (id) => `/api/mcp/exports/${id}`,
|
|
960
|
+
batch: (id) => `/api/mcp/export-batches/${id}`,
|
|
961
|
+
download: (id) => `/api/mcp/exports/${id}/download`,
|
|
962
|
+
};
|
|
963
|
+
const EXPORT_API_V1 = {
|
|
964
|
+
job: (id) => `/api/v1/exports/${id}`,
|
|
965
|
+
batch: (id) => `/api/v1/export-batches/${id}`,
|
|
966
|
+
download: (id) => `/api/v1/exports/${id}/download`,
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
async function downloadBatchChunks(args, key, batch, dest, api = EXPORT_API_MCP) {
|
|
822
970
|
const chunks = (batch.chunks || []).filter((c) => c.downloadable || EXPORT_TERMINAL_OK.includes(String(c.status || "").toLowerCase()));
|
|
823
971
|
if (!chunks.length) die("export batch reported done but returned no downloadable chunks.");
|
|
824
972
|
const files = [];
|
|
@@ -826,7 +974,7 @@ async function downloadBatchChunks(args, key, batch, dest) {
|
|
|
826
974
|
for (const chunk of chunks) {
|
|
827
975
|
i += 1;
|
|
828
976
|
const target = chunks.length === 1 ? dest : partPath(dest, chunk.chunk_index != null ? chunk.chunk_index + 1 : i);
|
|
829
|
-
const dl = chunk.download_api_path ||
|
|
977
|
+
const dl = chunk.download_url || chunk.download_api_path || api.download(chunk.job_id);
|
|
830
978
|
await downloadTo(args.base, dl, key, target);
|
|
831
979
|
files.push(target);
|
|
832
980
|
}
|
|
@@ -844,9 +992,9 @@ function countCsvRows(file) {
|
|
|
844
992
|
// `argorant export status <id>` / `argorant export download <id> -o file`:
|
|
845
993
|
// a paid export must never be unrecoverable because the CLI died after the
|
|
846
994
|
// job was created (bad path, ctrl-c, dropped connection).
|
|
847
|
-
async function exportStatusCmd(args, key, id) {
|
|
995
|
+
async function exportStatusCmd(args, key, id, api = EXPORT_API_MCP, cmdName = "export") {
|
|
848
996
|
const isBatch = args.batch;
|
|
849
|
-
const p = isBatch ?
|
|
997
|
+
const p = isBatch ? api.batch(id) : api.job(id);
|
|
850
998
|
const res = await request("GET", args.base, p, { key });
|
|
851
999
|
const s = need(res, "export status");
|
|
852
1000
|
if (args.json) return console.log(JSON.stringify(s, null, 2));
|
|
@@ -858,28 +1006,31 @@ async function exportStatusCmd(args, key, id) {
|
|
|
858
1006
|
console.log(` ${Number(s.verified_rows || 0).toLocaleString()}/${Number(s.total_rows || 0).toLocaleString()} rows · ${s.progress_pct || 0}%${s.downloadable ? green(" · ready to download") : ""}`);
|
|
859
1007
|
}
|
|
860
1008
|
if (s.error_message) console.log(red(" " + s.error_message));
|
|
861
|
-
if (s.downloadable) console.log(dim(` argorant
|
|
1009
|
+
if (s.downloadable) console.log(dim(` argorant ${cmdName} download ${id}${isBatch ? " --batch" : ""} -o leads.csv`));
|
|
862
1010
|
}
|
|
863
1011
|
|
|
864
|
-
async function exportDownloadCmd(args, key, id) {
|
|
1012
|
+
async function exportDownloadCmd(args, key, id, api = EXPORT_API_MCP, cmdName = "export") {
|
|
865
1013
|
const dest = ensureWritable(args.output || "argorant-leads.csv");
|
|
866
1014
|
if (args.batch) {
|
|
867
|
-
const res = await request("GET", args.base,
|
|
1015
|
+
const res = await request("GET", args.base, api.batch(id), { key });
|
|
868
1016
|
const b = need(res, "export batch status");
|
|
869
1017
|
if (!EXPORT_TERMINAL_OK.includes(String(b.status || "").toLowerCase())) {
|
|
870
|
-
die(`export batch #${id} is ${b.status || "not ready"} - run \`argorant
|
|
1018
|
+
die(`export batch #${id} is ${b.status || "not ready"} - run \`argorant ${cmdName} status ${id} --batch\`.`);
|
|
871
1019
|
}
|
|
872
|
-
const files = await downloadBatchChunks(args, key, b, dest);
|
|
1020
|
+
const files = await downloadBatchChunks(args, key, b, dest, api);
|
|
873
1021
|
if (args.json) return console.log(JSON.stringify({ ok: true, batch_id: id, files }, null, 2));
|
|
874
1022
|
for (const f of files) console.log(green("✓") + ` ${bold(String(countCsvRows(f) ?? "?"))} rows → ${bold(f)}`);
|
|
875
1023
|
return;
|
|
876
1024
|
}
|
|
877
|
-
const res = await request("GET", args.base,
|
|
1025
|
+
const res = await request("GET", args.base, api.job(id), { key });
|
|
878
1026
|
const s = need(res, "export status");
|
|
879
|
-
if (!s.downloadable && !s.download_api_path) {
|
|
880
|
-
|
|
1027
|
+
if (!s.downloadable && !s.download_api_path && !s.download_url) {
|
|
1028
|
+
if (EXPORT_TERMINAL_FAIL.includes(String(s.status || "").toLowerCase())) {
|
|
1029
|
+
die(`export #${id} ${String(s.status).toLowerCase() === "failed" ? "failed" : `was ${s.status}`}, so there is no file to download. Start a new export, or check it with \`argorant ${cmdName} status ${id}\`.`);
|
|
1030
|
+
}
|
|
1031
|
+
die(`export #${id} is ${s.status || "not ready"} - run \`argorant ${cmdName} status ${id}\`.`);
|
|
881
1032
|
}
|
|
882
|
-
await downloadTo(args.base, s.download_api_path ||
|
|
1033
|
+
await downloadTo(args.base, s.download_url || s.download_api_path || api.download(id), key, dest);
|
|
883
1034
|
if (args.json) return console.log(JSON.stringify({ ok: true, file: dest, job_id: id }, null, 2));
|
|
884
1035
|
const rows = countCsvRows(dest);
|
|
885
1036
|
console.log(green("✓") + ` Saved ${rows != null ? bold(rows.toLocaleString()) + " rows → " : ""}${bold(dest)}`);
|
|
@@ -1191,9 +1342,10 @@ function readStdin() {
|
|
|
1191
1342
|
});
|
|
1192
1343
|
}
|
|
1193
1344
|
|
|
1345
|
+
// Every campaign, a page at a time (the API pages its lists; an older server
|
|
1346
|
+
// answers one page without next_cursor and the loop stops there).
|
|
1194
1347
|
async function fetchCampaigns(base, key) {
|
|
1195
|
-
const
|
|
1196
|
-
const r = need(res, "campaigns list");
|
|
1348
|
+
const r = await fetchPages(base, key, "/api/v1/campaigns", { limit: 100 }, "campaigns", "campaigns list", true);
|
|
1197
1349
|
return r.campaigns || [];
|
|
1198
1350
|
}
|
|
1199
1351
|
|
|
@@ -1308,6 +1460,89 @@ async function campaignsEmails(argv) {
|
|
|
1308
1460
|
console.log(green("✓") + ` Saved ${(r.emails || emails).length} email(s) for ${dim(campaignId)}`);
|
|
1309
1461
|
}
|
|
1310
1462
|
|
|
1463
|
+
// subsequences: follow-ups a reply keyword starts (GET / POST /api/v1/campaigns/{id}/subsequences).
|
|
1464
|
+
// The server replaces the whole set: subsequences left out are deleted with anyone still in them,
|
|
1465
|
+
// so `set` asks before it drops one that still has people in progress (-y skips the question).
|
|
1466
|
+
const SUBSEQUENCES_USAGE =
|
|
1467
|
+
"usage: argorant campaigns subsequences list <campaign>\n" +
|
|
1468
|
+
" or: argorant campaigns subsequences set <campaign> --file subsequences.json [-y]\n" +
|
|
1469
|
+
" or: argorant campaigns subsequences clear <campaign> [-y]\n" +
|
|
1470
|
+
' subsequences.json: [{"name":"Interested","keywords":["interested","yes"],"emails":[{"body":"...","delay_days":0},{"body":"...","delay_days":3}]}]\n' +
|
|
1471
|
+
" Up to 5 subsequences, 1 to 10 keywords and 1 to 8 emails each. Email 1 defaults to the subject 'Re: {{thread_subject}}'.\n" +
|
|
1472
|
+
" Keywords also count in capitals, as a plural, with a small typo or inside a short sentence.\n" +
|
|
1473
|
+
" 'not interested' and replies that ask to stop never start one.";
|
|
1474
|
+
|
|
1475
|
+
function readSubsequencesFile(text, source) {
|
|
1476
|
+
let data;
|
|
1477
|
+
try { data = JSON.parse(text); } catch (e) { die(`${source}: not valid JSON (${e.message})`); }
|
|
1478
|
+
const items = Array.isArray(data) ? data : data && Array.isArray(data.subsequences) ? data.subsequences : null;
|
|
1479
|
+
if (!items) die(`${source}: expected a list of subsequences (or {"subsequences": [...]})`);
|
|
1480
|
+
if (items.length > 5) die(`${source}: a campaign can have up to 5 subsequences (got ${items.length})`);
|
|
1481
|
+
return items.map((item, i) => {
|
|
1482
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) die(`${source}: subsequence ${i + 1} is not an object`);
|
|
1483
|
+
const keywords = typeof item.keywords === "string" ? item.keywords.split(",") : item.keywords;
|
|
1484
|
+
if (!Array.isArray(keywords) || !keywords.some((k) => String(k || "").trim())) die(`${source}: subsequence ${i + 1} needs at least one keyword`);
|
|
1485
|
+
const emails = item.emails || item.steps;
|
|
1486
|
+
if (!Array.isArray(emails) || !emails.length) die(`${source}: subsequence ${i + 1} needs at least one email`);
|
|
1487
|
+
const out = { name: item.name, keywords: keywords.map((k) => String(k).trim()).filter(Boolean), emails };
|
|
1488
|
+
if (item.id) out.id = item.id;
|
|
1489
|
+
return out;
|
|
1490
|
+
});
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
function printSubsequences(r) {
|
|
1494
|
+
const subs = r.subsequences || [];
|
|
1495
|
+
console.log(dim(`Stop on reply: ${r.stop_on_reply === false ? "off" : "on"}`));
|
|
1496
|
+
if (!subs.length) return console.log(dim("No subsequences. Add them with `argorant campaigns subsequences set <campaign> --file subsequences.json`."));
|
|
1497
|
+
subs.forEach((s, i) => {
|
|
1498
|
+
const st = s.stats || {};
|
|
1499
|
+
console.log(`${bold(`${i + 1}. ${s.name}`)} ${dim(s.id || "")} ${s.status || ""}`);
|
|
1500
|
+
console.log(` keywords: ${(s.keywords || []).join(", ")}`);
|
|
1501
|
+
console.log(` emails: ${(s.steps || []).length} ${dim(`in progress ${st.in_progress || 0} replied ${st.replied || 0} finished ${st.finished || 0} sent ${st.emails_sent || 0}`)}`);
|
|
1502
|
+
for (const step of s.steps || []) {
|
|
1503
|
+
const preview = String(step.body || "").replace(/\s+/g, " ").slice(0, 70);
|
|
1504
|
+
console.log(dim(` ${step.step_number}. +${step.delay_days || 0}d ${step.subject || "(same thread)"} ${preview}`));
|
|
1505
|
+
}
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
async function campaignsSubsequences(argv) {
|
|
1510
|
+
const sub = argv[0];
|
|
1511
|
+
if (!["list", "get", "set", "clear"].includes(sub)) die(SUBSEQUENCES_USAGE);
|
|
1512
|
+
const key = requireKey();
|
|
1513
|
+
const args = readFlags(argv.slice(1), { "--file": "file" });
|
|
1514
|
+
const identifier = args._[0];
|
|
1515
|
+
if (!identifier) die(SUBSEQUENCES_USAGE);
|
|
1516
|
+
const campaignId = await resolveCampaign(args.base, key, identifier);
|
|
1517
|
+
const route = `/api/v1/campaigns/${campaignId}/subsequences`;
|
|
1518
|
+
if (sub === "list" || sub === "get") {
|
|
1519
|
+
const r = need(await request("GET", args.base, route, { key }), "campaigns subsequences");
|
|
1520
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
1521
|
+
return printSubsequences(r);
|
|
1522
|
+
}
|
|
1523
|
+
let items = [];
|
|
1524
|
+
if (sub === "set") {
|
|
1525
|
+
if (!args.file) die(SUBSEQUENCES_USAGE);
|
|
1526
|
+
const text = args.file === "-" ? await readStdin() : (() => { try { return fs.readFileSync(args.file, "utf8"); } catch { die(`cannot read file: ${args.file}`); } })();
|
|
1527
|
+
items = readSubsequencesFile(text, args.file === "-" ? "stdin" : args.file);
|
|
1528
|
+
}
|
|
1529
|
+
// What the replace would drop, and who is still inside it.
|
|
1530
|
+
const current = need(await request("GET", args.base, route, { key }), "campaigns subsequences");
|
|
1531
|
+
const keptIds = new Set(items.map((i) => i.id).filter(Boolean));
|
|
1532
|
+
const dropped = (current.subsequences || []).filter((s) => !keptIds.has(s.id));
|
|
1533
|
+
const busy = dropped.filter((s) => ((s.stats || {}).in_progress || 0) > 0);
|
|
1534
|
+
if (dropped.length && !args.yes) {
|
|
1535
|
+
const who = busy.length ? ` (${busy.map((s) => `${s.name}: ${s.stats.in_progress} in progress`).join(", ")})` : "";
|
|
1536
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1537
|
+
const ok = await new Promise((r) => rl.question(`This removes ${dropped.length} subsequence(s)${who}, with anyone still in them. Continue? [y/N] `, (a) => { rl.close(); r(/^y/i.test(a)); }));
|
|
1538
|
+
if (!ok) return console.log(dim("aborted"));
|
|
1539
|
+
}
|
|
1540
|
+
const r = need(await request("POST", args.base, route, { key, body: { subsequences: items } }), "campaigns subsequences set");
|
|
1541
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
1542
|
+
console.log(green("✓") + ` Saved ${(r.subsequences || []).length} subsequence(s) for ${dim(campaignId)}`);
|
|
1543
|
+
printSubsequences(r);
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1311
1546
|
// senders: which connected mailboxes send this campaign.
|
|
1312
1547
|
async function campaignsSenders(argv) {
|
|
1313
1548
|
const sub = argv[0];
|
|
@@ -1603,10 +1838,11 @@ async function campaignsDelete(argv) {
|
|
|
1603
1838
|
}
|
|
1604
1839
|
|
|
1605
1840
|
async function campaignsLeadsList(args, campaignId, key) {
|
|
1606
|
-
const query = {
|
|
1841
|
+
const query = { offset: args.offset || 0 };
|
|
1607
1842
|
if (args.status) query.status = args.status;
|
|
1608
1843
|
if (args.q) query.q = args.q;
|
|
1609
|
-
const
|
|
1844
|
+
const want = args.limit ? parseLimit(args.limit, "-n") : 50;
|
|
1845
|
+
const r = await fetchUpTo(args.base, key, `/api/v1/campaigns/${campaignId}/leads`, query, "leads", "campaigns leads", want);
|
|
1610
1846
|
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
1611
1847
|
console.log(dim(`${r.total} lead(s)`));
|
|
1612
1848
|
for (const l of r.leads || []) console.log(`${l.email} ${dim([l.first_name, l.last_name].filter(Boolean).join(" ") || "")} ${l.status}${l.current_step ? dim(` step ${l.current_step}`) : ""}${l.replied_at ? green(" replied") : ""}${l.bounced_at ? red(" bounced") : ""}`);
|
|
@@ -1626,7 +1862,8 @@ async function campaignsReplies(argv) {
|
|
|
1626
1862
|
const identifier = args._[0];
|
|
1627
1863
|
if (!identifier) die("usage: argorant campaigns replies <campaign> [-n 50]");
|
|
1628
1864
|
const campaignId = await resolveCampaign(args.base, key, identifier);
|
|
1629
|
-
const
|
|
1865
|
+
const want = args.limit ? parseLimit(args.limit, "-n") : 50;
|
|
1866
|
+
const r = await fetchUpTo(args.base, key, `/api/v1/campaigns/${campaignId}/replies`, {}, "replies", "campaigns replies", want);
|
|
1630
1867
|
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
1631
1868
|
if (!(r.replies || []).length) return console.log(dim("No replies yet."));
|
|
1632
1869
|
for (const e of r.replies) {
|
|
@@ -1640,11 +1877,12 @@ async function cmdInboxThreads(argv) {
|
|
|
1640
1877
|
const key = requireKey();
|
|
1641
1878
|
if (sub === "list") {
|
|
1642
1879
|
const args = readFlags(argv.slice(1), { "--folder": "folder", "--campaign": "campaign", "--classification": "classification", "--q": "q", "-n": "limit", "--limit": "limit" });
|
|
1643
|
-
const query = { folder: args.folder || "replies"
|
|
1880
|
+
const query = { folder: args.folder || "replies" };
|
|
1644
1881
|
if (args.classification) query.classification = args.classification;
|
|
1645
1882
|
if (args.q) query.q = args.q;
|
|
1646
1883
|
if (args.campaign) query.campaign_id = await resolveCampaign(args.base, key, args.campaign);
|
|
1647
|
-
const
|
|
1884
|
+
const want = args.limit ? parseLimit(args.limit, "-n") : 50;
|
|
1885
|
+
const r = await fetchUpTo(args.base, key, "/api/v1/inbox", query, "events", "inbox", want);
|
|
1648
1886
|
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
1649
1887
|
if (!(r.events || []).length) return console.log(dim(`No conversations in ${query.folder}.`));
|
|
1650
1888
|
for (const e of r.events) {
|
|
@@ -1718,7 +1956,7 @@ async function cmdBlocklist(argv) {
|
|
|
1718
1956
|
const key = requireKey();
|
|
1719
1957
|
if (sub === "list") {
|
|
1720
1958
|
const args = readFlags(argv.slice(1));
|
|
1721
|
-
const r =
|
|
1959
|
+
const r = await fetchPages(args.base, key, "/api/v1/blocklist", { limit: 100 }, "entries", "blocklist", true);
|
|
1722
1960
|
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
1723
1961
|
if (!(r.entries || []).length) return console.log(dim("Blocklist is empty."));
|
|
1724
1962
|
for (const e of r.entries) console.log(`${e.type === "domain" ? "@" : " "}${e.value} ${dim(e.reason || "")}${e.yours ? "" : dim(" (Argorant-wide)")} ${dim(e.id)}`);
|
|
@@ -1757,6 +1995,9 @@ ${bold("USAGE")}
|
|
|
1757
1995
|
--gap 60-120 --unsubscribe|--no-unsubscribe --start YYYY-MM-DD --tags a,b --name "<n>"
|
|
1758
1996
|
${p} emails set <campaign> --file emails.json
|
|
1759
1997
|
${p} emails set <campaign> --step <n> --subject "..." (--body-file <path> | --body <text|->) [--delay-days <d>]
|
|
1998
|
+
${p} subsequences list <campaign>
|
|
1999
|
+
${p} subsequences set <campaign> --file subsequences.json [-y] ${dim("reply keywords start follow-ups in the same thread")}
|
|
2000
|
+
${p} subsequences clear <campaign> [-y]
|
|
1760
2001
|
${p} leads add <campaign> --list <list-id> | --csv <file> [--include-catch-all]
|
|
1761
2002
|
${p} leads list <campaign> [--status replied] [--q text] [-n 50]
|
|
1762
2003
|
${p} leads remove <campaign> <email>
|
|
@@ -1774,6 +2015,10 @@ ${bold("USAGE")}
|
|
|
1774
2015
|
${bold("argorant inboxes")} set <mailbox> [--daily-limit 40] [--warmup|--no-warmup] [--active|--inactive] [--timezone <tz>]
|
|
1775
2016
|
${bold("argorant inboxes")} disconnect <mailbox>
|
|
1776
2017
|
${bold("argorant inboxes")} connect-google --admin <admin@yourdomain.com> [--emails a,b | --all]
|
|
2018
|
+
${bold("argorant inboxes")} connect-smtp --email <address> --preset <key> [--name "Sender"] [--password-env NAME] ${dim("any provider; --smtp-host/--imap-host without a preset")}
|
|
2019
|
+
${bold("argorant inboxes")} connect-smtp --csv mailboxes.csv ${dim("many at once, 25 per request, a result per row")}
|
|
2020
|
+
${bold("argorant inboxes")} test-smtp --email <address> --preset <key> ${dim("checks sending and reading replies, saves nothing")}
|
|
2021
|
+
${bold("argorant inboxes")} presets ${dim("provider keys for --preset (zoho, fastmail, yahoo, icloud, ...)")}
|
|
1777
2022
|
${bold("argorant inboxes")} endings ${dim("the domain endings on offer (com, net, org, co, sbs, biz, info)")}
|
|
1778
2023
|
${bold("argorant inboxes")} suggest <website|brand> [--ending com] [-n 20] ${dim("free domain names with prices")}
|
|
1779
2024
|
${bold("argorant inboxes")} quote --mailboxes 100 [--company acme.com] [--domains a.com,b.com] [--currency usd|eur] [--billing month|quarter]
|
|
@@ -1821,6 +2066,8 @@ async function cmdCampaigns(argv) {
|
|
|
1821
2066
|
replies: campaignsReplies,
|
|
1822
2067
|
analytics: campaignsAnalytics,
|
|
1823
2068
|
emails: campaignsEmails,
|
|
2069
|
+
subsequences: campaignsSubsequences,
|
|
2070
|
+
subsequence: campaignsSubsequences,
|
|
1824
2071
|
steps: campaignsSteps,
|
|
1825
2072
|
senders: campaignsSenders,
|
|
1826
2073
|
inboxes: campaignsInboxes,
|
|
@@ -1842,10 +2089,10 @@ async function cmdInboxes(argv) {
|
|
|
1842
2089
|
const key = requireKey();
|
|
1843
2090
|
if (sub === "list") {
|
|
1844
2091
|
const args = readFlags(argv.slice(1));
|
|
1845
|
-
const r =
|
|
2092
|
+
const r = await fetchPages(args.base, key, "/api/v1/inboxes", { limit: 100 }, "inboxes", "inboxes list", true);
|
|
1846
2093
|
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
1847
2094
|
const rows = r.inboxes || [];
|
|
1848
|
-
if (!rows.length) return console.log(dim("No mailboxes connected yet. Connect Google Workspace with `argorant inboxes connect-google --admin
|
|
2095
|
+
if (!rows.length) return console.log(dim("No mailboxes connected yet. Connect Google Workspace with `argorant inboxes connect-google --admin ...`, any other provider with `argorant inboxes connect-smtp --email ... --preset ...`, or Microsoft 365 in the app."));
|
|
1849
2096
|
for (const i of rows) {
|
|
1850
2097
|
console.log(`${bold(i.email)} ${dim(i.name || "")}`);
|
|
1851
2098
|
console.log(dim(` ${i.provider} · ${i.status}${i.health ? ` · ${i.health}` : ""} · today ${i.sent_today || 0}/${i.daily_limit || "?"}`));
|
|
@@ -2052,7 +2299,267 @@ async function cmdInboxes(argv) {
|
|
|
2052
2299
|
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
2053
2300
|
return console.log(green("✓") + ` Bundle ${target} cancelled${r.existing ? dim(" (already)") : ""}`);
|
|
2054
2301
|
}
|
|
2055
|
-
|
|
2302
|
+
if (sub === "presets") {
|
|
2303
|
+
const args = readFlags(argv.slice(1));
|
|
2304
|
+
const r = need(await request("GET", args.base, "/api/v1/inboxes/smtp/presets", { key }), "inboxes presets");
|
|
2305
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
2306
|
+
for (const p of r.presets || []) {
|
|
2307
|
+
const out = p.smtp_host ? `${p.smtp_host}:${p.smtp_port} ${p.smtp_secure}` : "you enter the servers";
|
|
2308
|
+
const inn = p.imap_host ? `${p.imap_host}:${p.imap_port} ${p.imap_secure}` : "";
|
|
2309
|
+
console.log(`${bold(p.key.padEnd(14))} ${p.label}`);
|
|
2310
|
+
console.log(dim(` sending ${out}${inn ? ` · reading replies ${inn}` : ""}`));
|
|
2311
|
+
if (p.hint) console.log(dim(` ${p.hint}`));
|
|
2312
|
+
}
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
if (sub === "test-smtp" || sub === "connect-smtp") return cmdSmtpMailbox(sub, argv.slice(1), key);
|
|
2316
|
+
die(`unknown inboxes subcommand: ${sub} (list, set, disconnect, connect-google, connect-smtp, test-smtp, presets, endings, suggest, quote, order, status, renewal, cancel-bundle)`);
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
// ---- mailboxes from any email provider (SMTP sending + IMAP replies), 0.14.0 ----
|
|
2320
|
+
// The password is never taken on the command line (shell history): --password-env NAME, the
|
|
2321
|
+
// ARGORANT_MAILBOX_PASSWORD variable, a hidden prompt, or the CSV file for many mailboxes.
|
|
2322
|
+
const SMTP_FLAGS = {
|
|
2323
|
+
"--email": "email", "--name": "name", "--preset": "preset",
|
|
2324
|
+
"--smtp-host": "smtpHost", "--smtp-port": "smtpPort", "--smtp-security": "smtpSecurity", "--smtp-user": "smtpUser",
|
|
2325
|
+
"--imap-host": "imapHost", "--imap-port": "imapPort", "--imap-security": "imapSecurity", "--imap-user": "imapUser",
|
|
2326
|
+
"--daily-limit": "dailyLimit", "--password-env": "passwordEnv", "--imap-password-env": "imapPasswordEnv", "--csv": "csv",
|
|
2327
|
+
};
|
|
2328
|
+
const SMTP_BULK_CHUNK = 25;
|
|
2329
|
+
const SMTP_USAGE = `usage: argorant inboxes connect-smtp --email <address> (--preset <key> | --smtp-host <h> --smtp-port <p> --imap-host <h>)
|
|
2330
|
+
[--name "Sender Name"] [--smtp-security starttls|ssl|none] [--smtp-user <u>] [--imap-port 993]
|
|
2331
|
+
[--imap-security ssl|starttls|none] [--imap-user <u>] [--daily-limit 25] [--password-env NAME] [--imap-password-env NAME]
|
|
2332
|
+
argorant inboxes connect-smtp --csv mailboxes.csv
|
|
2333
|
+
argorant inboxes test-smtp (same flags as connect-smtp for one mailbox; saves nothing)
|
|
2334
|
+
argorant inboxes presets (the provider keys for --preset)`;
|
|
2335
|
+
// CSV header -> field (lower case, spaces and dashes as underscores).
|
|
2336
|
+
const SMTP_CSV_COLUMNS = {
|
|
2337
|
+
email: "email", sender_name: "sender_name", name: "sender_name", display_name: "sender_name", preset: "preset",
|
|
2338
|
+
smtp_host: "smtp_host", smtp_port: "smtp_port", smtp_security: "smtp_security", smtp_secure: "smtp_security",
|
|
2339
|
+
smtp_username: "smtp_username", smtp_user: "smtp_username", smtp_password: "smtp_password", password: "smtp_password",
|
|
2340
|
+
imap_host: "imap_host", imap_port: "imap_port", imap_security: "imap_security", imap_secure: "imap_security",
|
|
2341
|
+
imap_username: "imap_username", imap_user: "imap_username", imap_password: "imap_password", daily_limit: "daily_limit",
|
|
2342
|
+
};
|
|
2343
|
+
|
|
2344
|
+
function normalizeSecurity(value, what) {
|
|
2345
|
+
if (value === undefined || value === null || String(value).trim() === "") return undefined;
|
|
2346
|
+
const v = String(value).trim().toLowerCase().replace(/[\s_-]+/g, "");
|
|
2347
|
+
if (["ssl", "ssl/tls", "tls", "ssltls", "implicit"].includes(v)) return "ssl";
|
|
2348
|
+
if (["starttls", "start"].includes(v)) return "starttls";
|
|
2349
|
+
if (["none", "no", "off", "plain"].includes(v)) return "none";
|
|
2350
|
+
throw new Error(`${what} must be ssl, starttls or none`);
|
|
2351
|
+
}
|
|
2352
|
+
function parsePort(value, what) {
|
|
2353
|
+
if (value === undefined || value === null || String(value).trim() === "") return undefined;
|
|
2354
|
+
const n = Number(String(value).trim());
|
|
2355
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) throw new Error(`${what} must be a port number from 1 to 65535`);
|
|
2356
|
+
return n;
|
|
2357
|
+
}
|
|
2358
|
+
function parseDailyLimit(value) {
|
|
2359
|
+
if (value === undefined || value === null || String(value).trim() === "") return undefined;
|
|
2360
|
+
const n = Number(String(value).trim());
|
|
2361
|
+
if (!Number.isInteger(n) || n < 1 || n > 10000) throw new Error("daily limit must be a whole number from 1 to 10000");
|
|
2362
|
+
return n;
|
|
2363
|
+
}
|
|
2364
|
+
// One mailbox in the API's field names; empty values are left out (the server fills the defaults).
|
|
2365
|
+
function mailboxBody(f) {
|
|
2366
|
+
const body = {};
|
|
2367
|
+
const put = (k, v) => { if (v !== undefined && v !== null && String(v) !== "") body[k] = v; };
|
|
2368
|
+
put("email", f.email ? String(f.email).trim().toLowerCase() : undefined);
|
|
2369
|
+
put("display_name", f.sender_name ? String(f.sender_name).trim() : undefined);
|
|
2370
|
+
put("preset", f.preset ? String(f.preset).trim().toLowerCase() : undefined);
|
|
2371
|
+
put("smtp_host", f.smtp_host ? String(f.smtp_host).trim() : undefined);
|
|
2372
|
+
put("smtp_port", parsePort(f.smtp_port, "smtp port"));
|
|
2373
|
+
put("smtp_secure", normalizeSecurity(f.smtp_security, "smtp security"));
|
|
2374
|
+
put("smtp_username", f.smtp_username ? String(f.smtp_username).trim() : undefined);
|
|
2375
|
+
put("smtp_password", f.smtp_password);
|
|
2376
|
+
put("imap_host", f.imap_host ? String(f.imap_host).trim() : undefined);
|
|
2377
|
+
put("imap_port", parsePort(f.imap_port, "imap port"));
|
|
2378
|
+
put("imap_secure", normalizeSecurity(f.imap_security, "imap security"));
|
|
2379
|
+
put("imap_username", f.imap_username ? String(f.imap_username).trim() : undefined);
|
|
2380
|
+
put("imap_password", f.imap_password);
|
|
2381
|
+
put("daily_limit", parseDailyLimit(f.daily_limit));
|
|
2382
|
+
if (!body.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) throw new Error("needs a valid email address");
|
|
2383
|
+
if (!body.smtp_password) throw new Error("needs a password");
|
|
2384
|
+
if (!body.preset && !(body.smtp_host && body.smtp_port && body.imap_host)) {
|
|
2385
|
+
throw new Error("needs a preset, or the sending server, its port and the server for reading replies");
|
|
2386
|
+
}
|
|
2387
|
+
return body;
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
// RFC 4180 CSV: quoted fields, doubled quotes, commas and line breaks inside quotes, CRLF, a BOM.
|
|
2391
|
+
function parseCsv(text) {
|
|
2392
|
+
const rows = [];
|
|
2393
|
+
let row = [], field = "", quoted = false, i = 0;
|
|
2394
|
+
const src = String(text || "").replace(/^\uFEFF/, "");
|
|
2395
|
+
while (i < src.length) {
|
|
2396
|
+
const ch = src[i];
|
|
2397
|
+
if (quoted) {
|
|
2398
|
+
if (ch === '"') {
|
|
2399
|
+
if (src[i + 1] === '"') { field += '"'; i += 2; continue; }
|
|
2400
|
+
quoted = false; i += 1; continue;
|
|
2401
|
+
}
|
|
2402
|
+
field += ch; i += 1; continue;
|
|
2403
|
+
}
|
|
2404
|
+
if (ch === '"' && field === "") { quoted = true; i += 1; continue; }
|
|
2405
|
+
if (ch === ",") { row.push(field); field = ""; i += 1; continue; }
|
|
2406
|
+
if (ch === "\r" && src[i + 1] === "\n") { i += 1; continue; }
|
|
2407
|
+
if (ch === "\n" || ch === "\r") { row.push(field); rows.push(row); row = []; field = ""; i += 1; continue; }
|
|
2408
|
+
field += ch; i += 1;
|
|
2409
|
+
}
|
|
2410
|
+
if (field !== "" || row.length) { row.push(field); rows.push(row); }
|
|
2411
|
+
return rows.filter((r) => r.some((v) => String(v).trim() !== ""));
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
// CSV text -> { rows: [{ line, body }], invalid: [{ line, email, message }], ignored: [column] }
|
|
2415
|
+
function mailboxesFromCsv(text) {
|
|
2416
|
+
const table = parseCsv(text);
|
|
2417
|
+
if (!table.length) throw new Error("the file is empty");
|
|
2418
|
+
const header = table[0].map((h) => String(h).trim().toLowerCase().replace(/[\s-]+/g, "_"));
|
|
2419
|
+
const fields = header.map((h) => SMTP_CSV_COLUMNS[h] || null);
|
|
2420
|
+
if (!fields.includes("email")) throw new Error("the file needs an email column (see: argorant inboxes connect-smtp --help)");
|
|
2421
|
+
const ignored = header.filter((h, i) => h && !fields[i]);
|
|
2422
|
+
const rows = [], invalid = [];
|
|
2423
|
+
table.slice(1).forEach((cells, index) => {
|
|
2424
|
+
const line = index + 2;
|
|
2425
|
+
const f = {};
|
|
2426
|
+
fields.forEach((name, i) => { if (name && cells[i] !== undefined && String(cells[i]).trim() !== "") f[name] = name.endsWith("password") ? String(cells[i]) : String(cells[i]).trim(); });
|
|
2427
|
+
try {
|
|
2428
|
+
rows.push({ line, body: mailboxBody(f) });
|
|
2429
|
+
} catch (e) {
|
|
2430
|
+
invalid.push({ line, email: f.email || "", status: "invalid", message: e.message });
|
|
2431
|
+
}
|
|
2432
|
+
});
|
|
2433
|
+
return { rows, invalid, ignored };
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
function promptSecret(question) {
|
|
2437
|
+
return new Promise((resolve) => {
|
|
2438
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
2439
|
+
let muted = false;
|
|
2440
|
+
rl._writeToOutput = (str) => { if (!muted) rl.output.write(str); };
|
|
2441
|
+
rl.question(question, (answer) => { rl.close(); process.stdout.write("\n"); resolve(answer); });
|
|
2442
|
+
muted = true;
|
|
2443
|
+
});
|
|
2444
|
+
}
|
|
2445
|
+
async function mailboxPassword(args, which) {
|
|
2446
|
+
const envFlag = which === "imap" ? args.imapPasswordEnv : args.passwordEnv;
|
|
2447
|
+
if (envFlag) {
|
|
2448
|
+
const v = process.env[envFlag];
|
|
2449
|
+
if (!v) die(`the environment variable ${envFlag} is empty or not set.`);
|
|
2450
|
+
return v;
|
|
2451
|
+
}
|
|
2452
|
+
if (which === "imap") return undefined; // the server uses the sending password
|
|
2453
|
+
if (process.env.ARGORANT_MAILBOX_PASSWORD) return process.env.ARGORANT_MAILBOX_PASSWORD;
|
|
2454
|
+
if (process.stdin.isTTY) {
|
|
2455
|
+
const v = await promptSecret("Mailbox password or app password (not shown): ");
|
|
2456
|
+
if (!v) die("no password given.");
|
|
2457
|
+
return v;
|
|
2458
|
+
}
|
|
2459
|
+
die("give the mailbox password with --password-env NAME or the ARGORANT_MAILBOX_PASSWORD variable.");
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
function printSignInTest(t) {
|
|
2463
|
+
if (!t || typeof t !== "object") return;
|
|
2464
|
+
for (const [leg, label] of [["smtp", "sending"], ["imap", "reading replies"]]) {
|
|
2465
|
+
const r = t[leg];
|
|
2466
|
+
if (!r) continue;
|
|
2467
|
+
console.log(` ${r.works ? green("✓") : red("✗")} ${label.padEnd(16)}${r.message || r.result || ""}`);
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
function printBulkResults(results) {
|
|
2471
|
+
const w = Math.min(40, Math.max(5, ...results.map((r) => String(r.email || "").length)));
|
|
2472
|
+
for (const r of results) {
|
|
2473
|
+
const mark = r.status === "connected" ? green("✓ connected") : r.status === "skipped" ? yellow("- skipped ") : red("✗ " + (r.status === "invalid" ? "invalid " : "failed "));
|
|
2474
|
+
console.log(`${String(r.line).padStart(4)} ${String(r.email || "").padEnd(w)} ${mark} ${r.status === "connected" ? "" : (r.message || "")}`);
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
async function cmdSmtpMailbox(sub, argv, key) {
|
|
2479
|
+
if (argv.includes("--password") || argv.some((a) => a.startsWith("--password="))) {
|
|
2480
|
+
die("for safety the password is never taken on the command line. Use --password-env NAME, the ARGORANT_MAILBOX_PASSWORD variable, or type it when asked.");
|
|
2481
|
+
}
|
|
2482
|
+
if (argv.includes("--help") || argv.includes("-h")) return console.log(SMTP_USAGE + `
|
|
2483
|
+
|
|
2484
|
+
CSV columns: email, sender_name, preset, smtp_host, smtp_port, smtp_security, smtp_username, smtp_password,
|
|
2485
|
+
imap_host, imap_port, imap_security, imap_username, imap_password, daily_limit
|
|
2486
|
+
Each row needs email, smtp_password (or password), and a preset or smtp_host + smtp_port + imap_host.
|
|
2487
|
+
Empty columns take the defaults: username = email, reading replies uses the sending sign-in, port 993, 25 a day.
|
|
2488
|
+
Send on port 587 (starttls). If you give 465, Argorant tries 587 and saves 587 when it works.
|
|
2489
|
+
Rows go up 25 at a time; each row says whether it connected or what to fix. Exit 1 when any row did not connect.`);
|
|
2490
|
+
const args = readFlags(argv, SMTP_FLAGS);
|
|
2491
|
+
if (args.csv) {
|
|
2492
|
+
if (sub !== "connect-smtp") die("--csv works with connect-smtp.");
|
|
2493
|
+
let text;
|
|
2494
|
+
try { text = fs.readFileSync(args.csv, "utf8"); } catch (e) { die(`cannot read ${args.csv}: ${e.message}`); }
|
|
2495
|
+
let parsed;
|
|
2496
|
+
try { parsed = mailboxesFromCsv(text); } catch (e) { die(`${args.csv}: ${e.message}`); }
|
|
2497
|
+
if (parsed.ignored.length && !args.json) warn(`ignoring unknown column${parsed.ignored.length === 1 ? "" : "s"}: ${parsed.ignored.join(", ")}`);
|
|
2498
|
+
const results = [...parsed.invalid];
|
|
2499
|
+
const totals = { connected: 0, failed: 0, skipped: 0, invalid: parsed.invalid.length };
|
|
2500
|
+
for (let i = 0; i < parsed.rows.length; i += SMTP_BULK_CHUNK) {
|
|
2501
|
+
const chunk = parsed.rows.slice(i, i + SMTP_BULK_CHUNK);
|
|
2502
|
+
if (!args.json) process.stderr.write(dim(`checking ${i + 1} to ${i + chunk.length} of ${parsed.rows.length}...\n`));
|
|
2503
|
+
const res = await request("POST", args.base, "/api/v1/inboxes/smtp/bulk", { key, body: { mailboxes: chunk.map((r) => r.body) } });
|
|
2504
|
+
if (res.status >= 400) {
|
|
2505
|
+
if (results.length && !args.json) printBulkResults(results.sort((a, b) => a.line - b.line));
|
|
2506
|
+
need(res, "inboxes connect-smtp --csv");
|
|
2507
|
+
}
|
|
2508
|
+
const r = res.json || {};
|
|
2509
|
+
for (const item of r.results || []) {
|
|
2510
|
+
const source = chunk[Number(item.row) - 1];
|
|
2511
|
+
results.push({ line: source ? source.line : null, email: item.email || (source && source.body.email), status: item.status, result: item.result, message: item.message });
|
|
2512
|
+
if (item.status in totals) totals[item.status] += 1;
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
results.sort((a, b) => (a.line || 0) - (b.line || 0));
|
|
2516
|
+
if (totals.failed || totals.invalid || totals.skipped) process.exitCode = 1;
|
|
2517
|
+
if (args.json) return console.log(JSON.stringify({ ...totals, results }, null, 2));
|
|
2518
|
+
printBulkResults(results);
|
|
2519
|
+
const parts = [`${totals.connected} connected`];
|
|
2520
|
+
if (totals.failed) parts.push(`${totals.failed} could not sign in`);
|
|
2521
|
+
if (totals.skipped) parts.push(`${totals.skipped} skipped`);
|
|
2522
|
+
if (totals.invalid) parts.push(`${totals.invalid} with missing or wrong columns`);
|
|
2523
|
+
return console.log(`\n${parts.join(", ")}.`);
|
|
2524
|
+
}
|
|
2525
|
+
if (!args.email) die(SMTP_USAGE);
|
|
2526
|
+
const password = await mailboxPassword(args, "smtp");
|
|
2527
|
+
const imapPassword = await mailboxPassword(args, "imap");
|
|
2528
|
+
let body;
|
|
2529
|
+
try {
|
|
2530
|
+
body = mailboxBody({
|
|
2531
|
+
email: args.email, sender_name: args.name, preset: args.preset, smtp_host: args.smtpHost, smtp_port: args.smtpPort,
|
|
2532
|
+
smtp_security: args.smtpSecurity, smtp_username: args.smtpUser, smtp_password: password, imap_host: args.imapHost,
|
|
2533
|
+
imap_port: args.imapPort, imap_security: args.imapSecurity, imap_username: args.imapUser, imap_password: imapPassword,
|
|
2534
|
+
daily_limit: args.dailyLimit,
|
|
2535
|
+
});
|
|
2536
|
+
} catch (e) {
|
|
2537
|
+
die(`${e.message}.\n${SMTP_USAGE}`);
|
|
2538
|
+
}
|
|
2539
|
+
if (sub === "test-smtp") {
|
|
2540
|
+
delete body.display_name; delete body.daily_limit;
|
|
2541
|
+
const r = need(await request("POST", args.base, "/api/v1/inboxes/smtp/test", { key, body }), "inboxes test-smtp");
|
|
2542
|
+
if (!r.works) process.exitCode = 1;
|
|
2543
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
2544
|
+
console.log(`${r.works ? green("✓") : red("✗")} ${body.email} ${r.message || ""}`);
|
|
2545
|
+
printSignInTest(r);
|
|
2546
|
+
if (r.works) console.log(dim(`Connect it: argorant inboxes connect-smtp ${process.argv.slice(4).join(" ")}`.trim()));
|
|
2547
|
+
return;
|
|
2548
|
+
}
|
|
2549
|
+
const res = await request("POST", args.base, "/api/v1/inboxes/smtp", { key, body });
|
|
2550
|
+
if (res.status === 422 && errorCode(res.json) === "sign_in_failed") {
|
|
2551
|
+
const details = (res.json && res.json.error && res.json.error.details) || {};
|
|
2552
|
+
process.exitCode = 1;
|
|
2553
|
+
if (args.json) return console.log(JSON.stringify({ connected: false, message: errorMessage(res.json), test: details.test || null }, null, 2));
|
|
2554
|
+
console.log(`${red("✗")} ${body.email} was not connected. ${errorMessage(res.json) || ""}`);
|
|
2555
|
+
printSignInTest(details.test);
|
|
2556
|
+
return;
|
|
2557
|
+
}
|
|
2558
|
+
const r = need(res, "inboxes connect-smtp");
|
|
2559
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
2560
|
+
const i = r.inbox || {};
|
|
2561
|
+
console.log(green("✓") + ` Connected ${bold(i.email || body.email)}${i.daily_limit ? dim(` ${i.daily_limit} a day`) : ""}`);
|
|
2562
|
+
console.log(dim("It is in `argorant inboxes list` now and can be picked as a sender for any campaign."));
|
|
2056
2563
|
}
|
|
2057
2564
|
|
|
2058
2565
|
// =============================================================================
|
|
@@ -2134,13 +2641,16 @@ function findNeed(res, what, { expect } = {}) {
|
|
|
2134
2641
|
const to = res.res && res.res.headers && res.res.headers.location;
|
|
2135
2642
|
die(`${what}: the server answered with a redirect (HTTP ${res.status}${to ? ` to ${to}` : ""}). Check --base / ARGORANT_API_BASE.`);
|
|
2136
2643
|
}
|
|
2137
|
-
|
|
2644
|
+
const envelope = j && typeof j === "object" && j.error && typeof j.error === "object" && !Array.isArray(j.error);
|
|
2645
|
+
if (res.status >= 400 && !envelope && j && typeof j === "object" && !Array.isArray(j) && j.detail === undefined && (j.error || j.message)) {
|
|
2138
2646
|
r = { ...res, json: { detail: j } };
|
|
2139
2647
|
}
|
|
2140
2648
|
if (r.status === 429) {
|
|
2141
2649
|
const ra = Number((res.res && res.res.headers && res.res.headers["retry-after"]) || 0);
|
|
2142
|
-
const msg =
|
|
2143
|
-
|
|
2650
|
+
const msg = errorMessage(r.json) || "the email finder limit was reached.";
|
|
2651
|
+
const rid = requestIdOf(res);
|
|
2652
|
+
const ref = rid ? `\nRequest id: ${rid} (quote it if you contact support)` : "";
|
|
2653
|
+
die((Number.isFinite(ra) && ra > 0 ? `${msg} Try again in ${fmtWait(Math.ceil(ra))}. Nothing was charged.` : `${msg} Nothing was charged.`) + ref, EXIT.RATE_LIMIT);
|
|
2144
2654
|
}
|
|
2145
2655
|
let out = need(r, what);
|
|
2146
2656
|
// Job endpoints answer {ok, job: {...}}; the commands work on the job itself.
|
|
@@ -2483,6 +2993,692 @@ async function cmdFind(argv) {
|
|
|
2483
2993
|
return findSingle(args, key);
|
|
2484
2994
|
}
|
|
2485
2995
|
|
|
2996
|
+
// =============================================================================
|
|
2997
|
+
// API v1 commands (0.13.0): account, lists, exports, credits, webhooks,
|
|
2998
|
+
// companies and lookup. They call /api/v1 only. Every write carries an
|
|
2999
|
+
// Idempotency-Key (see request()), so running a command again never does the
|
|
3000
|
+
// work twice. Lists come back a page at a time: -n sets the page size,
|
|
3001
|
+
// --cursor continues where the last page ended, --all walks to the end.
|
|
3002
|
+
// =============================================================================
|
|
3003
|
+
|
|
3004
|
+
const V1 = "/api/v1";
|
|
3005
|
+
const PAGE_FLAGS = { "-n": "limit", "--limit": "limit", "--cursor": "cursor", "--offset": "offset" };
|
|
3006
|
+
const COMPANY_FILTER_FLAGS = {
|
|
3007
|
+
"--query": "q",
|
|
3008
|
+
"--q": "q",
|
|
3009
|
+
"--industry": "industry",
|
|
3010
|
+
"--industries": "industries",
|
|
3011
|
+
"--country": "country",
|
|
3012
|
+
"--geography": "country",
|
|
3013
|
+
"--region": "country",
|
|
3014
|
+
"--state": "state",
|
|
3015
|
+
"--city": "city",
|
|
3016
|
+
"--company": "company_name",
|
|
3017
|
+
"--domain": "company_domain",
|
|
3018
|
+
"--employees": "employee_range",
|
|
3019
|
+
"--employee-range": "employee_range",
|
|
3020
|
+
"--keywords": "keywords",
|
|
3021
|
+
"--keyword": "keywords",
|
|
3022
|
+
"--technologies": "technologies",
|
|
3023
|
+
"--technology": "technologies",
|
|
3024
|
+
};
|
|
3025
|
+
const LOOKUP_KINDS = ["industries", "countries", "keywords", "technologies", "titles"];
|
|
3026
|
+
const LOOKUP_ALIASES = { industry: "industries", country: "countries", keyword: "keywords", technology: "technologies", tech: "technologies", title: "titles" };
|
|
3027
|
+
|
|
3028
|
+
// The page query every list command sends: limit plus cursor or offset.
|
|
3029
|
+
function pageQuery(args, def = 25) {
|
|
3030
|
+
const q = { limit: args.limit !== undefined ? parseLimit(args.limit, "--limit") : def };
|
|
3031
|
+
if (args.cursor !== undefined) q.cursor = String(args.cursor);
|
|
3032
|
+
if (args.offset !== undefined) {
|
|
3033
|
+
const n = Number(args.offset);
|
|
3034
|
+
if (!Number.isInteger(n) || n < 0) die(`--offset must be 0 or a positive whole number (got "${args.offset}").`);
|
|
3035
|
+
q.offset = n;
|
|
3036
|
+
}
|
|
3037
|
+
if (q.cursor !== undefined && q.offset !== undefined) die("pass --cursor or --offset, not both.");
|
|
3038
|
+
return q;
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
// One page, or with all=true every page (following next_cursor) merged into
|
|
3042
|
+
// one answer of the same shape.
|
|
3043
|
+
async function fetchPages(base, key, urlPath, query, itemsKey, what, all) {
|
|
3044
|
+
let page = need(await request("GET", base, urlPath, { key, query }), what);
|
|
3045
|
+
if (!all) return page;
|
|
3046
|
+
const items = [...pickItems(page, itemsKey)];
|
|
3047
|
+
let guard = 0;
|
|
3048
|
+
while (page.next_cursor && guard++ < 5000) {
|
|
3049
|
+
const q = { ...query, cursor: page.next_cursor };
|
|
3050
|
+
delete q.offset;
|
|
3051
|
+
page = need(await request("GET", base, urlPath, { key, query: q }), what);
|
|
3052
|
+
items.push(...pickItems(page, itemsKey));
|
|
3053
|
+
}
|
|
3054
|
+
return { ...page, [itemsKey]: items, offset: query.offset || 0, next_cursor: null, has_more: false };
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
// Up to `want` rows, a page of at most 100 at a time, following next_cursor.
|
|
3058
|
+
async function fetchUpTo(base, key, urlPath, query, itemsKey, what, want) {
|
|
3059
|
+
let q = { ...query, limit: Math.min(100, want) };
|
|
3060
|
+
let page = need(await request("GET", base, urlPath, { key, query: q }), what);
|
|
3061
|
+
const rows = [...pickItems(page, itemsKey)];
|
|
3062
|
+
let guard = 0;
|
|
3063
|
+
while (rows.length < want && page.next_cursor && guard++ < 5000) {
|
|
3064
|
+
q = { ...query, limit: Math.min(100, want - rows.length), cursor: page.next_cursor };
|
|
3065
|
+
delete q.offset;
|
|
3066
|
+
page = need(await request("GET", base, urlPath, { key, query: q }), what);
|
|
3067
|
+
const more = pickItems(page, itemsKey);
|
|
3068
|
+
if (!more.length) break;
|
|
3069
|
+
rows.push(...more);
|
|
3070
|
+
}
|
|
3071
|
+
return { ...page, [itemsKey]: rows.slice(0, want) };
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
// The array of a list answer, by its resource key (lists, exports, ...) with
|
|
3075
|
+
// the usual fallbacks.
|
|
3076
|
+
function pickItems(r, itemsKey) {
|
|
3077
|
+
if (!r || typeof r !== "object") return [];
|
|
3078
|
+
for (const k of [itemsKey, "data", "results", "items"]) if (Array.isArray(r[k])) return r[k];
|
|
3079
|
+
return [];
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
function pageFooter(r, itemsKey, hint) {
|
|
3083
|
+
const n = pickItems(r, itemsKey).length;
|
|
3084
|
+
const parts = [`${n.toLocaleString()} shown`];
|
|
3085
|
+
if (r.total !== undefined && r.total !== null) parts.push(`${Number(r.total).toLocaleString()} in total`);
|
|
3086
|
+
console.log(dim(parts.join(" · ")));
|
|
3087
|
+
if (r.next_cursor) console.log(dim(`Next page: ${hint} --cursor ${r.next_cursor} (or --all for everything)`));
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
function numericId(raw, what, usage) {
|
|
3091
|
+
const id = String(raw || "").trim();
|
|
3092
|
+
if (!id) die(usage);
|
|
3093
|
+
if (!/^\d+$/.test(id)) die(`${what} must be a number (got "${raw}").`);
|
|
3094
|
+
return id;
|
|
3095
|
+
}
|
|
3096
|
+
|
|
3097
|
+
// Destructive and paid-on-confirm commands: ask at a terminal, refuse without
|
|
3098
|
+
// --yes when there is nobody to ask (an agent, CI, a pipe).
|
|
3099
|
+
async function confirmOrDie(args, question, noTtyHint) {
|
|
3100
|
+
if (args.yes) return true;
|
|
3101
|
+
if (!process.stdin.isTTY) die(`${noTtyHint} Add --yes to confirm.`);
|
|
3102
|
+
const ans = await prompt(`${question} [y/N] `);
|
|
3103
|
+
if (!/^y(es)?$/i.test(ans)) {
|
|
3104
|
+
console.log(dim("aborted."));
|
|
3105
|
+
return false;
|
|
3106
|
+
}
|
|
3107
|
+
return true;
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
function fmtDate(v) {
|
|
3111
|
+
if (!v) return "";
|
|
3112
|
+
return String(v).slice(0, 16).replace("T", " ");
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
function fmtNum(v) {
|
|
3116
|
+
if (v === undefined || v === null || v === "") return "-";
|
|
3117
|
+
const n = Number(v);
|
|
3118
|
+
return Number.isFinite(n) ? n.toLocaleString("en-US", { maximumFractionDigits: 2 }) : String(v);
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
// A price from cents (amount_cents, price_cents) or a decimal amount (price, amount).
|
|
3122
|
+
function fmtPrice(o, currency) {
|
|
3123
|
+
if (!o || typeof o !== "object") return "?";
|
|
3124
|
+
const cur = String(o.currency || currency || "usd").toLowerCase();
|
|
3125
|
+
const cents = o.price_cents ?? o.amount_cents ?? o.total_cents ?? o.due_today_cents ?? o.charged_cents;
|
|
3126
|
+
if (cents !== undefined && cents !== null) return money(cents, cur);
|
|
3127
|
+
const dec = o.price ?? o.amount ?? o.total;
|
|
3128
|
+
if (dec !== undefined && dec !== null && Number.isFinite(Number(dec))) return money(Math.round(Number(dec) * 100), cur);
|
|
3129
|
+
return "?";
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
// ---- account ----
|
|
3133
|
+
async function cmdAccount(argv) {
|
|
3134
|
+
const args = readFlags(argv);
|
|
3135
|
+
const key = requireKey();
|
|
3136
|
+
const a = need(await request("GET", args.base, `${V1}/account`, { key }), "account");
|
|
3137
|
+
if (args.json) return console.log(JSON.stringify(a, null, 2));
|
|
3138
|
+
const ws = a.workspace && typeof a.workspace === "object" ? a.workspace : {};
|
|
3139
|
+
const role = a.workspace_role || ws.role;
|
|
3140
|
+
console.log(`${bold("Account")} ${a.email || (a.user && a.user.email) || "-"}${role ? dim(` (${role})`) : ""}`);
|
|
3141
|
+
if (ws.name || a.workspace_name) console.log(`${bold("Workspace")} ${ws.name || a.workspace_name}`);
|
|
3142
|
+
const plan = a.plan && typeof a.plan === "object" ? a.plan : { name: a.plan };
|
|
3143
|
+
if (plan.name) {
|
|
3144
|
+
const interval = plan.billing_interval || plan.interval;
|
|
3145
|
+
const bits = [plan.status, interval && `billed ${interval === "year" ? "yearly" : "monthly"}`, (plan.renews_at || plan.renewal_at) && `${plan.cancels_at_period_end ? "ends" : "renews"} ${fmtDate(plan.renews_at || plan.renewal_at).slice(0, 10)}`].filter(Boolean);
|
|
3146
|
+
console.log(`${bold("Plan")} ${plan.name}${bits.length ? dim(` ${bits.join(" · ")}`) : ""}`);
|
|
3147
|
+
}
|
|
3148
|
+
const cr = a.credits;
|
|
3149
|
+
if (cr !== undefined && cr !== null) {
|
|
3150
|
+
if (typeof cr === "object") {
|
|
3151
|
+
const avail = cr.available ?? cr.balance ?? cr.remaining ?? cr.total;
|
|
3152
|
+
const parts = [];
|
|
3153
|
+
for (const [k, label] of [["from_plan", "from your plan"], ["plan", "from your plan"], ["pay_as_you_go", "pay as you go"], ["free", "free"]]) {
|
|
3154
|
+
if (cr[k] !== undefined && cr[k] !== null && Number(cr[k]) > 0) parts.push(`${fmtNum(cr[k])} ${label}`);
|
|
3155
|
+
}
|
|
3156
|
+
console.log(`${bold("Credits")} ${bold(fmtNum(avail))} available${parts.length ? dim(` (${parts.join(" · ")})`) : ""}`);
|
|
3157
|
+
} else {
|
|
3158
|
+
console.log(`${bold("Credits")} ${bold(fmtNum(cr))} available`);
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
const lim = (a.limits && typeof a.limits === "object" && a.limits) || (a.rate_limit && typeof a.rate_limit === "object" && a.rate_limit) || {};
|
|
3162
|
+
const perMin = lim.requests_per_minute ?? lim.per_minute ?? a.rate_limit_per_minute;
|
|
3163
|
+
if (perMin !== undefined && perMin !== null) console.log(`${bold("Requests")} ${fmtNum(perMin)} per minute`);
|
|
3164
|
+
else if (lim.unlimited) console.log(`${bold("Requests")} no per-minute or daily limit`);
|
|
3165
|
+
const daily = lim.daily && typeof lim.daily === "object" ? Object.entries(lim.daily).filter(([, v]) => v && typeof v === "object" && v.limit !== undefined && v.limit !== null) : [];
|
|
3166
|
+
if (daily.length) {
|
|
3167
|
+
console.log(`${bold("Today")} ${daily.map(([name, v]) => `${name.replace(/_/g, " ")} ${fmtNum(v.used_today || 0)}/${fmtNum(v.limit)}`).join(" · ")}`);
|
|
3168
|
+
}
|
|
3169
|
+
const ce = a.campaign_emails;
|
|
3170
|
+
if (ce && typeof ce === "object") {
|
|
3171
|
+
if (ce.unlimited) console.log(`${bold("Campaign emails")} no monthly limit`);
|
|
3172
|
+
else if (ce.allowance !== undefined && ce.allowance !== null) {
|
|
3173
|
+
console.log(`${bold("Campaign emails")} ${fmtNum(ce.used)} of ${fmtNum(ce.allowance)} used${ce.resets_on ? dim(` resets ${String(ce.resets_on).slice(0, 10)}`) : ""}`);
|
|
3174
|
+
}
|
|
3175
|
+
if (ce.trial_active && ce.trial_days_left !== undefined && ce.trial_days_left !== null) console.log(dim(` free trial: ${ce.trial_days_left} day(s) left`));
|
|
3176
|
+
}
|
|
3177
|
+
if (Array.isArray(a.scopes)) console.log(`${bold("Scopes")} ${a.scopes.join(", ") || "-"}`);
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
// ---- lists ----
|
|
3181
|
+
function listsHelp() {
|
|
3182
|
+
const p = bold("argorant lists");
|
|
3183
|
+
console.log(`
|
|
3184
|
+
${bold("Argorant lists")} - saved lists of people (free to create and browse)
|
|
3185
|
+
|
|
3186
|
+
${p} [ls] [--kind records|filtered] [-n 25] [--cursor <c>] [--all]
|
|
3187
|
+
${p} create --name "<name>" [filters] ${dim("saves a search; the size is counted for you")}
|
|
3188
|
+
${p} show <id>
|
|
3189
|
+
${p} people <id> [-n 25] [--cursor <c>] [--all] ${dim("the people in it, contact details hidden")}
|
|
3190
|
+
${p} rename <id> --name "<new name>"
|
|
3191
|
+
${p} rm <id> [--yes]
|
|
3192
|
+
${p} export <id> [-n 1000] [-o leads.csv] [--include-exported] [--include-phones] [--no-wait] [--yes]
|
|
3193
|
+
${dim("1 credit per valid contact; with --include-phones 10 credits per phone number returned")}
|
|
3194
|
+
|
|
3195
|
+
filters for create: --title --seniority --department --industry --keywords --country --state --city --company --domain
|
|
3196
|
+
${dim("Add --json to any command for the raw answer.")}
|
|
3197
|
+
`);
|
|
3198
|
+
}
|
|
3199
|
+
|
|
3200
|
+
const LIST_FILTER_FLAGS = {
|
|
3201
|
+
"--query": "q",
|
|
3202
|
+
"--title": "title",
|
|
3203
|
+
"--exclude-title": "exclude_title",
|
|
3204
|
+
"--seniority": "seniority",
|
|
3205
|
+
"--department": "departments",
|
|
3206
|
+
"--departments": "departments",
|
|
3207
|
+
"--industry": "industry",
|
|
3208
|
+
"--keywords": "keywords",
|
|
3209
|
+
"--keyword": "keywords",
|
|
3210
|
+
"--country": "country",
|
|
3211
|
+
"--geography": "country",
|
|
3212
|
+
"--region": "country",
|
|
3213
|
+
"--state": "state",
|
|
3214
|
+
"--city": "city",
|
|
3215
|
+
"--company": "company_name",
|
|
3216
|
+
"--domain": "company_domain",
|
|
3217
|
+
};
|
|
3218
|
+
|
|
3219
|
+
function listLine(l) {
|
|
3220
|
+
const size = Number(l.snapshot_total || l.item_count || 0);
|
|
3221
|
+
const kind = l.selection_mode === "filtered" ? "saved search" : "list";
|
|
3222
|
+
return `${bold("#" + (l.list_id ?? l.id))} ${l.name || "-"} ${dim(`${size.toLocaleString()} people · ${kind}${l.updated_at ? ` · updated ${fmtDate(l.updated_at)}` : ""}`)}`;
|
|
3223
|
+
}
|
|
3224
|
+
|
|
3225
|
+
async function cmdLists(argv) {
|
|
3226
|
+
let sub = argv[0] && !argv[0].startsWith("-") ? argv[0].toLowerCase() : "ls";
|
|
3227
|
+
let rest = argv[0] && !argv[0].startsWith("-") ? argv.slice(1) : argv;
|
|
3228
|
+
if (/^\d+$/.test(sub)) { rest = argv; sub = "show"; }
|
|
3229
|
+
if (sub === "help") return listsHelp();
|
|
3230
|
+
const key = requireKey();
|
|
3231
|
+
if (sub === "ls" || sub === "list") {
|
|
3232
|
+
const args = readFlags(rest, { ...PAGE_FLAGS, "--kind": "kind" }, { "--all": "all" });
|
|
3233
|
+
const query = pageQuery(args, 25);
|
|
3234
|
+
if (args.kind) {
|
|
3235
|
+
const kind = String(args.kind).toLowerCase();
|
|
3236
|
+
if (!["all", "records", "filtered"].includes(kind)) die("--kind is all, records (hand-picked lists) or filtered (saved searches)");
|
|
3237
|
+
query.kind = kind;
|
|
3238
|
+
}
|
|
3239
|
+
const r = await fetchPages(args.base, key, `${V1}/lists`, query, "lists", "lists", args.all);
|
|
3240
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3241
|
+
const rows = pickItems(r, "lists");
|
|
3242
|
+
if (!rows.length) return console.log(dim('No saved lists yet. Create one with `argorant lists create --name "..." --title CEO --country Germany`.'));
|
|
3243
|
+
for (const l of rows) console.log(listLine(l));
|
|
3244
|
+
pageFooter(r, "lists", "argorant lists");
|
|
3245
|
+
return;
|
|
3246
|
+
}
|
|
3247
|
+
if (sub === "create" || sub === "new") {
|
|
3248
|
+
const args = readFlags(rest, { ...LIST_FILTER_FLAGS, "--name": "name" }, { "--has-phone": "has_phone", "--has-linkedin": "has_linkedin", "--has-email": "has_email" });
|
|
3249
|
+
const name = String(args.name || "").trim();
|
|
3250
|
+
if (!name) die('usage: argorant lists create --name "My list" [filters] (e.g. --title CEO --country Germany)');
|
|
3251
|
+
const filters = {};
|
|
3252
|
+
for (const field of Object.values(LIST_FILTER_FLAGS)) if (args[field]) filters[field] = args[field];
|
|
3253
|
+
for (const field of ["has_phone", "has_linkedin", "has_email"]) if (args[field]) filters[field] = "true";
|
|
3254
|
+
if (args._.length) filters.q = args._.join(" ");
|
|
3255
|
+
if (!Object.keys(filters).length) die("give at least one filter, for example --title CEO --country Germany");
|
|
3256
|
+
const body = { name, filters };
|
|
3257
|
+
const r = need(await request("POST", args.base, `${V1}/lists`, { key, body }), "lists create");
|
|
3258
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3259
|
+
const l = r.list && typeof r.list === "object" ? r.list : r;
|
|
3260
|
+
console.log(green("✓") + ` Saved list ${bold("#" + (l.list_id ?? l.id))} ${dim("“" + (l.name || name) + "”")} · ${bold(Number(l.snapshot_total || 0).toLocaleString())} matching people`);
|
|
3261
|
+
console.log(dim(`Export it: argorant lists export ${l.list_id ?? l.id} -n 500 -o leads.csv`));
|
|
3262
|
+
return;
|
|
3263
|
+
}
|
|
3264
|
+
if (sub === "show" || sub === "get" || sub === "status") {
|
|
3265
|
+
const args = readFlags(rest);
|
|
3266
|
+
const id = numericId(args._[0], "list id", "usage: argorant lists show <id>");
|
|
3267
|
+
const r = need(await request("GET", args.base, `${V1}/lists/${id}`, { key }), "list");
|
|
3268
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3269
|
+
const l = r.list && typeof r.list === "object" ? r.list : r;
|
|
3270
|
+
console.log(listLine(l));
|
|
3271
|
+
const f = l.filters && typeof l.filters === "object" ? Object.entries(l.filters).filter(([k, v]) => v && k !== "record_type") : [];
|
|
3272
|
+
if (f.length) console.log(dim(` filters: ${f.map(([k, v]) => `${k}=${v}`).join(", ")}`));
|
|
3273
|
+
console.log(dim(` argorant lists people ${id} · argorant lists export ${id} -o leads.csv`));
|
|
3274
|
+
return;
|
|
3275
|
+
}
|
|
3276
|
+
if (sub === "people" || sub === "items") {
|
|
3277
|
+
const args = readFlags(rest, PAGE_FLAGS, { "--all": "all" });
|
|
3278
|
+
const id = numericId(args._[0], "list id", "usage: argorant lists people <id> [-n 25] [--cursor <c>] [--all]");
|
|
3279
|
+
const r = await fetchPages(args.base, key, `${V1}/lists/${id}/people`, pageQuery(args, 25), "people", "list people", args.all);
|
|
3280
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3281
|
+
const rows = pickItems(r, "people");
|
|
3282
|
+
if (!rows.length) return console.log(dim("This list has no people yet."));
|
|
3283
|
+
for (const p of rows) {
|
|
3284
|
+
const name = p.full_name || [p.first_name, p.last_name].filter(Boolean).join(" ") || p.preview || p.name;
|
|
3285
|
+
const who = [name, p.title].filter(Boolean).join(" · ");
|
|
3286
|
+
const where = [p.company || p.company_name, p.country].filter(Boolean).join(", ");
|
|
3287
|
+
console.log(` ${bold(who || "-")}${where ? dim(" " + where) : ""}${p.email ? " " + cyan(p.email) : ""}`);
|
|
3288
|
+
}
|
|
3289
|
+
pageFooter(r, "people", `argorant lists people ${id}`);
|
|
3290
|
+
return;
|
|
3291
|
+
}
|
|
3292
|
+
if (sub === "rename") {
|
|
3293
|
+
const args = readFlags(rest, { "--name": "name" });
|
|
3294
|
+
const id = numericId(args._[0], "list id", 'usage: argorant lists rename <id> --name "<new name>"');
|
|
3295
|
+
const name = String(args.name || args._.slice(1).join(" ") || "").trim();
|
|
3296
|
+
if (!name) die('usage: argorant lists rename <id> --name "<new name>"');
|
|
3297
|
+
const r = need(await request("PATCH", args.base, `${V1}/lists/${id}`, { key, body: { name } }), "lists rename");
|
|
3298
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3299
|
+
const l = r.list && typeof r.list === "object" ? r.list : r;
|
|
3300
|
+
return console.log(green("✓") + ` List #${id} is now ${bold("“" + (l.name || name) + "”")}`);
|
|
3301
|
+
}
|
|
3302
|
+
if (sub === "rm" || sub === "delete" || sub === "remove") {
|
|
3303
|
+
const args = readFlags(rest);
|
|
3304
|
+
const id = numericId(args._[0], "list id", "usage: argorant lists rm <id> [--yes]");
|
|
3305
|
+
if (!(await confirmOrDie(args, `Delete list #${id}? Campaigns that already use its people keep them.`, `Deleting list #${id} needs a confirmation.`))) return;
|
|
3306
|
+
const r = need(await request("DELETE", args.base, `${V1}/lists/${id}`, { key }), "lists rm");
|
|
3307
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3308
|
+
return console.log(green("✓") + ` Deleted list #${id}`);
|
|
3309
|
+
}
|
|
3310
|
+
if (sub === "export") return listsExport(rest, key);
|
|
3311
|
+
die(`unknown lists subcommand: ${sub} (ls, create, show, people, rename, rm, export)`);
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
async function listsExport(rest, key) {
|
|
3315
|
+
const args = readFlags(rest, { "-n": "limit", "--limit": "limit", "-o": "output", "--output": "output" }, {
|
|
3316
|
+
"--include-phones": "includePhones",
|
|
3317
|
+
"--include-exported": "includeExported",
|
|
3318
|
+
"--exclude-previously-exported": "excludeExported",
|
|
3319
|
+
"--no-wait": "noWait",
|
|
3320
|
+
});
|
|
3321
|
+
const id = numericId(args._[0], "list id", "usage: argorant lists export <id> [-n 1000] [-o leads.csv] [--include-exported] [--include-phones] [--no-wait] [--yes]");
|
|
3322
|
+
if (args.includeExported && args.excludeExported) die("pass --include-exported or --exclude-previously-exported, not both.");
|
|
3323
|
+
const limit = args.limit !== undefined ? parseLimit(args.limit, "--limit") : 1000;
|
|
3324
|
+
const dest = args.noWait ? null : ensureWritable(args.output || "argorant-leads.csv");
|
|
3325
|
+
// Same rule as `export`: the question is for a person at a terminal; with
|
|
3326
|
+
// --yes, --json or no terminal it runs straight away.
|
|
3327
|
+
if (!args.yes && !args.json && process.stdin.isTTY) {
|
|
3328
|
+
const phones = args.includePhones ? " plus 10 credits per phone number returned" : "";
|
|
3329
|
+
const ans = await prompt(`Export up to ${bold(limit)} people from list #${id}? 1 credit per valid contact${phones}; invalid ones cost nothing. [y/N] `);
|
|
3330
|
+
if (!/^y(es)?$/i.test(ans)) return console.log(dim("aborted."));
|
|
3331
|
+
}
|
|
3332
|
+
const body = { limit, exclude_previously_exported: !args.includeExported };
|
|
3333
|
+
if (args.includePhones) body.include_phones = true;
|
|
3334
|
+
const job = need(await request("POST", args.base, `${V1}/lists/${id}/export`, { key, body }), "lists export");
|
|
3335
|
+
const isBatch = job.type === "batch" || (job.batch_id && !job.job_id);
|
|
3336
|
+
const jobId = isBatch ? job.batch_id : job.job_id;
|
|
3337
|
+
if (args.noWait || !jobId) {
|
|
3338
|
+
if (args.json) return console.log(JSON.stringify(job, null, 2));
|
|
3339
|
+
console.log(green("✓") + ` Export ${isBatch ? "batch " : ""}${bold("#" + (jobId ?? "?"))} started for list #${id}`);
|
|
3340
|
+
if (jobId) console.log(dim(`Check it: argorant exports status ${jobId}${isBatch ? " --batch" : ""} · download: argorant exports download ${jobId}${isBatch ? " --batch" : ""} -o leads.csv`));
|
|
3341
|
+
return;
|
|
3342
|
+
}
|
|
3343
|
+
if (isBatch) {
|
|
3344
|
+
if (!args.json) process.stdout.write(dim(`Large export queued as batch #${jobId}\n`));
|
|
3345
|
+
const batch = await pollExportBatch(args, key, EXPORT_API_V1.batch(jobId), { quiet: !!args.json });
|
|
3346
|
+
if (!args.json) process.stdout.write("\n");
|
|
3347
|
+
const files = await downloadBatchChunks(args, key, batch, dest, EXPORT_API_V1);
|
|
3348
|
+
if (args.json) return console.log(JSON.stringify({ ...job, files }, null, 2));
|
|
3349
|
+
for (const f of files) console.log(green("✓") + ` ${bold(String(countCsvRows(f) ?? "?"))} rows → ${bold(f)}`);
|
|
3350
|
+
return;
|
|
3351
|
+
}
|
|
3352
|
+
if (!args.json) process.stdout.write(dim(`Export #${jobId} queued - checking every email and building the file`));
|
|
3353
|
+
const started = Date.now();
|
|
3354
|
+
let status = {};
|
|
3355
|
+
for (;;) {
|
|
3356
|
+
await new Promise((r) => setTimeout(r, 2500));
|
|
3357
|
+
status = need(await request("GET", args.base, EXPORT_API_V1.job(jobId), { key }), "export status");
|
|
3358
|
+
const st = String(status.status || "").toLowerCase();
|
|
3359
|
+
if (!args.json) process.stdout.write(".");
|
|
3360
|
+
if (EXPORT_TERMINAL_OK.includes(st) || status.downloadable) break;
|
|
3361
|
+
if (EXPORT_TERMINAL_FAIL.includes(st)) die(`\nexport ${st}${status.error_message ? `: ${status.error_message}` : "."}`);
|
|
3362
|
+
if (Date.now() - started > 1000 * 60 * 20) die(`\nstill running after 20 minutes. Check later with \`argorant exports status ${jobId}\`.`);
|
|
3363
|
+
}
|
|
3364
|
+
if (!args.json) process.stdout.write("\n");
|
|
3365
|
+
try {
|
|
3366
|
+
await downloadTo(args.base, status.download_url || status.download_api_path || EXPORT_API_V1.download(jobId), key, dest);
|
|
3367
|
+
} catch (e) {
|
|
3368
|
+
die(`${e.message}\nThe export itself is done. Download it again with \`argorant exports download ${jobId} -o ${args.output || "argorant-leads.csv"}\`.`);
|
|
3369
|
+
}
|
|
3370
|
+
if (args.json) return console.log(JSON.stringify({ ...job, file: dest }, null, 2));
|
|
3371
|
+
const rows = countCsvRows(dest);
|
|
3372
|
+
console.log(green("✓") + ` Saved ${rows != null ? bold(rows.toLocaleString()) + " rows → " : ""}${bold(dest)}`);
|
|
3373
|
+
}
|
|
3374
|
+
|
|
3375
|
+
// ---- exports ----
|
|
3376
|
+
async function cmdExports(argv) {
|
|
3377
|
+
const sub = argv[0] && !argv[0].startsWith("-") ? argv[0].toLowerCase() : "ls";
|
|
3378
|
+
const rest = argv[0] && !argv[0].startsWith("-") ? argv.slice(1) : argv;
|
|
3379
|
+
if (sub === "help") {
|
|
3380
|
+
return console.log(`
|
|
3381
|
+
${bold("argorant exports")} [ls] [-n 25] [--cursor <c>] [--all] ${dim("your exports, newest first")}
|
|
3382
|
+
${bold("argorant exports")} status <id> [--batch]
|
|
3383
|
+
${bold("argorant exports")} download <id> [--batch] [-o leads.csv] ${dim("free; the file stays available for a while after it is built")}
|
|
3384
|
+
${dim("New exports: argorant export \"<query>\" -n 1000 -o leads.csv, or argorant lists export <list-id>")}
|
|
3385
|
+
`);
|
|
3386
|
+
}
|
|
3387
|
+
const key = requireKey();
|
|
3388
|
+
if (sub === "ls" || sub === "list") {
|
|
3389
|
+
const args = readFlags(rest, PAGE_FLAGS, { "--all": "all" });
|
|
3390
|
+
const r = await fetchPages(args.base, key, `${V1}/exports`, pageQuery(args, 25), "exports", "exports", args.all);
|
|
3391
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3392
|
+
const rows = pickItems(r, "exports");
|
|
3393
|
+
if (!rows.length) return console.log(dim('No exports yet. Start one with `argorant export "<query>" -n 500 -o leads.csv`.'));
|
|
3394
|
+
for (const e of rows) {
|
|
3395
|
+
const id = e.job_id ?? e.id;
|
|
3396
|
+
console.log(`${bold("#" + id)} ${e.status || "-"} ${dim(`${Number(e.verified_rows || 0).toLocaleString()} valid of ${Number(e.total_rows || 0).toLocaleString()} rows · ${fmtDate(e.created_at)}`)}${e.downloadable ? green(" ready") : ""}`);
|
|
3397
|
+
}
|
|
3398
|
+
pageFooter(r, "exports", "argorant exports");
|
|
3399
|
+
return;
|
|
3400
|
+
}
|
|
3401
|
+
if (sub === "status" || sub === "download") {
|
|
3402
|
+
const args = readFlags(rest, { "-o": "output", "--output": "output" }, { "--batch": "batch" });
|
|
3403
|
+
const id = numericId(args._[0], "export id", `usage: argorant exports ${sub} <id> [--batch]${sub === "download" ? " [-o leads.csv]" : ""}`);
|
|
3404
|
+
return sub === "status" ? exportStatusCmd(args, key, id, EXPORT_API_V1, "exports") : exportDownloadCmd(args, key, id, EXPORT_API_V1, "exports");
|
|
3405
|
+
}
|
|
3406
|
+
die(`unknown exports subcommand: ${sub} (ls, status, download)`);
|
|
3407
|
+
}
|
|
3408
|
+
|
|
3409
|
+
// ---- credits ----
|
|
3410
|
+
function packLine(p) {
|
|
3411
|
+
const credits = Number(p.credits || 0);
|
|
3412
|
+
const cents = p.price_cents ?? p.amount_cents;
|
|
3413
|
+
const sign = String(p.currency || "usd").toLowerCase() === "eur" ? "€" : "$";
|
|
3414
|
+
const per = cents !== undefined && cents !== null && credits ? ` · ${sign}${(Number(cents) / 100 / credits).toFixed(3)} per credit` : "";
|
|
3415
|
+
return `${bold(credits.toLocaleString())} credits ${fmtPrice(p)}${dim(per + (p.valid_days ? ` · valid ${p.valid_days} days` : ""))}`;
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3418
|
+
function quoteLines(q) {
|
|
3419
|
+
const lines = [];
|
|
3420
|
+
const pack = q.pack && typeof q.pack === "object" ? q.pack : q;
|
|
3421
|
+
lines.push(`${bold(Number(pack.credits || q.credits || 0).toLocaleString())} credits for ${bold(fmtPrice(q.price_cents !== undefined || q.amount_cents !== undefined ? q : pack, q.currency))}`);
|
|
3422
|
+
const card = q.payment && q.payment.saved_card;
|
|
3423
|
+
if (card && card.usable) lines.push(`Paid with: the saved ${card.card || "card"}, charged right away when you confirm${card.charge_before ? ` (before ${fmtDate(card.charge_before)} UTC)` : ""}`);
|
|
3424
|
+
else if (card) lines.push(dim(`Paid with: a payment link you open yourself${card.reason ? ` (${card.reason})` : ""}`));
|
|
3425
|
+
else lines.push(dim("Paid with: a payment link you open yourself"));
|
|
3426
|
+
if (q.expires_at) lines.push(dim(`Quote ${q.quote_id} is valid until ${fmtDate(q.expires_at)} UTC`));
|
|
3427
|
+
return lines;
|
|
3428
|
+
}
|
|
3429
|
+
|
|
3430
|
+
async function cmdCredits(argv) {
|
|
3431
|
+
const sub = argv[0] && !argv[0].startsWith("-") ? argv[0].toLowerCase() : "packs";
|
|
3432
|
+
const rest = argv[0] && !argv[0].startsWith("-") ? argv.slice(1) : argv;
|
|
3433
|
+
if (sub === "help") {
|
|
3434
|
+
return console.log(`
|
|
3435
|
+
${bold("argorant credits")} packs ${dim("the credit packs and their prices")}
|
|
3436
|
+
${bold("argorant credits")} quote <credits> ${dim("a price quote for one pack; nothing is bought")}
|
|
3437
|
+
${bold("argorant credits")} buy <credits> [--pay-with link] [--yes]
|
|
3438
|
+
${dim("shows the quote, asks, then charges the saved card (when your workspace allows it) or gives you a payment link")}
|
|
3439
|
+
${bold("argorant credits")} purchases [<id>] ${dim("purchases made with this account")}
|
|
3440
|
+
${dim("1 credit = 1 valid contact; 0.5 credit = 1 email check. Bought credits stay valid for 365 days.")}
|
|
3441
|
+
`);
|
|
3442
|
+
}
|
|
3443
|
+
const key = requireKey();
|
|
3444
|
+
if (sub === "packs" || sub === "prices") {
|
|
3445
|
+
const args = readFlags(rest);
|
|
3446
|
+
const r = need(await request("GET", args.base, `${V1}/credits/packs`, { key }), "credit packs");
|
|
3447
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3448
|
+
const packs = pickItems(r, "packs");
|
|
3449
|
+
if (!packs.length) return console.log(dim("No credit packs are on offer right now."));
|
|
3450
|
+
for (const p of packs) console.log(packLine(p));
|
|
3451
|
+
console.log(dim("Buy one: argorant credits buy <credits>"));
|
|
3452
|
+
return;
|
|
3453
|
+
}
|
|
3454
|
+
if (sub === "quote" || sub === "buy") {
|
|
3455
|
+
const args = readFlags(rest, { "--currency": "currency", "--pay-with": "payWith" });
|
|
3456
|
+
const credits = parseLimit(String(args._[0] || "").replace(/[,_]/g, "") || "x", "credits");
|
|
3457
|
+
const body = { credits };
|
|
3458
|
+
if (args.currency) body.currency = String(args.currency).toLowerCase();
|
|
3459
|
+
const q = need(await request("POST", args.base, `${V1}/credits/quote`, { key, body }), "credit quote");
|
|
3460
|
+
if (sub === "quote") {
|
|
3461
|
+
if (args.json) return console.log(JSON.stringify(q, null, 2));
|
|
3462
|
+
for (const l of quoteLines(q)) console.log(l);
|
|
3463
|
+
console.log(dim(`Buy it: argorant credits buy ${credits}`));
|
|
3464
|
+
return;
|
|
3465
|
+
}
|
|
3466
|
+
const payWith = String(args.payWith || "auto").toLowerCase();
|
|
3467
|
+
if (!["auto", "link"].includes(payWith)) die("--pay-with is link (always a payment link); leave it out to use the saved card when your workspace allows it");
|
|
3468
|
+
if (!args.json) for (const l of quoteLines(q)) console.log(l);
|
|
3469
|
+
const card = q.payment && q.payment.saved_card;
|
|
3470
|
+
const how = payWith !== "link" && card && card.usable ? `The saved ${card.card || "card"} is charged now.` : "You get a payment link; nothing is charged before you pay it.";
|
|
3471
|
+
if (!(await confirmOrDie(args, `Buy ${credits.toLocaleString()} credits for ${fmtPrice(q.price_cents !== undefined || q.amount_cents !== undefined ? q : q.pack || q, q.currency)}? ${how}`, "Buying credits needs a confirmation."))) return;
|
|
3472
|
+
const pbody = { quote_id: q.quote_id, confirmed: true };
|
|
3473
|
+
if (payWith !== "auto") pbody.pay_with = payWith;
|
|
3474
|
+
const r = need(await request("POST", args.base, `${V1}/credits/purchases`, { key, body: pbody }), "credit purchase");
|
|
3475
|
+
if (args.json) return console.log(JSON.stringify({ quote: q, purchase: r }, null, 2));
|
|
3476
|
+
const p = r.purchase && typeof r.purchase === "object" ? { ...r.purchase, ...r } : r;
|
|
3477
|
+
const url = p.checkout_url || p.payment_url || p.url;
|
|
3478
|
+
if (String(p.status || "").toLowerCase() === "paid") {
|
|
3479
|
+
console.log(green("✓") + ` Paid${p.card ? ` with the saved ${p.card}` : ""}: ${bold(Number(p.credits || credits).toLocaleString())} credits added${p.replayed ? dim(" (already done earlier)") : ""}`);
|
|
3480
|
+
if (p.message) console.log(dim(p.message));
|
|
3481
|
+
} else if (url) {
|
|
3482
|
+
console.log(green("✓") + ` Purchase ${bold(p.purchase_id || p.id || "")} is waiting for payment${p.replayed ? dim(" (same purchase as before)") : ""}`);
|
|
3483
|
+
if (p.saved_card && p.saved_card.reason) console.log(yellow(`Not charged to the saved card: ${p.saved_card.reason}`));
|
|
3484
|
+
console.log(`Pay here: ${cyan(url)}`);
|
|
3485
|
+
console.log(dim("The credits are added as soon as the payment goes through."));
|
|
3486
|
+
} else {
|
|
3487
|
+
console.log(`${p.status || "done"}. ${dim(p.message || "")}`);
|
|
3488
|
+
}
|
|
3489
|
+
return;
|
|
3490
|
+
}
|
|
3491
|
+
if (sub === "purchases" || sub === "purchase" || sub === "history") {
|
|
3492
|
+
const args = readFlags(rest, PAGE_FLAGS, { "--all": "all" });
|
|
3493
|
+
const id = args._[0];
|
|
3494
|
+
if (id) {
|
|
3495
|
+
const r = need(await request("GET", args.base, `${V1}/credits/purchases/${encodeURIComponent(id)}`, { key }), "credit purchase");
|
|
3496
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3497
|
+
const p = r.purchase && typeof r.purchase === "object" ? r.purchase : r;
|
|
3498
|
+
console.log(`${bold(p.purchase_id || p.id || id)} ${p.status || "-"} ${fmtNum(p.credits)} credits · ${fmtPrice(p)} ${dim(fmtDate(p.created_at))}`);
|
|
3499
|
+
if (p.checkout_url && String(p.status || "") !== "paid") console.log(`Pay here: ${cyan(p.checkout_url)}`);
|
|
3500
|
+
return;
|
|
3501
|
+
}
|
|
3502
|
+
const r = await fetchPages(args.base, key, `${V1}/credits/purchases`, pageQuery(args, 25), "purchases", "credit purchases", args.all);
|
|
3503
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3504
|
+
const rows = pickItems(r, "purchases");
|
|
3505
|
+
if (!rows.length) return console.log(dim("No credit purchases yet. See the packs with `argorant credits packs`."));
|
|
3506
|
+
for (const p of rows) console.log(`${bold(p.purchase_id || p.id)} ${p.status || "-"} ${fmtNum(p.credits)} credits · ${fmtPrice(p)} ${dim(fmtDate(p.created_at))}`);
|
|
3507
|
+
pageFooter(r, "purchases", "argorant credits purchases");
|
|
3508
|
+
return;
|
|
3509
|
+
}
|
|
3510
|
+
die(`unknown credits subcommand: ${sub} (packs, quote, buy, purchases)`);
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3513
|
+
// ---- webhooks ----
|
|
3514
|
+
async function cmdWebhooks(argv) {
|
|
3515
|
+
const sub = argv[0] && !argv[0].startsWith("-") ? argv[0].toLowerCase() : "ls";
|
|
3516
|
+
const rest = argv[0] && !argv[0].startsWith("-") ? argv.slice(1) : argv;
|
|
3517
|
+
if (sub === "help") {
|
|
3518
|
+
return console.log(`
|
|
3519
|
+
${bold("argorant webhooks")} [ls] ${dim("your webhooks and the events you can subscribe to")}
|
|
3520
|
+
${bold("argorant webhooks")} add <https-url> --events reply.received,export.ready
|
|
3521
|
+
${bold("argorant webhooks")} set <id> [--url <https-url>] [--events a,b] [--active|--inactive]
|
|
3522
|
+
${bold("argorant webhooks")} test <id> ${dim("sends a test event to the address")}
|
|
3523
|
+
${bold("argorant webhooks")} deliveries <id> [-n 25] ${dim("recent deliveries and their result")}
|
|
3524
|
+
${bold("argorant webhooks")} rm <id> [--yes]
|
|
3525
|
+
${dim("Webhooks are included from the Pro plan. Each delivery is signed. Check its X-Argorant-Signature header (a SHA-256 HMAC of the body) with the secret shown when you add the webhook.")}
|
|
3526
|
+
`);
|
|
3527
|
+
}
|
|
3528
|
+
const key = requireKey();
|
|
3529
|
+
if (sub === "ls" || sub === "list" || sub === "events") {
|
|
3530
|
+
const args = readFlags(rest, PAGE_FLAGS, { "--all": "all" });
|
|
3531
|
+
const r = await fetchPages(args.base, key, `${V1}/webhooks`, pageQuery(args, 25), "webhooks", "webhooks", args.all);
|
|
3532
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3533
|
+
if (sub !== "events") {
|
|
3534
|
+
const rows = pickItems(r, "webhooks");
|
|
3535
|
+
if (!rows.length) console.log(dim("No webhooks yet. Add one with `argorant webhooks add https://example.com/hook --events reply.received`."));
|
|
3536
|
+
for (const w of rows) {
|
|
3537
|
+
const events = Array.isArray(w.events) ? w.events.join(", ") : String(w.events || "");
|
|
3538
|
+
console.log(`${bold("#" + w.id)} ${w.url} ${w.active === false ? yellow("paused") : green("active")}`);
|
|
3539
|
+
console.log(dim(` ${events}${w.last_error ? ` · last error: ${w.last_error}` : ""}`));
|
|
3540
|
+
}
|
|
3541
|
+
if (rows.length) pageFooter(r, "webhooks", "argorant webhooks");
|
|
3542
|
+
}
|
|
3543
|
+
if (Array.isArray(r.events) && r.events.length) console.log(dim(`Events: ${r.events.join(", ")}`));
|
|
3544
|
+
return;
|
|
3545
|
+
}
|
|
3546
|
+
if (sub === "add" || sub === "create") {
|
|
3547
|
+
const args = readFlags(rest, { "--events": "events", "--event": "events", "--url": "url" });
|
|
3548
|
+
const url = String(args.url || args._[0] || "").trim();
|
|
3549
|
+
const events = String(args.events || "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
3550
|
+
if (!url || !events.length) die("usage: argorant webhooks add <https-url> --events reply.received,export.ready (argorant webhooks events lists them)");
|
|
3551
|
+
if (!/^https:\/\//i.test(url)) die("the webhook address must start with https://");
|
|
3552
|
+
const r = need(await request("POST", args.base, `${V1}/webhooks`, { key, body: { url, events } }), "webhooks add");
|
|
3553
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3554
|
+
const w = r.webhook && typeof r.webhook === "object" ? { ...r, ...r.webhook } : r;
|
|
3555
|
+
console.log(green("✓") + ` Webhook ${bold("#" + w.id)} sends ${(w.events || events).join ? (w.events || events).join(", ") : w.events} to ${w.url || url}`);
|
|
3556
|
+
if (w.secret) {
|
|
3557
|
+
console.log(`Signing secret: ${bold(w.secret)}`);
|
|
3558
|
+
console.log(yellow("Store it now. It is shown only once."));
|
|
3559
|
+
}
|
|
3560
|
+
return;
|
|
3561
|
+
}
|
|
3562
|
+
if (sub === "set" || sub === "update") {
|
|
3563
|
+
const args = readFlags(rest, { "--events": "events", "--url": "url" }, { "--active": "activeOn", "--inactive": "activeOff", "--pause": "activeOff", "--resume": "activeOn" });
|
|
3564
|
+
const id = numericId(args._[0], "webhook id", "usage: argorant webhooks set <id> [--url <https-url>] [--events a,b] [--active|--inactive]");
|
|
3565
|
+
const body = {};
|
|
3566
|
+
if (args.url) {
|
|
3567
|
+
if (!/^https:\/\//i.test(args.url)) die("the webhook address must start with https://");
|
|
3568
|
+
body.url = String(args.url).trim();
|
|
3569
|
+
}
|
|
3570
|
+
if (args.events) body.events = String(args.events).split(",").map((x) => x.trim()).filter(Boolean);
|
|
3571
|
+
if (args.activeOn && args.activeOff) die("pass --active or --inactive, not both.");
|
|
3572
|
+
if (args.activeOn) body.active = true;
|
|
3573
|
+
if (args.activeOff) body.active = false;
|
|
3574
|
+
if (!Object.keys(body).length) die("nothing to change: give --url, --events, --active or --inactive");
|
|
3575
|
+
const r = need(await request("PATCH", args.base, `${V1}/webhooks/${id}`, { key, body }), "webhooks set");
|
|
3576
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3577
|
+
return console.log(green("✓") + ` Webhook #${id} updated`);
|
|
3578
|
+
}
|
|
3579
|
+
if (sub === "test") {
|
|
3580
|
+
const args = readFlags(rest);
|
|
3581
|
+
const id = numericId(args._[0], "webhook id", "usage: argorant webhooks test <id>");
|
|
3582
|
+
const r = need(await request("POST", args.base, `${V1}/webhooks/${id}/test`, { key, body: {} }), "webhooks test");
|
|
3583
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3584
|
+
return console.log(green("✓") + ` Test event${r.event ? ` (${r.event})` : ""} queued for webhook #${id}. See the result with \`argorant webhooks deliveries ${id}\`.`);
|
|
3585
|
+
}
|
|
3586
|
+
if (sub === "deliveries") {
|
|
3587
|
+
const args = readFlags(rest, PAGE_FLAGS, { "--all": "all" });
|
|
3588
|
+
const id = numericId(args._[0], "webhook id", "usage: argorant webhooks deliveries <id> [-n 25]");
|
|
3589
|
+
const r = await fetchPages(args.base, key, `${V1}/webhooks/${id}/deliveries`, pageQuery(args, 25), "deliveries", "webhook deliveries", args.all);
|
|
3590
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3591
|
+
const rows = pickItems(r, "deliveries");
|
|
3592
|
+
if (!rows.length) return console.log(dim("No deliveries yet."));
|
|
3593
|
+
for (const d of rows) {
|
|
3594
|
+
const tag = d.ok || d.status === "delivered" ? green(d.status || "delivered") : yellow(d.status || "pending");
|
|
3595
|
+
console.log(`${dim(fmtDate(d.created_at))} ${d.event || "-"} ${tag}${d.attempts ? dim(` · ${d.attempts} attempt(s)`) : ""}${d.last_error ? dim(` · ${d.last_error}`) : ""}`);
|
|
3596
|
+
}
|
|
3597
|
+
pageFooter(r, "deliveries", `argorant webhooks deliveries ${id}`);
|
|
3598
|
+
return;
|
|
3599
|
+
}
|
|
3600
|
+
if (sub === "rm" || sub === "delete" || sub === "remove") {
|
|
3601
|
+
const args = readFlags(rest);
|
|
3602
|
+
const id = numericId(args._[0], "webhook id", "usage: argorant webhooks rm <id> [--yes]");
|
|
3603
|
+
if (!(await confirmOrDie(args, `Delete webhook #${id}? Events stop going to its address.`, `Deleting webhook #${id} needs a confirmation.`))) return;
|
|
3604
|
+
const r = need(await request("DELETE", args.base, `${V1}/webhooks/${id}`, { key }), "webhooks rm");
|
|
3605
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3606
|
+
return console.log(green("✓") + ` Deleted webhook #${id}`);
|
|
3607
|
+
}
|
|
3608
|
+
die(`unknown webhooks subcommand: ${sub} (ls, add, set, test, deliveries, rm, events)`);
|
|
3609
|
+
}
|
|
3610
|
+
|
|
3611
|
+
// ---- companies ----
|
|
3612
|
+
async function cmdCompanies(argv) {
|
|
3613
|
+
const sub = argv[0] && !argv[0].startsWith("-") ? argv[0].toLowerCase() : "help";
|
|
3614
|
+
const rest = argv.slice(1);
|
|
3615
|
+
if (sub === "help") {
|
|
3616
|
+
return console.log(`
|
|
3617
|
+
${bold("argorant companies")} count [filters] ${dim("free")}
|
|
3618
|
+
${bold("argorant companies")} search [filters] [-n 25] [--cursor <c>] [--all] ${dim("free")}
|
|
3619
|
+
${bold("argorant companies")} people <company.com> [--title <role>] [-n 5] ${dim("free; same as argorant company")}
|
|
3620
|
+
filters: --industry --country --state --city --company --domain --keywords --technologies --employees 11-50,51-200 [free text]
|
|
3621
|
+
`);
|
|
3622
|
+
}
|
|
3623
|
+
const key = requireKey();
|
|
3624
|
+
if (sub === "count") {
|
|
3625
|
+
const args = readFlags(rest, COMPANY_FILTER_FLAGS);
|
|
3626
|
+
const query = companyFilters(args);
|
|
3627
|
+
const r = need(await request("GET", args.base, `${V1}/companies/count`, { key, query }), "companies count");
|
|
3628
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3629
|
+
return console.log(bold(Number(r.count || 0).toLocaleString()) + dim(" matching companies"));
|
|
3630
|
+
}
|
|
3631
|
+
if (sub === "search" || sub === "ls") {
|
|
3632
|
+
const args = readFlags(rest, { ...COMPANY_FILTER_FLAGS, ...PAGE_FLAGS }, { "--all": "all" });
|
|
3633
|
+
const query = { ...companyFilters(args), ...pageQuery(args, 10) };
|
|
3634
|
+
const r = await fetchPages(args.base, key, `${V1}/companies/search`, query, "companies", "companies search", args.all);
|
|
3635
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3636
|
+
const rows = pickItems(r, "companies");
|
|
3637
|
+
if (!rows.length) return console.log(dim("No matching companies."));
|
|
3638
|
+
for (const co of rows) {
|
|
3639
|
+
const name = co.company_name || co.company || co.name || co.company_domain || "-";
|
|
3640
|
+
const where = [co.city, co.country].filter(Boolean).join(", ");
|
|
3641
|
+
const size = co.employee_range || (co.employee_count ? `${Number(co.employee_count).toLocaleString()} people` : "");
|
|
3642
|
+
console.log(`${bold(name)} ${dim([co.company_domain || co.domain, co.industry, size, where].filter(Boolean).join(" · "))}`);
|
|
3643
|
+
}
|
|
3644
|
+
pageFooter(r, "companies", "argorant companies search ...");
|
|
3645
|
+
return;
|
|
3646
|
+
}
|
|
3647
|
+
if (sub === "people") {
|
|
3648
|
+
return cmdCompany(applySavedBase(parseArgs(rest)));
|
|
3649
|
+
}
|
|
3650
|
+
die(`unknown companies subcommand: ${sub} (count, search, people)`);
|
|
3651
|
+
}
|
|
3652
|
+
|
|
3653
|
+
function companyFilters(args) {
|
|
3654
|
+
const q = {};
|
|
3655
|
+
for (const field of new Set(Object.values(COMPANY_FILTER_FLAGS))) if (args[field]) q[field] = args[field];
|
|
3656
|
+
if (args._.length) q.q = [q.q, ...args._].filter(Boolean).join(" ");
|
|
3657
|
+
return q;
|
|
3658
|
+
}
|
|
3659
|
+
|
|
3660
|
+
// ---- lookup ----
|
|
3661
|
+
async function cmdLookup(argv) {
|
|
3662
|
+
const raw = String(argv[0] || "").toLowerCase();
|
|
3663
|
+
const kind = LOOKUP_ALIASES[raw] || raw;
|
|
3664
|
+
if (!LOOKUP_KINDS.includes(kind)) {
|
|
3665
|
+
die(`usage: argorant lookup ${LOOKUP_KINDS.join("|")} [text] [-n 20]\n Free. Use the exact labels it returns in your filters (for example --industry "Software Development").`);
|
|
3666
|
+
}
|
|
3667
|
+
const args = readFlags(argv.slice(1), { "-n": "limit", "--limit": "limit" });
|
|
3668
|
+
const key = requireKey();
|
|
3669
|
+
const query = { limit: args.limit !== undefined ? parseLimit(args.limit, "--limit") : 20 };
|
|
3670
|
+
if (args._.length) query.q = args._.join(" ");
|
|
3671
|
+
const r = need(await request("GET", args.base, `${V1}/lookups/${kind}`, { key, query }), `lookup ${kind}`);
|
|
3672
|
+
if (args.json) return console.log(JSON.stringify(r, null, 2));
|
|
3673
|
+
const rows = pickItems(r, "suggestions").length ? pickItems(r, "suggestions") : pickItems(r, kind);
|
|
3674
|
+
if (!rows.length) return console.log(dim(`No ${kind} match${query.q ? ` "${query.q}"` : ""}.`));
|
|
3675
|
+
for (const s of rows) {
|
|
3676
|
+
const label = typeof s === "string" ? s : s.label ?? s.value ?? s.name;
|
|
3677
|
+
const count = typeof s === "object" && s ? s.count : undefined;
|
|
3678
|
+
console.log(`${bold(label)}${count !== undefined && count !== null ? dim(` ${Number(count).toLocaleString()}`) : ""}`);
|
|
3679
|
+
}
|
|
3680
|
+
}
|
|
3681
|
+
|
|
2486
3682
|
function money(cents, currency) {
|
|
2487
3683
|
if (cents === null || cents === undefined) return "?";
|
|
2488
3684
|
const sign = String(currency || "usd").toLowerCase() === "eur" ? "€" : "$";
|
|
@@ -2525,10 +3721,17 @@ ${bold("COMMANDS")}
|
|
|
2525
3721
|
${cyan("find")} status | download | resume | cancel <job_id> Manage a find job ${dim("(free)")}
|
|
2526
3722
|
${cyan("find pricing")} Finder prices, balance, limits ${dim("(free)")}
|
|
2527
3723
|
${cyan("campaigns")} ... Email outreach: create, write, enroll, launch ${dim("(argorant campaigns help)")}
|
|
2528
|
-
${cyan("inboxes")} list | connect-google
|
|
3724
|
+
${cyan("inboxes")} list | connect-google | connect-smtp | test-smtp Connected mailboxes; connect Google Workspace or any provider
|
|
2529
3725
|
${cyan("inboxes")} quote | order | status | renewal New mailboxes on new domains ${dim("(saved card or payment link)")}
|
|
2530
3726
|
${cyan("inbox")} list | read | reply | forward | classify Replies across campaigns; answer in-thread or forward
|
|
2531
3727
|
${cyan("blocklist")} list | add | remove Addresses and domains never contacted
|
|
3728
|
+
${cyan("account")} Plan, credits, request limit, campaign emails ${dim("(free)")}
|
|
3729
|
+
${cyan("lists")} ls | create | show | people | rename | rm | export Saved lists ${dim("(argorant lists help)")}
|
|
3730
|
+
${cyan("exports")} ls | status | download Your exports, a page at a time ${dim("(free)")}
|
|
3731
|
+
${cyan("companies")} count | search | people Companies that match your filters ${dim("(free)")}
|
|
3732
|
+
${cyan("lookup")} industries | countries | keywords | technologies | titles [text] Exact filter labels ${dim("(free)")}
|
|
3733
|
+
${cyan("credits")} packs | quote | buy | purchases Buy credit packs ${dim("(asks first; saved card or payment link)")}
|
|
3734
|
+
${cyan("webhooks")} ls | add | set | test | deliveries | rm Get events sent to your server ${dim("(Pro plan and up)")}
|
|
2532
3735
|
|
|
2533
3736
|
${bold("FILTERS")}
|
|
2534
3737
|
--keywords <k> Comma = OR. The widest, most reliable filter - prefer it
|
|
@@ -2551,16 +3754,23 @@ ${bold("OPTIONS")}
|
|
|
2551
3754
|
10 credits per phone number returned, nothing if none is found;
|
|
2552
3755
|
numbers you already unlocked are free. From the Pro plan.
|
|
2553
3756
|
--max-credits <n> Credit cap for a \`find --file\` job (it pauses at the cap)
|
|
3757
|
+
--cursor <c> Next page of a list (printed under each page); --all reads every page
|
|
3758
|
+
--idempotency-key <k> Your own key for this command's writes, so running it
|
|
3759
|
+
again replays the first answer instead of doing the work twice
|
|
3760
|
+
(by default every write gets a fresh key)
|
|
2554
3761
|
--grade <g> valid (default) or valid-plus-catchall - which deliverable
|
|
2555
3762
|
grade to include on reveal/export. You only ever pay for
|
|
2556
3763
|
deliverable contacts; this is the one grade distinction
|
|
2557
3764
|
exposed anywhere. ${dim("(coming soon - currently a no-op; see docs)")}
|
|
2558
3765
|
|
|
2559
3766
|
${bold("NON-INTERACTIVE USE")} ${dim("(agents, CI, pipes)")}
|
|
2560
|
-
${red("reveal, export, verify --file, and find SPEND CREDITS WITHOUT A PROMPT")} whenever
|
|
3767
|
+
${red("reveal, export, lists export, verify --file, and find SPEND CREDITS WITHOUT A PROMPT")} whenever
|
|
2561
3768
|
stdin is not a TTY, or when --yes / --json is passed. The confirmation is a
|
|
2562
3769
|
convenience for humans at a terminal, never a safety net. Check your -n.
|
|
2563
3770
|
A single \`find\` never asks: each lookup costs at most 1 credit.
|
|
3771
|
+
\`credits buy\`, \`lists rm\` and \`webhooks rm\` never run without a yes: at a
|
|
3772
|
+
terminal they ask, anywhere else they need --yes.
|
|
3773
|
+
Errors print a request id. Quote it if you contact support.
|
|
2564
3774
|
|
|
2565
3775
|
${bold("EXIT CODES")}
|
|
2566
3776
|
0 ok · 1 error · 2 not authenticated · 3 forbidden (missing scope)
|
|
@@ -2583,26 +3793,51 @@ ${bold("EXAMPLES")}
|
|
|
2583
3793
|
${p} verify --file my-list.csv -o verified.csv
|
|
2584
3794
|
${p} find "Patrick Collison" --domain stripe.com
|
|
2585
3795
|
${p} find --file people.csv -o found.csv --max-credits 200
|
|
3796
|
+
${p} lookup industries soft
|
|
3797
|
+
${p} lists create --name "DACH CFOs" --title CFO --country Germany,Austria,Switzerland
|
|
3798
|
+
${p} lists export 1240 -n 500 -o cfos.csv
|
|
3799
|
+
${p} credits buy 5000
|
|
3800
|
+
${p} webhooks add https://example.com/hooks/argorant --events reply.received,reply.positive
|
|
2586
3801
|
|
|
2587
3802
|
Docs: ${cyan("https://argorant.com/docs/cli")}
|
|
2588
3803
|
`);
|
|
2589
3804
|
}
|
|
2590
3805
|
|
|
3806
|
+
// Commands with their own flag vocabulary (readFlags), never the generic
|
|
3807
|
+
// filter parser. `list` with a 0.13.0 subcommand goes to `lists`.
|
|
3808
|
+
const OWN_FLAG_COMMANDS = {
|
|
3809
|
+
campaigns: (a) => cmdCampaigns(a),
|
|
3810
|
+
campaign: (a) => cmdCampaigns(a),
|
|
3811
|
+
inboxes: (a) => cmdInboxes(a),
|
|
3812
|
+
mailboxes: (a) => cmdInboxes(a),
|
|
3813
|
+
inbox: (a) => cmdInboxThreads(a),
|
|
3814
|
+
unibox: (a) => cmdInboxThreads(a),
|
|
3815
|
+
blocklist: (a) => cmdBlocklist(a),
|
|
3816
|
+
find: (a) => cmdFind(a),
|
|
3817
|
+
account: (a) => cmdAccount(a),
|
|
3818
|
+
lists: (a) => cmdLists(a),
|
|
3819
|
+
exports: (a) => cmdExports(a),
|
|
3820
|
+
credits: (a) => cmdCredits(a),
|
|
3821
|
+
webhooks: (a) => cmdWebhooks(a),
|
|
3822
|
+
webhook: (a) => cmdWebhooks(a),
|
|
3823
|
+
companies: (a) => cmdCompanies(a),
|
|
3824
|
+
lookup: (a) => cmdLookup(a),
|
|
3825
|
+
lookups: (a) => cmdLookup(a),
|
|
3826
|
+
};
|
|
3827
|
+
const LISTS_SUBCOMMANDS = new Set(["ls", "people", "items", "rename", "rm", "delete", "remove", "export", "show", "get", "help"]);
|
|
3828
|
+
|
|
2591
3829
|
async function main() {
|
|
2592
|
-
const argv = process.argv.slice(2);
|
|
3830
|
+
const argv = takeIdempotencyFlag(process.argv.slice(2));
|
|
2593
3831
|
const cmd = argv[0];
|
|
2594
3832
|
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") return help();
|
|
2595
3833
|
if (cmd === "version" || cmd === "--version" || cmd === "-v") return console.log(VERSION);
|
|
2596
3834
|
// `campaigns` and `find` have their own flag vocabulary (--step, --count, --first, --max-credits, ...)
|
|
2597
3835
|
// handled by readFlags - it never goes through the generic filter parser
|
|
2598
3836
|
// below, which would reject those flags as unknown.
|
|
2599
|
-
|
|
3837
|
+
const own = OWN_FLAG_COMMANDS[cmd] || (cmd === "list" && LISTS_SUBCOMMANDS.has(String(argv[1] || "").toLowerCase()) ? OWN_FLAG_COMMANDS.lists : null);
|
|
3838
|
+
if (own) {
|
|
2600
3839
|
try {
|
|
2601
|
-
|
|
2602
|
-
else if (cmd === "find") await cmdFind(argv.slice(1));
|
|
2603
|
-
else if (cmd === "blocklist") await cmdBlocklist(argv.slice(1));
|
|
2604
|
-
else if (cmd === "inbox" || cmd === "unibox") await cmdInboxThreads(argv.slice(1));
|
|
2605
|
-
else await cmdInboxes(argv.slice(1));
|
|
3840
|
+
await own(argv.slice(1));
|
|
2606
3841
|
} catch (e) {
|
|
2607
3842
|
die(e && e.message ? e.message : String(e));
|
|
2608
3843
|
}
|
|
@@ -2632,4 +3867,15 @@ async function main() {
|
|
|
2632
3867
|
}
|
|
2633
3868
|
}
|
|
2634
3869
|
|
|
2635
|
-
main
|
|
3870
|
+
if (require.main === module) {
|
|
3871
|
+
main();
|
|
3872
|
+
} else {
|
|
3873
|
+
// Loaded by the offline tests (scripts/test-unit.mjs): nothing runs, the
|
|
3874
|
+
// helpers are handed out.
|
|
3875
|
+
module.exports = {
|
|
3876
|
+
parseArgs, readFlags, flagValue, parseLimit, takeIdempotencyFlag, nextIdempotencyKey, isV1Write, IDEM,
|
|
3877
|
+
errorMessage, errorCode, requestIdOf, detailMsg, need, EXIT, request, pageQuery, pickItems, fetchPages, retryWaitSeconds, RATE_RETRY,
|
|
3878
|
+
companyFilters, numericId, fmtPrice, packLine, quoteLines, LOOKUP_KINDS, OWN_FLAG_COMMANDS, VERSION,
|
|
3879
|
+
parseCsv, mailboxesFromCsv, mailboxBody, normalizeSecurity, SMTP_BULK_CHUNK,
|
|
3880
|
+
};
|
|
3881
|
+
}
|