@funnelchat-hq/cli 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +407 -2
  3. package/dist/cli.js +2341 -0
  4. package/package.json +62 -4
package/dist/cli.js ADDED
@@ -0,0 +1,2341 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/http/pacer.ts
4
+ var DEFAULT_RATE = 1;
5
+ var MAX_RATE = 10;
6
+ var Pacer = class {
7
+ constructor(ratePerSecond, clock, sleep) {
8
+ this.clock = clock;
9
+ this.sleep = sleep;
10
+ this.intervalMs = 1e3 / ratePerSecond;
11
+ }
12
+ clock;
13
+ sleep;
14
+ intervalMs;
15
+ #next = 0;
16
+ async wait(signal) {
17
+ const now = this.clock();
18
+ const start = Math.max(now, this.#next);
19
+ this.#next = start + this.intervalMs;
20
+ if (start > now) await this.sleep(start - now, signal);
21
+ }
22
+ };
23
+ var realSleep = (ms, signal) => new Promise((resolve2, reject) => {
24
+ if (signal?.aborted) {
25
+ reject(new Error("aborted"));
26
+ return;
27
+ }
28
+ const onAbort = () => {
29
+ clearTimeout(timer);
30
+ reject(new Error("aborted"));
31
+ };
32
+ const timer = setTimeout(() => {
33
+ signal?.removeEventListener("abort", onAbort);
34
+ resolve2();
35
+ }, ms);
36
+ signal?.addEventListener("abort", onAbort, { once: true });
37
+ });
38
+
39
+ // src/main.ts
40
+ import { Command, CommanderError } from "commander";
41
+
42
+ // src/errors.ts
43
+ var ExitCode = {
44
+ OK: 0,
45
+ /** Something the CLI did not expect. */
46
+ UNEXPECTED: 1,
47
+ /** Wrong command, option or value; or `--resume` with options that differ from the original run. */
48
+ USAGE: 2,
49
+ /** No key, a key the API rejects (`401`), or a key without the permission the route needs (`403`). */
50
+ AUTH: 3,
51
+ /** The resource does not exist in the account (`404`). */
52
+ NOT_FOUND: 4,
53
+ /** A limit the CLI does not wait for: the daily cap or a `Retry-After` too long. Resumable. */
54
+ RATE_LIMITED: 5,
55
+ /** The API failed (`5xx`, `422`…) or could not be reached, after the retries. */
56
+ API: 6,
57
+ /** The output already exists: the CLI never overwrites a file. */
58
+ EXISTS: 7,
59
+ /** Interrupted with Ctrl+C. An export can be resumed. */
60
+ INTERRUPTED: 130
61
+ };
62
+ var CliError = class extends Error {
63
+ exitCode;
64
+ code;
65
+ hint;
66
+ details;
67
+ constructor(message, options) {
68
+ super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
69
+ this.name = new.target.name;
70
+ this.exitCode = options.exitCode;
71
+ this.code = options.code;
72
+ this.hint = options.hint;
73
+ this.details = { ...options.details };
74
+ }
75
+ };
76
+ function usageError(message, hint) {
77
+ return new CliError(message, { exitCode: ExitCode.USAGE, code: "usage", ...hint ? { hint } : {} });
78
+ }
79
+ var ApiError = class extends CliError {
80
+ status;
81
+ /** The stable `error` code of the body (`daily_cap_exceeded`, `permission_denied`…), when there is one. */
82
+ apiCode;
83
+ /** Seconds from `Retry-After`, when present. */
84
+ retryAfter;
85
+ fieldErrors;
86
+ route;
87
+ domain;
88
+ /** `X-Request-Id` of the response, when it had one. */
89
+ requestId;
90
+ constructor(init) {
91
+ const record = isRecord(init.body) ? init.body : void 0;
92
+ const apiCode = typeof record?.error === "string" && record.error !== "" ? record.error : void 0;
93
+ const apiMessage = typeof record?.message === "string" && record.message !== "" ? record.message : void 0;
94
+ const fieldErrors = isRecord(record?.errors) ? record.errors : void 0;
95
+ const described = describe(init, apiCode, apiMessage, fieldErrors);
96
+ const { exitCode, message } = init.override ?? described;
97
+ const hint = withRequestId(init.override ? void 0 : described.hint, init.requestId);
98
+ super(message, {
99
+ exitCode,
100
+ code: apiCode ?? `http_${init.status}`,
101
+ ...hint ? { hint } : {},
102
+ details: {
103
+ status: init.status,
104
+ route: `${init.method} ${init.route}`,
105
+ ...apiCode ? { api_error: apiCode } : {},
106
+ ...init.retryAfter !== void 0 ? { retry_after: init.retryAfter } : {},
107
+ ...fieldErrors ? { errors: fieldErrors } : {},
108
+ ...init.requestId ? { request_id: init.requestId } : {},
109
+ ...init.override?.details
110
+ }
111
+ });
112
+ this.status = init.status;
113
+ this.apiCode = apiCode;
114
+ this.retryAfter = init.retryAfter;
115
+ this.fieldErrors = fieldErrors;
116
+ this.route = init.route;
117
+ this.domain = init.domain;
118
+ this.requestId = init.requestId;
119
+ }
120
+ };
121
+ function withRequestId(hint, requestId) {
122
+ if (!requestId) return hint;
123
+ const cite = `Request id (X-Request-Id): ${requestId}. Quote it, with the approximate time, if you report this to support.`;
124
+ return hint ? `${hint}
125
+ ${cite}` : cite;
126
+ }
127
+ function describe(init, apiCode, apiMessage, fieldErrors) {
128
+ const where = `${init.method} ${init.route}`;
129
+ const said = apiMessage ? `: ${apiMessage}` : ".";
130
+ const { status } = init;
131
+ if (status === 401) {
132
+ return {
133
+ exitCode: ExitCode.AUTH,
134
+ message: `The API rejected the key (401 on ${where})${said}`,
135
+ hint: "Check that the key is complete (with the `<id>|` at the start) and still active, and run `funnelchat login` again."
136
+ };
137
+ }
138
+ if (status === 403) {
139
+ const permission = init.permission ? ` This route needs \`${init.permission}\`.` : "";
140
+ return {
141
+ exitCode: ExitCode.AUTH,
142
+ message: `The key cannot use ${where} (403${apiCode ? ` ${apiCode}` : ""})${said}`,
143
+ hint: apiCode === "route_not_available" ? "No key enables it for now." : `Create a key with the missing permission.${permission}`
144
+ };
145
+ }
146
+ if (status === 404 && apiCode === "route_not_found") {
147
+ return {
148
+ exitCode: ExitCode.NOT_FOUND,
149
+ message: `The route does not exist in the API (404 route_not_found on ${where}).`,
150
+ hint: "The CLI only calls routes of the API reference: check that the base URL is the public API's (https://api.funnelchat.com) and that you have the latest version of the CLI."
151
+ };
152
+ }
153
+ if (status === 404) {
154
+ return { exitCode: ExitCode.NOT_FOUND, message: `Not found in your account (404 on ${where})${said}` };
155
+ }
156
+ if (status === 429) {
157
+ return { exitCode: ExitCode.RATE_LIMITED, message: `The API asked to wait (429${apiCode ? ` ${apiCode}` : ""} on ${where})${said}` };
158
+ }
159
+ if (status === 422 && fieldErrors) {
160
+ const first = Object.entries(fieldErrors)[0];
161
+ const detail = first ? ` ${first[0]}: ${first[1]?.[0] ?? ""}` : "";
162
+ return { exitCode: ExitCode.API, message: `The API did not accept the request (422 on ${where})${said}${detail ? `
163
+ ${detail}` : ""}` };
164
+ }
165
+ if (status === 500 && apiCode === "edge_misconfigured") {
166
+ return {
167
+ exitCode: ExitCode.API,
168
+ message: `The API is not available right now (500 edge_misconfigured on ${where}): the API's entry point is not configured to reach this area.`,
169
+ hint: "It is a problem on Funnelchat's side: please report it."
170
+ };
171
+ }
172
+ if (status === 502 && apiCode === "origin_unreachable") {
173
+ return {
174
+ exitCode: ExitCode.API,
175
+ message: `The API is not available right now (502 origin_unreachable on ${where}): the API's entry point could not reach the area that serves the route.`,
176
+ hint: "Try again in a few minutes; if it persists, please report it."
177
+ };
178
+ }
179
+ return { exitCode: ExitCode.API, message: `The API answered ${status}${apiCode ? ` ${apiCode}` : ""} on ${where}${said}` };
180
+ }
181
+ var ConnectionError = class extends CliError {
182
+ constructor(message, cause) {
183
+ super(message, { exitCode: ExitCode.API, code: "connection_error", hint: "Check your connection and the base URL, and try again.", cause });
184
+ }
185
+ };
186
+ var InterruptedError = class extends CliError {
187
+ constructor() {
188
+ super("Interrupted.", { exitCode: ExitCode.INTERRUPTED, code: "interrupted" });
189
+ }
190
+ };
191
+ function parseRetryAfter(value, now = Date.now()) {
192
+ if (value === null || value === void 0) return void 0;
193
+ const trimmed = value.trim();
194
+ if (trimmed === "") return void 0;
195
+ if (/^\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
196
+ const date = Date.parse(trimmed);
197
+ if (Number.isNaN(date)) return void 0;
198
+ return Math.max(0, Math.ceil((date - now) / 1e3));
199
+ }
200
+ function isRecord(value) {
201
+ return typeof value === "object" && value !== null && !Array.isArray(value);
202
+ }
203
+
204
+ // src/version.ts
205
+ var VERSION = "0.1.1";
206
+ var PACKAGE_NAME = "@funnelchat-hq/cli";
207
+ var USER_AGENT_PRODUCT = "funnelchat-cli";
208
+
209
+ // src/http/pagination.ts
210
+ function extractItems(body) {
211
+ if (Array.isArray(body)) return body;
212
+ if (isRecord(body) && Array.isArray(body.data)) return body.data;
213
+ return [];
214
+ }
215
+ function toInt(value) {
216
+ if (typeof value === "number" && Number.isInteger(value)) return value;
217
+ if (typeof value === "string" && /^\d+$/.test(value)) return Number(value);
218
+ return null;
219
+ }
220
+ function nextPageQuery(body) {
221
+ if (!isRecord(body)) return null;
222
+ for (const holder of [body.meta, body]) {
223
+ if (!isRecord(holder)) continue;
224
+ if ("next_cursor" in holder) {
225
+ return typeof holder.next_cursor === "string" && holder.next_cursor !== "" ? { cursor: holder.next_cursor } : null;
226
+ }
227
+ const current = toInt(holder.current_page);
228
+ const last = toInt(holder.last_page);
229
+ if (current !== null && last !== null) return current < last ? { page: current + 1 } : null;
230
+ }
231
+ const links = isRecord(body.links) ? body.links : void 0;
232
+ const nextUrl = links?.next ?? body.next_page_url;
233
+ if (typeof nextUrl === "string" && nextUrl !== "") {
234
+ const params = new URL(nextUrl, "https://placeholder.invalid").searchParams;
235
+ const cursor = params.get("cursor");
236
+ if (cursor) return { cursor };
237
+ const page = toInt(params.get("page"));
238
+ if (page !== null) return { page };
239
+ }
240
+ return null;
241
+ }
242
+ function followingPage(body, query) {
243
+ if (extractItems(body).length === 0) return null;
244
+ const next = nextPageQuery(body);
245
+ if (next === null) return null;
246
+ const unchanged = Object.entries(next).every(([key, value]) => String(query[key]) === String(value));
247
+ return unchanged ? null : next;
248
+ }
249
+ function totalOf(body) {
250
+ if (!isRecord(body) || !isRecord(body.meta)) return null;
251
+ return toInt(body.meta.total);
252
+ }
253
+
254
+ // src/http/url.ts
255
+ function serializeQuery(query) {
256
+ if (!query) return "";
257
+ const params = new URLSearchParams();
258
+ for (const [key, value] of Object.entries(query)) append(params, key, value);
259
+ return params.toString();
260
+ }
261
+ function append(params, key, value) {
262
+ if (value === void 0 || value === null) return;
263
+ if (Array.isArray(value)) {
264
+ const scalarKey = key.endsWith("[]") ? key : `${key}[]`;
265
+ const base = key.endsWith("[]") ? key.slice(0, -2) : key;
266
+ value.forEach((item, index) => {
267
+ if (isPlainObject(item) || Array.isArray(item)) append(params, `${base}[${index}]`, item);
268
+ else append(params, scalarKey, item);
269
+ });
270
+ return;
271
+ }
272
+ if (value instanceof Date) {
273
+ params.append(key, value.toISOString());
274
+ return;
275
+ }
276
+ if (isPlainObject(value)) {
277
+ for (const [child, childValue] of Object.entries(value)) append(params, `${key}[${child}]`, childValue);
278
+ return;
279
+ }
280
+ if (typeof value === "boolean") {
281
+ params.append(key, value ? "1" : "0");
282
+ return;
283
+ }
284
+ params.append(key, String(value));
285
+ }
286
+ function isPlainObject(value) {
287
+ if (typeof value !== "object" || value === null) return false;
288
+ const proto = Object.getPrototypeOf(value);
289
+ return proto === Object.prototype || proto === null;
290
+ }
291
+ function interpolatePath(template, params) {
292
+ return template.replace(/\{([^}]+)\}/g, (_, name) => {
293
+ const value = params?.[name];
294
+ if (value === void 0 || value === null || value === "") {
295
+ throw new TypeError(`Missing the path parameter "${name}" for ${template}.`);
296
+ }
297
+ return encodeURIComponent(String(value));
298
+ });
299
+ }
300
+ function joinUrl(baseUrl, path, query) {
301
+ const base = baseUrl.replace(/\/+$/, "");
302
+ const suffix = path.startsWith("/") ? path : `/${path}`;
303
+ if (!query) return `${base}${suffix}`;
304
+ return `${base}${suffix}${suffix.includes("?") ? "&" : "?"}${query}`;
305
+ }
306
+
307
+ // src/http/client.ts
308
+ var DEFAULT_TIMEOUT_MS = 3e4;
309
+ var DEFAULT_MAX_RETRIES = 4;
310
+ var DEFAULT_MAX_WAIT_SECONDS = 60;
311
+ var INITIAL_BACKOFF_MS = 1e3;
312
+ var MAX_BACKOFF_MS = 3e4;
313
+ var ApiClient = class {
314
+ baseUrl;
315
+ /** Requests sent, retries included. */
316
+ requests = 0;
317
+ #apiKey;
318
+ #options;
319
+ constructor(options) {
320
+ this.#apiKey = options.apiKey;
321
+ this.#options = options;
322
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
323
+ }
324
+ /** What `JSON.stringify(client)` shows: never the key. */
325
+ toJSON() {
326
+ return { baseUrl: this.baseUrl };
327
+ }
328
+ /** One `GET` of `route`, with retries. Returns the parsed body. */
329
+ async get(route, options = {}) {
330
+ return await this.#send(route, options);
331
+ }
332
+ /** One page of a listing route, and the query of the page after it. */
333
+ async page(route, options = {}) {
334
+ const body = await this.#send(route, options);
335
+ return { items: extractItems(body), body, next: followingPage(body, options.query ?? {}) };
336
+ }
337
+ /** Every item of every page of a listing route. */
338
+ async *all(route, options = {}) {
339
+ let query = { ...options.query };
340
+ for (; ; ) {
341
+ const page = await this.page(route, { ...options, query });
342
+ yield* page.items;
343
+ if (!page.next) return;
344
+ query = mergeNext(query, page.next);
345
+ }
346
+ }
347
+ async #send(route, options) {
348
+ const opts = this.#options;
349
+ const method = route.method;
350
+ const pathname = interpolatePath(route.path, options.path);
351
+ const url = joinUrl(this.baseUrl, pathname, serializeQuery(options.query));
352
+ const maxRetries = Math.max(0, opts.maxRetries ?? DEFAULT_MAX_RETRIES);
353
+ const maxWait = opts.maxWaitSeconds ?? DEFAULT_MAX_WAIT_SECONDS;
354
+ const retriesAllowed = method === "GET";
355
+ const random = opts.random ?? Math.random;
356
+ const headers = {
357
+ Accept: "application/json",
358
+ "Accept-Language": "es",
359
+ "User-Agent": `${USER_AGENT_PRODUCT}/${VERSION} node/${process.version}`,
360
+ Authorization: `Bearer ${this.#apiKey}`
361
+ };
362
+ for (let attempt = 0; ; attempt++) {
363
+ const canRetry = retriesAllowed && attempt < maxRetries;
364
+ await opts.pacer.wait(opts.signal);
365
+ this.requests++;
366
+ const started = opts.clock();
367
+ opts.log?.(`\u2192 ${method} ${url}${attempt > 0 ? ` (attempt ${attempt + 1})` : ""}`);
368
+ let response;
369
+ let text;
370
+ try {
371
+ ({ response, text } = await this.#attempt(method, url, headers));
372
+ } catch (error2) {
373
+ if (error2 instanceof InterruptedError) throw error2;
374
+ if (canRetry) {
375
+ const delay2 = backoffMs(attempt, random);
376
+ opts.log?.(`\u2190 ${describeConnectionError(error2)}; retrying in ${formatSeconds(delay2 / 1e3)}`);
377
+ await this.#sleep(delay2);
378
+ continue;
379
+ }
380
+ throw error2;
381
+ }
382
+ const requestId = response.headers.get("x-request-id") ?? void 0;
383
+ opts.log?.(`\u2190 ${response.status} in ${opts.clock() - started} ms${requestId ? ` \xB7 X-Request-Id ${requestId}` : ""}`);
384
+ const body = parseBody(response, text);
385
+ if (response.ok) return body;
386
+ const retryAfter = parseRetryAfter(response.headers.get("retry-after"), opts.clock());
387
+ const error = new ApiError({
388
+ status: response.status,
389
+ body,
390
+ retryAfter,
391
+ method,
392
+ route: route.path,
393
+ domain: route.domain,
394
+ permission: route.permission,
395
+ requestId
396
+ });
397
+ const retryable = response.status === 429 || response.status >= 500 && error.apiCode !== "edge_misconfigured";
398
+ if (!retryable) throw error;
399
+ if (retryAfter !== void 0 && retryAfter > maxWait) {
400
+ throw stopForLongWait(error, retryAfter, opts.clock());
401
+ }
402
+ if (!canRetry) {
403
+ if (response.status === 429) error.hint = [`Wait ${formatSeconds(retryAfter ?? 60)} and try again.`, error.hint].filter(Boolean).join("\n");
404
+ throw error;
405
+ }
406
+ const delay = retryAfter !== void 0 ? Math.ceil(retryAfter * 1e3) : backoffMs(attempt, random);
407
+ opts.log?.(` ${response.status}${error.apiCode ? ` ${error.apiCode}` : ""}: waiting ${formatSeconds(delay / 1e3)}${retryAfter !== void 0 ? " (Retry-After)" : ""}`);
408
+ await this.#sleep(delay);
409
+ }
410
+ }
411
+ async #sleep(ms) {
412
+ try {
413
+ await this.#options.sleep(ms, this.#options.signal);
414
+ } catch (error) {
415
+ if (this.#options.signal?.aborted) throw new InterruptedError();
416
+ throw error;
417
+ }
418
+ }
419
+ async #attempt(method, url, headers) {
420
+ const signal = this.#options.signal;
421
+ if (signal?.aborted) throw new InterruptedError();
422
+ const timeoutMs = this.#options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
423
+ const controller2 = new AbortController();
424
+ const onAbort = () => controller2.abort();
425
+ signal?.addEventListener("abort", onAbort, { once: true });
426
+ let timedOut = false;
427
+ const timer = setTimeout(() => {
428
+ timedOut = true;
429
+ controller2.abort();
430
+ }, timeoutMs);
431
+ const classify = (cause) => {
432
+ if (signal?.aborted) return new InterruptedError();
433
+ if (timedOut) return new ConnectionError(`The Funnelchat API did not answer within ${timeoutMs / 1e3} s.`, cause);
434
+ return new ConnectionError(`Could not connect to the Funnelchat API (${hostOf(url)}).`, cause);
435
+ };
436
+ try {
437
+ let response;
438
+ try {
439
+ response = await this.#options.fetch(url, { method, headers, signal: controller2.signal });
440
+ } catch (error) {
441
+ throw classify(error);
442
+ }
443
+ try {
444
+ return { response, text: await response.text() };
445
+ } catch (error) {
446
+ throw classify(error);
447
+ }
448
+ } finally {
449
+ clearTimeout(timer);
450
+ signal?.removeEventListener("abort", onAbort);
451
+ }
452
+ }
453
+ };
454
+ function mergeNext(query, next) {
455
+ const merged = { ...query, ...next };
456
+ if ("cursor" in next) delete merged.page;
457
+ if ("page" in next) delete merged.cursor;
458
+ return merged;
459
+ }
460
+ function backoffMs(attempt, random = Math.random) {
461
+ const base = Math.min(INITIAL_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
462
+ return Math.round(base * (1 - 0.25 * random()));
463
+ }
464
+ function stopForLongWait(error, retryAfter, now) {
465
+ const at = new Date(now + retryAfter * 1e3);
466
+ const when = `${formatSeconds(retryAfter)} (at ${at.toISOString().slice(11, 16)} UTC on ${at.toISOString().slice(0, 10)})`;
467
+ let message;
468
+ if (error.apiCode === "daily_cap_exceeded") {
469
+ const domain = error.domain ?? "this area";
470
+ message = `The daily cap of calls to ${domain} was reached for this key. It resets in ${when}.`;
471
+ } else {
472
+ message = `${error.message} The API asks to wait ${when}, longer than the CLI waits on its own.`;
473
+ }
474
+ return new ApiError({
475
+ status: error.status,
476
+ body: error.apiCode ? { error: error.apiCode } : null,
477
+ retryAfter,
478
+ method: "GET",
479
+ route: error.route,
480
+ domain: error.domain,
481
+ permission: null,
482
+ requestId: error.requestId,
483
+ override: {
484
+ message,
485
+ exitCode: error.status === 429 ? ExitCode.RATE_LIMITED : ExitCode.API,
486
+ details: { resets_at: at.toISOString() }
487
+ }
488
+ });
489
+ }
490
+ function formatSeconds(seconds) {
491
+ const s = Math.max(0, Math.ceil(seconds));
492
+ if (s < 60) return `${s} s`;
493
+ const h = Math.floor(s / 3600);
494
+ const m = Math.floor(s % 3600 / 60);
495
+ if (h === 0) return `${m} min${s % 60 ? ` ${s % 60} s` : ""}`;
496
+ return `${h} h${m ? ` ${m} min` : ""}`;
497
+ }
498
+ function parseBody(response, text) {
499
+ if (text === "") return null;
500
+ const type = response.headers.get("content-type") ?? "";
501
+ if (type.includes("json") || /^\s*[[{]/.test(text)) {
502
+ try {
503
+ return JSON.parse(text);
504
+ } catch {
505
+ return text;
506
+ }
507
+ }
508
+ return text.length <= 300 ? text : `${text.slice(0, 300)}\u2026`;
509
+ }
510
+ function hostOf(url) {
511
+ try {
512
+ return new URL(url).host;
513
+ } catch {
514
+ return url;
515
+ }
516
+ }
517
+ function describeConnectionError(error) {
518
+ return error instanceof Error ? error.message : "network error";
519
+ }
520
+
521
+ // src/http/redact.ts
522
+ var KEY_SHAPE = /fc_[a-z]+_[A-Za-z0-9]{4,}/g;
523
+ var REDACTED = "[key hidden]";
524
+ var Redactor = class {
525
+ #secrets = [];
526
+ /** Adds a value to blank everywhere: the whole key and its secret part. */
527
+ add(key) {
528
+ if (!key) return;
529
+ const parts = [key, key.includes("|") ? key.slice(key.indexOf("|") + 1) : ""];
530
+ for (const part of parts) if (part.length >= 6 && !this.#secrets.includes(part)) this.#secrets.push(part);
531
+ this.#secrets.sort((a, b) => b.length - a.length);
532
+ }
533
+ redact(text) {
534
+ let out = text;
535
+ for (const secret of this.#secrets) out = out.split(secret).join(REDACTED);
536
+ return out.replace(KEY_SHAPE, REDACTED);
537
+ }
538
+ };
539
+
540
+ // src/config/store.ts
541
+ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync } from "fs";
542
+ import { homedir } from "os";
543
+ import { join } from "path";
544
+ var CONFIG_FILE_NAME = "config.json";
545
+ function configDir(env, platform = process.platform) {
546
+ if (env.FUNNELCHAT_CONFIG_DIR) return env.FUNNELCHAT_CONFIG_DIR;
547
+ if (platform === "win32" && env.APPDATA) return join(env.APPDATA, "funnelchat");
548
+ const base = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME !== "" ? env.XDG_CONFIG_HOME : join(env.HOME ?? homedir(), ".config");
549
+ return join(base, "funnelchat");
550
+ }
551
+ function configPath(env) {
552
+ return join(configDir(env), CONFIG_FILE_NAME);
553
+ }
554
+ function loadConfig(env) {
555
+ const path = configPath(env);
556
+ if (!existsSync(path)) return { path, config: { version: 1, profiles: {} }, insecurePermissions: false };
557
+ let parsed;
558
+ try {
559
+ parsed = JSON.parse(readFileSync(path, "utf8"));
560
+ } catch (cause) {
561
+ throw new CliError(`Could not read the configuration file ${path}: it is not valid JSON.`, {
562
+ exitCode: ExitCode.USAGE,
563
+ code: "config_unreadable",
564
+ hint: "Fix it or delete it and run `funnelchat login` again.",
565
+ cause
566
+ });
567
+ }
568
+ const profiles = {};
569
+ if (isRecord(parsed) && isRecord(parsed.profiles)) {
570
+ for (const [name, value] of Object.entries(parsed.profiles)) {
571
+ if (!isRecord(value)) continue;
572
+ profiles[name] = {
573
+ ...typeof value.api_key === "string" ? { api_key: value.api_key } : {},
574
+ ...typeof value.base_url === "string" ? { base_url: value.base_url } : {},
575
+ ...typeof value.saved_at === "string" ? { saved_at: value.saved_at } : {}
576
+ };
577
+ }
578
+ }
579
+ const current = isRecord(parsed) && typeof parsed.current_profile === "string" ? parsed.current_profile : void 0;
580
+ const insecurePermissions = process.platform !== "win32" && (statSync(path).mode & 63) !== 0;
581
+ return { path, config: { version: 1, ...current ? { current_profile: current } : {}, profiles }, insecurePermissions };
582
+ }
583
+ function saveConfig(env, config) {
584
+ const dir = configDir(env);
585
+ mkdirSync(dir, { recursive: true, mode: 448 });
586
+ const path = join(dir, CONFIG_FILE_NAME);
587
+ const tmp = join(dir, `.${CONFIG_FILE_NAME}.${process.pid}.tmp`);
588
+ const fd = openSync(tmp, "w", 384);
589
+ try {
590
+ writeSync(fd, `${JSON.stringify(config, null, 2)}
591
+ `);
592
+ fsyncSync(fd);
593
+ } finally {
594
+ closeSync(fd);
595
+ }
596
+ try {
597
+ chmodSync(tmp, 384);
598
+ renameSync(tmp, path);
599
+ chmodSync(path, 384);
600
+ } catch (error) {
601
+ rmSync(tmp, { force: true });
602
+ throw error;
603
+ }
604
+ return path;
605
+ }
606
+
607
+ // src/config/credentials.ts
608
+ var PRODUCTION_URL = "https://api.funnelchat.com";
609
+ var STAGING_URL = "https://api-staging.funnelchat.com";
610
+ var DEFAULT_PROFILE = "default";
611
+ var KEY_SHAPE2 = /^\d+\|fc_[a-z]+_[A-Za-z0-9]+$/;
612
+ function activeProfileName(flags, env, config) {
613
+ return flags.profile ?? nonEmpty(env.FUNNELCHAT_PROFILE) ?? config.current_profile ?? DEFAULT_PROFILE;
614
+ }
615
+ function checkKeyShape(key) {
616
+ const trimmed = key.trim();
617
+ if (!KEY_SHAPE2.test(trimmed)) {
618
+ throw new CliError("The key does not have the shape of a Funnelchat key (`<id>|fc_live_\u2026`).", {
619
+ exitCode: ExitCode.AUTH,
620
+ code: "malformed_key",
621
+ hint: "Copy it in full from the Developers screen of your account, including the `<id>|` at the start."
622
+ });
623
+ }
624
+ return trimmed;
625
+ }
626
+ function resolveBaseUrl(flags, env, profileUrl) {
627
+ if (flags.baseUrl && flags.staging) throw usageError("Use --base-url or --staging, not both.");
628
+ const raw = flags.baseUrl ?? (flags.staging ? STAGING_URL : void 0) ?? nonEmpty(env.FUNNELCHAT_BASE_URL) ?? profileUrl ?? PRODUCTION_URL;
629
+ return checkBaseUrl(raw);
630
+ }
631
+ function checkBaseUrl(raw) {
632
+ let url;
633
+ try {
634
+ url = new URL(raw);
635
+ } catch {
636
+ throw usageError(`The base URL is not valid: ${raw}`);
637
+ }
638
+ const local = ["localhost", "127.0.0.1", "[::1]", "::1"].includes(url.hostname);
639
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) {
640
+ throw usageError(
641
+ `The base URL must be https (${raw}).`,
642
+ "http is only accepted for this machine (for example the sandbox at http://127.0.0.1:4010), so the key never travels unencrypted."
643
+ );
644
+ }
645
+ if (url.search || url.hash) throw usageError(`The base URL takes no query and no #: ${raw}`);
646
+ return url.toString().replace(/\/+$/, "");
647
+ }
648
+ function resolveCredentials(flags, env, config) {
649
+ const profileName = activeProfileName(flags, env, config);
650
+ const profile = config.profiles[profileName];
651
+ const envKey = nonEmpty(env.FUNNELCHAT_API_KEY);
652
+ if (flags.profile === void 0 && envKey) {
653
+ return { apiKey: checkKeyShape(envKey), keySource: "env", profile: profileName, baseUrl: resolveBaseUrl(flags, env, void 0) };
654
+ }
655
+ if (profile?.api_key) {
656
+ return { apiKey: checkKeyShape(profile.api_key), keySource: "profile", profile: profileName, baseUrl: resolveBaseUrl(flags, env, profile.base_url) };
657
+ }
658
+ if (flags.profile !== void 0) {
659
+ throw new CliError(`The "${flags.profile}" profile has no saved key.`, {
660
+ exitCode: ExitCode.AUTH,
661
+ code: "no_key",
662
+ hint: `Save it with \`funnelchat login --profile ${flags.profile}\`.`
663
+ });
664
+ }
665
+ throw new CliError("No API key.", {
666
+ exitCode: ExitCode.AUTH,
667
+ code: "no_key",
668
+ hint: "Set the FUNNELCHAT_API_KEY variable or save it with `funnelchat login`."
669
+ });
670
+ }
671
+ function nonEmpty(value) {
672
+ return value && value.trim() !== "" ? value.trim() : void 0;
673
+ }
674
+
675
+ // src/output.ts
676
+ var Output = class {
677
+ constructor(stdout, stderr, redactor, options) {
678
+ this.stdout = stdout;
679
+ this.stderr = stderr;
680
+ this.redactor = redactor;
681
+ this.options = options;
682
+ }
683
+ stdout;
684
+ stderr;
685
+ redactor;
686
+ options;
687
+ get json() {
688
+ return this.options.json;
689
+ }
690
+ /** The result of a command: `data` with `--json`, the human lines otherwise. */
691
+ result(data, human) {
692
+ if (this.options.json) this.#write(this.stdout, `${JSON.stringify(data, null, 2)}
693
+ `);
694
+ else this.#lines(this.stdout, human());
695
+ }
696
+ /** Progress of a long command (stderr). Silent with `--quiet` or `--json`. */
697
+ progress(line) {
698
+ if (!this.options.quiet && !this.options.json) this.#lines(this.stderr, line);
699
+ }
700
+ /** A note for people (stderr). Silent with `--json`. */
701
+ note(line) {
702
+ if (!this.options.json) this.#lines(this.stderr, line);
703
+ }
704
+ /** A warning (stderr), always shown. */
705
+ warn(line) {
706
+ this.#lines(this.stderr, `warning: ${line}`);
707
+ }
708
+ /** `--verbose` only (stderr). */
709
+ verbose(line) {
710
+ if (this.options.verbose) this.#lines(this.stderr, line);
711
+ }
712
+ error(error) {
713
+ const cliError = toCliError(error);
714
+ if (this.options.json) {
715
+ const body = {
716
+ error: {
717
+ code: cliError.code,
718
+ message: cliError.message,
719
+ ...cliError.hint ? { hint: cliError.hint } : {},
720
+ exit_code: cliError.exitCode,
721
+ ...cliError.details
722
+ }
723
+ };
724
+ this.#write(this.stderr, `${JSON.stringify(body)}
725
+ `);
726
+ } else {
727
+ this.#lines(this.stderr, [`error: ${cliError.message}`, ...cliError.hint ? cliError.hint.split("\n").map((l) => ` ${l}`) : []]);
728
+ if (this.options.verbose && cliError.exitCode === ExitCode.UNEXPECTED && error instanceof Error && error.stack) {
729
+ this.#lines(this.stderr, error.stack);
730
+ }
731
+ }
732
+ return cliError.exitCode;
733
+ }
734
+ #lines(writer, lines) {
735
+ const text = Array.isArray(lines) ? lines.join("\n") : lines;
736
+ this.#write(writer, text.endsWith("\n") ? text : `${text}
737
+ `);
738
+ }
739
+ #write(writer, text) {
740
+ writer.write(this.redactor.redact(text));
741
+ }
742
+ };
743
+ function toCliError(error) {
744
+ if (error instanceof CliError) return error;
745
+ const message = error instanceof Error ? error.message : String(error);
746
+ return new CliError(`Unexpected error: ${message}`, { exitCode: ExitCode.UNEXPECTED, code: "unexpected", cause: error });
747
+ }
748
+ function table(headers, rows) {
749
+ const cells = rows.map((row) => row.map((cell) => cell === null || cell === void 0 || cell === "" ? "-" : String(cell)));
750
+ const widths = headers.map((h, i) => Math.max(h.length, ...cells.map((row) => (row[i] ?? "").length)));
751
+ const line = (row) => row.map((cell, i) => i === row.length - 1 ? cell : cell.padEnd(widths[i] ?? 0)).join(" ");
752
+ return [line(headers), ...cells.map(line)];
753
+ }
754
+
755
+ // src/context.ts
756
+ var Context = class {
757
+ constructor(io, globals) {
758
+ this.io = io;
759
+ this.globals = globals;
760
+ this.redactor.add(io.env.FUNNELCHAT_API_KEY);
761
+ this.out = new Output(io.stdout, io.stderr, this.redactor, { json: globals.json === true, quiet: globals.quiet === true, verbose: globals.verbose === true });
762
+ }
763
+ io;
764
+ globals;
765
+ redactor = new Redactor();
766
+ out;
767
+ #config;
768
+ #credentials;
769
+ #client;
770
+ #warnedConfig = false;
771
+ config() {
772
+ if (!this.#config) {
773
+ this.#config = loadConfig(this.io.env);
774
+ for (const profile of Object.values(this.#config.config.profiles)) this.redactor.add(profile.api_key);
775
+ if (this.#config.insecurePermissions && !this.#warnedConfig) {
776
+ this.#warnedConfig = true;
777
+ this.out.warn(`other users of this machine can read ${this.#config.path}. Fix it with: chmod 600 "${this.#config.path}"`);
778
+ }
779
+ }
780
+ return this.#config;
781
+ }
782
+ credentials() {
783
+ if (!this.#credentials) {
784
+ this.#credentials = resolveCredentials(this.globals, this.io.env, this.config().config);
785
+ this.redactor.add(this.#credentials.apiKey);
786
+ }
787
+ return this.#credentials;
788
+ }
789
+ client(overrides = {}) {
790
+ if (!overrides.apiKey && this.#client) return this.#client;
791
+ const apiKey = overrides.apiKey ?? this.credentials().apiKey;
792
+ const baseUrl = overrides.baseUrl ?? this.credentials().baseUrl;
793
+ this.redactor.add(apiKey);
794
+ const client = new ApiClient({
795
+ apiKey,
796
+ baseUrl,
797
+ fetch: this.io.fetch,
798
+ pacer: new Pacer(this.rate(), this.io.clock, this.io.sleep),
799
+ sleep: this.io.sleep,
800
+ clock: this.io.clock,
801
+ maxWaitSeconds: this.maxWait(),
802
+ log: (line) => this.out.verbose(line),
803
+ ...this.io.signal ? { signal: this.io.signal } : {}
804
+ });
805
+ if (!overrides.apiKey) this.#client = client;
806
+ return client;
807
+ }
808
+ /** Requests per second: `--rate`, then `FUNNELCHAT_RATE`, then 1. */
809
+ rate() {
810
+ const raw = this.globals.rate ?? this.io.env.FUNNELCHAT_RATE;
811
+ if (raw === void 0 || raw === "") return DEFAULT_RATE;
812
+ const value = Number(raw);
813
+ if (!Number.isFinite(value) || value <= 0 || value > MAX_RATE) {
814
+ throw usageError(`--rate must be a number greater than 0 and up to ${MAX_RATE} (requests per second): ${raw}`);
815
+ }
816
+ return value;
817
+ }
818
+ /** The longest `Retry-After` waited for by itself: `--max-wait`, by default 60 s. */
819
+ maxWait() {
820
+ const raw = this.globals.maxWait;
821
+ if (raw === void 0) return 60;
822
+ const value = Number(raw);
823
+ if (!Number.isInteger(value) || value < 0 || value > 3600) throw usageError(`--max-wait must be an integer from 0 to 3600 (seconds): ${raw}`);
824
+ return value;
825
+ }
826
+ };
827
+
828
+ // src/generated/routes.ts
829
+ var SPEC_SOURCE = {
830
+ "sha256": "8bbbae569b4cab520884db98f28204156c1bd6c629578bee609343c193e0dadb",
831
+ "repository": "git@github.com:Funnelchat20/developers-portal.git",
832
+ "branch": "feat/openapi-unificada",
833
+ "commit": "0ef4abb008870ed8c7cae49a08900a81e5e57ed8",
834
+ "paths": 191
835
+ };
836
+ var routes = {
837
+ "me": {
838
+ "id": "me",
839
+ "method": "GET",
840
+ "path": "/api/v1/me",
841
+ "summary": "Ver mi cuenta",
842
+ "domain": "accounts",
843
+ "permission": "accounts:read",
844
+ "pagination": "none",
845
+ "plainArray": false,
846
+ "perPageMax": null,
847
+ "query": [],
848
+ "pending": null
849
+ },
850
+ "devicesList": {
851
+ "id": "devicesList",
852
+ "method": "GET",
853
+ "path": "/api/v1/devices",
854
+ "summary": "Listar n\xFAmeros",
855
+ "domain": "accounts",
856
+ "permission": "accounts:read",
857
+ "pagination": "page",
858
+ "plainArray": true,
859
+ "perPageMax": 100,
860
+ "query": [
861
+ "perPage"
862
+ ],
863
+ "pending": null
864
+ },
865
+ "deviceHealth": {
866
+ "id": "deviceHealth",
867
+ "method": "GET",
868
+ "path": "/api/v1/devices/{device}/health",
869
+ "summary": "Ver la salud de un n\xFAmero",
870
+ "domain": "accounts",
871
+ "permission": "accounts:read",
872
+ "pagination": "none",
873
+ "plainArray": false,
874
+ "perPageMax": null,
875
+ "query": [],
876
+ "pending": null
877
+ },
878
+ "contactsList": {
879
+ "id": "contactsList",
880
+ "method": "GET",
881
+ "path": "/api/v1/contacts",
882
+ "summary": "Listar contactos",
883
+ "domain": "conversations",
884
+ "permission": "conversations:read",
885
+ "pagination": "page",
886
+ "plainArray": false,
887
+ "perPageMax": 100,
888
+ "query": [
889
+ "perPage",
890
+ "orderBy",
891
+ "orderByMethod",
892
+ "tag_ids[]",
893
+ "tag_mode",
894
+ "created_from",
895
+ "created_to",
896
+ "only_with_phone"
897
+ ],
898
+ "pending": null
899
+ },
900
+ "contactTags": {
901
+ "id": "contactTags",
902
+ "method": "GET",
903
+ "path": "/api/v1/contacts/{contact}/tags",
904
+ "summary": "Listar las etiquetas de un contacto",
905
+ "domain": "conversations",
906
+ "permission": "conversations:read",
907
+ "pagination": "page",
908
+ "plainArray": false,
909
+ "perPageMax": 100,
910
+ "query": [
911
+ "perPage"
912
+ ],
913
+ "pending": null
914
+ },
915
+ "contactCustomFields": {
916
+ "id": "contactCustomFields",
917
+ "method": "GET",
918
+ "path": "/api/v1/contacts/{contact}/custom-fields",
919
+ "summary": "Listar los campos personalizados de un contacto",
920
+ "domain": "conversations",
921
+ "permission": "conversations:read",
922
+ "pagination": "page",
923
+ "plainArray": false,
924
+ "perPageMax": 100,
925
+ "query": [
926
+ "perPage"
927
+ ],
928
+ "pending": null
929
+ },
930
+ "customFieldsList": {
931
+ "id": "customFieldsList",
932
+ "method": "GET",
933
+ "path": "/api/v1/custom-fields",
934
+ "summary": "Listar campos personalizados",
935
+ "domain": "conversations",
936
+ "permission": "conversations:read",
937
+ "pagination": "page",
938
+ "plainArray": false,
939
+ "perPageMax": 100,
940
+ "query": [
941
+ "perPage",
942
+ "orderBy",
943
+ "orderByMethod"
944
+ ],
945
+ "pending": null
946
+ },
947
+ "conversationsList": {
948
+ "id": "conversationsList",
949
+ "method": "GET",
950
+ "path": "/api/v1/conversations",
951
+ "summary": "Listar conversaciones",
952
+ "domain": "conversations",
953
+ "permission": "conversations:read",
954
+ "pagination": "cursor",
955
+ "plainArray": false,
956
+ "perPageMax": 100,
957
+ "query": [
958
+ "perPage",
959
+ "orderBy",
960
+ "deviceId",
961
+ "contactId",
962
+ "updated_since"
963
+ ],
964
+ "pending": null
965
+ },
966
+ "contactMessages": {
967
+ "id": "contactMessages",
968
+ "method": "GET",
969
+ "path": "/api/v1/contacts/{contactId}/messages",
970
+ "summary": "Listar los mensajes de un contacto",
971
+ "domain": "conversations",
972
+ "permission": "conversations:read",
973
+ "pagination": "cursor",
974
+ "plainArray": false,
975
+ "perPageMax": 100,
976
+ "query": [
977
+ "perPage",
978
+ "updated_since"
979
+ ],
980
+ "pending": null
981
+ },
982
+ "templatesList": {
983
+ "id": "templatesList",
984
+ "method": "GET",
985
+ "path": "/api/v1/templates",
986
+ "summary": "Listar plantillas",
987
+ "domain": "conversations",
988
+ "permission": "conversations:read",
989
+ "pagination": "page",
990
+ "plainArray": false,
991
+ "perPageMax": 100,
992
+ "query": [
993
+ "perPage",
994
+ "deviceId",
995
+ "status"
996
+ ],
997
+ "pending": null
998
+ },
999
+ "macStatus": {
1000
+ "id": "macStatus",
1001
+ "method": "GET",
1002
+ "path": "/api/v1/mac/status",
1003
+ "summary": "Ver el consumo de MACs",
1004
+ "domain": "conversations",
1005
+ "permission": "conversations:read",
1006
+ "pagination": "none",
1007
+ "plainArray": false,
1008
+ "perPageMax": null,
1009
+ "query": [],
1010
+ "pending": null
1011
+ }
1012
+ };
1013
+
1014
+ // src/commands/account.ts
1015
+ var ACCOUNT_FIELDS = ["id", "name", "email", "role", "timezone", "locale", "effective_business_numbers_limit", "effective_agents_limit"];
1016
+ function accountSummary(me) {
1017
+ const record = isRecord(me) && isRecord(me.data) && !("id" in me) ? me.data : me;
1018
+ const out = {};
1019
+ if (!isRecord(record)) return out;
1020
+ for (const field of ACCOUNT_FIELDS) if (field in record) out[field] = record[field];
1021
+ return out;
1022
+ }
1023
+ async function login(ctx, flags) {
1024
+ const { config } = ctx.config();
1025
+ const profile = activeProfileName(ctx.globals, ctx.io.env, config);
1026
+ const baseUrl = resolveBaseUrl(ctx.globals, ctx.io.env, config.profiles[profile]?.base_url);
1027
+ const raw = ctx.io.stdin.isTTY && ctx.io.stdin.setRawMode ? await promptHidden(ctx.io.stdin, ctx.io.stderr, "Paste your Funnelchat API key (it is not shown) and press Enter: ") : await readAll(ctx.io.stdin);
1028
+ const key = checkKeyShape(raw);
1029
+ ctx.redactor.add(key);
1030
+ let account = null;
1031
+ let verified = false;
1032
+ if (flags.verify !== false) {
1033
+ try {
1034
+ account = accountSummary(await ctx.client({ apiKey: key, baseUrl }).get(routes.me));
1035
+ verified = true;
1036
+ } catch (error) {
1037
+ if (error instanceof ApiError && error.status === 403) {
1038
+ ctx.out.warn("the key is valid, but it lacks the accounts:read permission: `funnelchat whoami` and `funnelchat numbers` will not work with it.");
1039
+ verified = true;
1040
+ } else {
1041
+ throw error;
1042
+ }
1043
+ }
1044
+ }
1045
+ config.profiles[profile] = { api_key: key, base_url: baseUrl, saved_at: new Date(ctx.io.clock()).toISOString() };
1046
+ config.current_profile = profile;
1047
+ const path = saveConfig(ctx.io.env, config);
1048
+ if (ctx.io.env.FUNNELCHAT_API_KEY) ctx.out.warn("FUNNELCHAT_API_KEY is set: while it is, it wins over the saved profile (except with --profile).");
1049
+ ctx.out.result({ profile, base_url: baseUrl, config_file: path, verified, account }, () => [
1050
+ `Key saved in the "${profile}" profile (${path}, 0600 permissions).`,
1051
+ ...account ? [`Account: ${String(account.name ?? "-")} (${String(account.email ?? "-")})`] : [],
1052
+ ...baseUrl !== PRODUCTION_URL ? [`Base URL: ${baseUrl}`] : [],
1053
+ ...!verified ? ["The key was not checked against the API (--no-verify)."] : []
1054
+ ]);
1055
+ }
1056
+ function logout(ctx) {
1057
+ const { config } = ctx.config();
1058
+ const profile = activeProfileName(ctx.globals, ctx.io.env, config);
1059
+ const existed = profile in config.profiles;
1060
+ delete config.profiles[profile];
1061
+ if (config.current_profile === profile) delete config.current_profile;
1062
+ const path = existed ? saveConfig(ctx.io.env, config) : ctx.config().path;
1063
+ ctx.out.result(
1064
+ { profile, removed: existed, config_file: path },
1065
+ () => existed ? `Deleted the key of the "${profile}" profile from ${path}.` : `The "${profile}" profile had no saved key.`
1066
+ );
1067
+ if (ctx.io.env.FUNNELCHAT_API_KEY) ctx.out.warn("FUNNELCHAT_API_KEY is still set in your environment.");
1068
+ }
1069
+ async function whoami(ctx) {
1070
+ const credentials = ctx.credentials();
1071
+ const account = accountSummary(await ctx.client().get(routes.me));
1072
+ const keySource = credentials.keySource === "env" ? "from the FUNNELCHAT_API_KEY variable" : `from the "${credentials.profile}" profile`;
1073
+ ctx.out.result({ account, key_source: credentials.keySource, profile: credentials.keySource === "profile" ? credentials.profile : null, base_url: credentials.baseUrl }, () => [
1074
+ `Account: ${String(account.name ?? "-")} (${String(account.email ?? "-")})`,
1075
+ ` id ${String(account.id ?? "-")} \xB7 role ${String(account.role ?? "-")} \xB7 time zone ${String(account.timezone ?? "-")} \xB7 language ${String(account.locale ?? "-")}`,
1076
+ `Key: ${keySource} (not shown)`,
1077
+ `Base URL: ${credentials.baseUrl}`
1078
+ ]);
1079
+ }
1080
+ function profilesList(ctx) {
1081
+ const { config, path } = ctx.config();
1082
+ const active = activeProfileName(ctx.globals, ctx.io.env, config);
1083
+ const rows = Object.entries(config.profiles).map(([name, profile]) => ({
1084
+ name,
1085
+ active: name === active,
1086
+ base_url: profile.base_url ?? PRODUCTION_URL,
1087
+ has_key: Boolean(profile.api_key),
1088
+ saved_at: profile.saved_at ?? null
1089
+ }));
1090
+ ctx.out.result(
1091
+ { config_file: path, active_profile: active, env_key: Boolean(ctx.io.env.FUNNELCHAT_API_KEY), profiles: rows },
1092
+ () => rows.length === 0 ? `No saved profiles (${path}). Save one with \`funnelchat login\`.` : [
1093
+ ...table(
1094
+ ["", "profile", "base url", "key", "saved"],
1095
+ rows.map((r) => [r.active ? "*" : "", r.name, r.base_url, r.has_key ? "yes" : "no", r.saved_at])
1096
+ ),
1097
+ ...ctx.io.env.FUNNELCHAT_API_KEY ? ["", "FUNNELCHAT_API_KEY is set: it wins over the active profile (except with --profile)."] : []
1098
+ ]
1099
+ );
1100
+ }
1101
+ function profilesUse(ctx, name) {
1102
+ const { config } = ctx.config();
1103
+ if (!config.profiles[name]) throw usageError(`There is no "${name}" profile.`, `Create it with \`funnelchat login --profile ${name}\`.`);
1104
+ config.current_profile = name;
1105
+ const path = saveConfig(ctx.io.env, config);
1106
+ ctx.out.result({ active_profile: name, config_file: path }, () => `Active profile: "${name}".`);
1107
+ }
1108
+ function promptHidden(stdin, stderr, question) {
1109
+ stderr.write(question);
1110
+ return new Promise((resolve2, reject) => {
1111
+ let value = "";
1112
+ stdin.setEncoding?.("utf8");
1113
+ stdin.setRawMode?.(true);
1114
+ stdin.resume();
1115
+ const finish = (error) => {
1116
+ stdin.setRawMode?.(false);
1117
+ stdin.pause();
1118
+ stdin.removeAllListeners("data");
1119
+ stderr.write("\n");
1120
+ if (error) reject(error);
1121
+ else resolve2(value.replace(/\[20[01]~/g, ""));
1122
+ };
1123
+ stdin.on("data", (chunk) => {
1124
+ for (const ch of String(chunk)) {
1125
+ if (ch === "\r" || ch === "\n" || ch === "") return finish();
1126
+ if (ch === "") return finish(new InterruptedError());
1127
+ if (ch === "\x7F" || ch === "\b") value = value.slice(0, -1);
1128
+ else if (ch >= " ") value += ch;
1129
+ }
1130
+ });
1131
+ });
1132
+ }
1133
+ function readAll(stdin) {
1134
+ return new Promise((resolve2, reject) => {
1135
+ let value = "";
1136
+ stdin.setEncoding?.("utf8");
1137
+ stdin.on("data", (chunk) => {
1138
+ value += String(chunk);
1139
+ });
1140
+ stdin.on("end", () => resolve2(value.trim()));
1141
+ stdin.on("error", reject);
1142
+ stdin.resume();
1143
+ });
1144
+ }
1145
+
1146
+ // src/commands/common.ts
1147
+ function positiveInt(value, flag) {
1148
+ if (!/^\d+$/.test(value) || Number(value) < 1 || !Number.isSafeInteger(Number(value))) {
1149
+ throw usageError(`${flag} must be a positive integer: ${value}`);
1150
+ }
1151
+ return Number(value);
1152
+ }
1153
+ function idList(values, flag) {
1154
+ const ids = [];
1155
+ for (const value of values ?? []) {
1156
+ for (const piece of value.split(",")) {
1157
+ const trimmed = piece.trim();
1158
+ if (trimmed === "") continue;
1159
+ const id = positiveInt(trimmed, flag);
1160
+ if (!ids.includes(id)) ids.push(id);
1161
+ }
1162
+ }
1163
+ return ids;
1164
+ }
1165
+ function collect(value, previous) {
1166
+ return [...previous ?? [], value];
1167
+ }
1168
+ function explicitOptions(command, names) {
1169
+ return new Set(names.filter((name) => command.getOptionValueSource(name) === "cli"));
1170
+ }
1171
+
1172
+ // src/dates.ts
1173
+ var DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
1174
+ var DATE_TIME = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})$/i;
1175
+ function isValidDay(value) {
1176
+ const match = DAY.exec(value);
1177
+ if (!match) return false;
1178
+ const date = /* @__PURE__ */ new Date(`${value}T00:00:00Z`);
1179
+ return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
1180
+ }
1181
+ function toApiDateTime(date) {
1182
+ return `${date.toISOString().slice(0, 19)}+00:00`;
1183
+ }
1184
+ function parseSince(value, flag = "--since") {
1185
+ if (isValidDay(value)) return `${value}T00:00:00+00:00`;
1186
+ if (DATE_TIME.test(value)) {
1187
+ const date = new Date(value.replace(" ", "T"));
1188
+ if (!Number.isNaN(date.getTime())) return toApiDateTime(date);
1189
+ }
1190
+ throw usageError(
1191
+ `${flag} must be a date (YYYY-MM-DD, from 00:00 UTC) or a date and time with a zone (2026-10-01T09:00:00-03:00): ${value}`
1192
+ );
1193
+ }
1194
+ function parseContactBound(value, flag) {
1195
+ if (isValidDay(value)) return value;
1196
+ if (DATE_TIME.test(value)) {
1197
+ const date = new Date(value.replace(" ", "T"));
1198
+ if (!Number.isNaN(date.getTime())) return toApiDateTime(date);
1199
+ }
1200
+ throw usageError(`${flag} must be a YYYY-MM-DD date or a date and time with a zone: ${value}`);
1201
+ }
1202
+ function timestampForName(now) {
1203
+ return new Date(now).toISOString().slice(0, 19).replace(/[-:]/g, "").replace("T", "-");
1204
+ }
1205
+
1206
+ // src/export/csv.ts
1207
+ var CSV_BOM = "\uFEFF";
1208
+ var FORMULA_START = /^[=+\-@\t\r]/;
1209
+ var PLAIN_NUMBER = /^[+-]?\d+(\.\d+)?$/;
1210
+ function csvCell(value) {
1211
+ if (value === null || value === void 0) return "";
1212
+ let text = typeof value === "string" ? value : typeof value === "object" ? JSON.stringify(value) : String(value);
1213
+ if (FORMULA_START.test(text) && !PLAIN_NUMBER.test(text)) text = `'${text}`;
1214
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
1215
+ }
1216
+ function csvRow(values) {
1217
+ return `${values.map(csvCell).join(",")}\r
1218
+ `;
1219
+ }
1220
+
1221
+ // src/export/files.ts
1222
+ import { createHash } from "crypto";
1223
+ import {
1224
+ closeSync as closeSync2,
1225
+ existsSync as existsSync2,
1226
+ fstatSync,
1227
+ fsyncSync as fsyncSync2,
1228
+ ftruncateSync,
1229
+ linkSync,
1230
+ openSync as openSync2,
1231
+ readSync,
1232
+ renameSync as renameSync2,
1233
+ rmSync as rmSync2,
1234
+ statSync as statSync2,
1235
+ unlinkSync,
1236
+ writeSync as writeSync2
1237
+ } from "fs";
1238
+ import { basename, dirname, join as join2 } from "path";
1239
+ function outputExists(path) {
1240
+ return new CliError(`${path} already exists: the CLI never overwrites a file.`, {
1241
+ exitCode: ExitCode.EXISTS,
1242
+ code: "output_exists",
1243
+ hint: "Choose another folder with --output-dir, or move that file.",
1244
+ details: { path }
1245
+ });
1246
+ }
1247
+ function sha256File(path) {
1248
+ const hash = createHash("sha256");
1249
+ const fd = openSync2(path, "r");
1250
+ try {
1251
+ const buffer = Buffer.allocUnsafe(1 << 20);
1252
+ for (; ; ) {
1253
+ const read = readSync(fd, buffer, 0, buffer.length, null);
1254
+ if (read === 0) break;
1255
+ hash.update(buffer.subarray(0, read));
1256
+ }
1257
+ } finally {
1258
+ closeSync2(fd);
1259
+ }
1260
+ return hash.digest("hex");
1261
+ }
1262
+ var LINK_UNSUPPORTED = /* @__PURE__ */ new Set(["EPERM", "ENOTSUP", "EOPNOTSUPP", "EXDEV", "ENOSYS", "EINVAL"]);
1263
+ function publish(partPath, finalPath) {
1264
+ try {
1265
+ linkSync(partPath, finalPath);
1266
+ } catch (error) {
1267
+ const code2 = error.code ?? "";
1268
+ if (code2 === "EEXIST") {
1269
+ if (sameFile(partPath, finalPath)) {
1270
+ unlinkSync(partPath);
1271
+ return;
1272
+ }
1273
+ throw outputExists(finalPath);
1274
+ }
1275
+ if (!LINK_UNSUPPORTED.has(code2)) throw error;
1276
+ if (existsSync2(finalPath)) throw outputExists(finalPath);
1277
+ renameSync2(partPath, finalPath);
1278
+ return;
1279
+ }
1280
+ unlinkSync(partPath);
1281
+ }
1282
+ function sameFile(a, b) {
1283
+ try {
1284
+ const sa = statSync2(a);
1285
+ const sb = statSync2(b);
1286
+ return sa.ino === sb.ino && sa.dev === sb.dev;
1287
+ } catch {
1288
+ return false;
1289
+ }
1290
+ }
1291
+ function writeNewFile(finalPath, content) {
1292
+ if (existsSync2(finalPath)) throw outputExists(finalPath);
1293
+ const partPath = `${finalPath}.part`;
1294
+ const fd = openSync2(partPath, "w", 420);
1295
+ try {
1296
+ writeSync2(fd, content);
1297
+ fsyncSync2(fd);
1298
+ } finally {
1299
+ closeSync2(fd);
1300
+ }
1301
+ const entry = { path: basename(finalPath), rows: 0, bytes: Buffer.byteLength(content), sha256: sha256File(partPath) };
1302
+ publish(partPath, finalPath);
1303
+ return entry;
1304
+ }
1305
+ function writeAtomic(path, content) {
1306
+ const tmp = join2(dirname(path), `.${basename(path)}.${process.pid}.tmp`);
1307
+ const fd = openSync2(tmp, "w", 384);
1308
+ try {
1309
+ writeSync2(fd, content);
1310
+ fsyncSync2(fd);
1311
+ } finally {
1312
+ closeSync2(fd);
1313
+ }
1314
+ try {
1315
+ renameSync2(tmp, path);
1316
+ } catch (error) {
1317
+ rmSync2(tmp, { force: true });
1318
+ throw error;
1319
+ }
1320
+ }
1321
+ var PartFile = class _PartFile {
1322
+ finalPath;
1323
+ partPath;
1324
+ #fd;
1325
+ #offset;
1326
+ #rows;
1327
+ constructor(finalPath, fd, position) {
1328
+ this.finalPath = finalPath;
1329
+ this.partPath = `${finalPath}.part`;
1330
+ this.#fd = fd;
1331
+ this.#offset = position.offset;
1332
+ this.#rows = position.rows;
1333
+ }
1334
+ /** Starts a new file. Fails if the final name already exists. */
1335
+ static create(finalPath) {
1336
+ if (existsSync2(finalPath)) throw outputExists(finalPath);
1337
+ return new _PartFile(finalPath, openSync2(`${finalPath}.part`, "w", 420), { offset: 0, rows: 0 });
1338
+ }
1339
+ /**
1340
+ * Reopens a `.part` at the position of the last checkpoint and drops whatever was written after it.
1341
+ * Returns a {@link FileEntry} instead when the file was already published (the run stopped right
1342
+ * after publishing it, before saving the state).
1343
+ */
1344
+ static resume(finalPath, position) {
1345
+ const partPath = `${finalPath}.part`;
1346
+ if (existsSync2(finalPath)) {
1347
+ if (existsSync2(partPath) && sameFile(partPath, finalPath)) unlinkSync(partPath);
1348
+ if (existsSync2(partPath)) throw outputExists(finalPath);
1349
+ const size2 = statSync2(finalPath).size;
1350
+ if (size2 !== position.offset) {
1351
+ throw inconsistent(`${basename(finalPath)} is ${size2} bytes and the saved state says ${position.offset}`);
1352
+ }
1353
+ return { path: basename(finalPath), rows: position.rows, bytes: size2, sha256: sha256File(finalPath) };
1354
+ }
1355
+ if (!existsSync2(partPath)) {
1356
+ if (position.offset === 0) return _PartFile.create(finalPath);
1357
+ throw inconsistent(`falta ${basename(partPath)}`);
1358
+ }
1359
+ const fd = openSync2(partPath, "r+");
1360
+ const size = fstatSync(fd).size;
1361
+ if (size < position.offset) {
1362
+ closeSync2(fd);
1363
+ throw inconsistent(`${basename(partPath)} is ${size} bytes and the saved state says ${position.offset}`);
1364
+ }
1365
+ if (size > position.offset) ftruncateSync(fd, position.offset);
1366
+ return new _PartFile(finalPath, fd, position);
1367
+ }
1368
+ get position() {
1369
+ return { offset: this.#offset, rows: this.#rows };
1370
+ }
1371
+ get rows() {
1372
+ return this.#rows;
1373
+ }
1374
+ /** Appends whole rows (each already ending in a newline). */
1375
+ append(text, rows) {
1376
+ const buffer = Buffer.from(text, "utf8");
1377
+ let written = 0;
1378
+ while (written < buffer.length) {
1379
+ written += writeSync2(this.fd, buffer, written, buffer.length - written, this.#offset + written);
1380
+ }
1381
+ this.#offset += buffer.length;
1382
+ this.#rows += rows;
1383
+ }
1384
+ /** Flushes to disk: call it before saving the position in the export state. */
1385
+ sync() {
1386
+ fsyncSync2(this.fd);
1387
+ }
1388
+ /** Syncs, closes, hashes and publishes the file under its final name. */
1389
+ finalize() {
1390
+ this.sync();
1391
+ this.close();
1392
+ const entry = { path: basename(this.finalPath), rows: this.#rows, bytes: this.#offset, sha256: sha256File(this.partPath) };
1393
+ publish(this.partPath, this.finalPath);
1394
+ return entry;
1395
+ }
1396
+ close() {
1397
+ if (this.#fd !== null) {
1398
+ closeSync2(this.#fd);
1399
+ this.#fd = null;
1400
+ }
1401
+ }
1402
+ get fd() {
1403
+ if (this.#fd === null) throw new Error(`${this.partPath} is already closed.`);
1404
+ return this.#fd;
1405
+ }
1406
+ };
1407
+ function inconsistent(detail) {
1408
+ return new CliError(`Cannot resume: the state of the export does not match the files (${detail}).`, {
1409
+ exitCode: ExitCode.USAGE,
1410
+ code: "resume_inconsistent",
1411
+ hint: "Start the export again in another folder."
1412
+ });
1413
+ }
1414
+
1415
+ // src/export/session.ts
1416
+ import { closeSync as closeSync3, existsSync as existsSync3, mkdirSync as mkdirSync2, openSync as openSync3, readdirSync, readFileSync as readFileSync2, rmSync as rmSync3, statSync as statSync3, unlinkSync as unlinkSync2, writeSync as writeSync3 } from "fs";
1417
+ import { join as join3, resolve } from "path";
1418
+ var STATE_FILE = ".funnelchat-export.json";
1419
+ var LOCK_FILE = ".funnelchat-export.lock";
1420
+ var MANIFEST_FILE = "manifest.json";
1421
+ var ExportSession = class _ExportSession {
1422
+ dir;
1423
+ state;
1424
+ #locked = true;
1425
+ constructor(dir, state) {
1426
+ this.dir = dir;
1427
+ this.state = state;
1428
+ }
1429
+ static open(options) {
1430
+ const dir = resolve(options.cwd, options.dir);
1431
+ const statePath = join3(dir, STATE_FILE);
1432
+ if (!options.resume) {
1433
+ if (existsSync3(dir)) {
1434
+ if (!statSync3(dir).isDirectory()) throw exists(dir, "Choose a folder with --output-dir.");
1435
+ if (readdirSync(dir).length > 0) {
1436
+ throw exists(
1437
+ dir,
1438
+ existsSync3(statePath) ? `There is an unfinished export in that folder: resume it with
1439
+ funnelchat ${options.kind} export --output-dir ${quote(options.dir)} --resume` : "Choose a new or empty folder with --output-dir: the CLI never overwrites files."
1440
+ );
1441
+ }
1442
+ }
1443
+ mkdirSync2(dir, { recursive: true });
1444
+ lock(dir);
1445
+ const session2 = new _ExportSession(dir, options.initial());
1446
+ session2.save();
1447
+ return { session: session2 };
1448
+ }
1449
+ if (!existsSync3(statePath)) {
1450
+ if (existsSync3(join3(dir, MANIFEST_FILE))) return { alreadyComplete: join3(dir, MANIFEST_FILE) };
1451
+ throw usageError(`There is no export to resume in ${dir}.`, "Pass the same --output-dir as the export that was interrupted.");
1452
+ }
1453
+ const state = readState(statePath);
1454
+ if (state.kind !== options.kind) {
1455
+ throw usageError(`That folder holds a ${state.kind} export, not a ${options.kind} one.`, `Resume it with \`funnelchat ${state.kind} export --output-dir ${quote(options.dir)} --resume\`.`);
1456
+ }
1457
+ if (state.base_url !== options.baseUrl) {
1458
+ throw usageError(`That export was made against ${state.base_url} and the base URL is now ${options.baseUrl}.`, "Resume it with the same base URL (and the same key) as the original.");
1459
+ }
1460
+ const differences = [...options.explicit].filter((key) => JSON.stringify(options.options[key] ?? null) !== JSON.stringify(state.options[key] ?? null)).map((key) => `${key}: original ${JSON.stringify(state.options[key] ?? null)}, now ${JSON.stringify(options.options[key] ?? null)}`);
1461
+ if (differences.length) {
1462
+ throw usageError(
1463
+ `The options do not match those of the original export (${differences.join("; ")}).`,
1464
+ "With --resume there is no need to repeat the options: the original ones are used."
1465
+ );
1466
+ }
1467
+ lock(dir);
1468
+ const session = new _ExportSession(dir, state);
1469
+ session.state.runs += 1;
1470
+ session.save();
1471
+ return { session };
1472
+ }
1473
+ path(name) {
1474
+ return join3(this.dir, name);
1475
+ }
1476
+ save() {
1477
+ writeAtomic(join3(this.dir, STATE_FILE), `${JSON.stringify(this.state, null, 2)}
1478
+ `);
1479
+ }
1480
+ /** Writes the manifest (never over an existing one), deletes the state and releases the lock. */
1481
+ complete(manifest) {
1482
+ const entry = writeNewFile(join3(this.dir, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}
1483
+ `);
1484
+ rmSync3(join3(this.dir, STATE_FILE), { force: true });
1485
+ this.release();
1486
+ return entry;
1487
+ }
1488
+ release() {
1489
+ if (!this.#locked) return;
1490
+ this.#locked = false;
1491
+ rmSync3(join3(this.dir, LOCK_FILE), { force: true });
1492
+ }
1493
+ };
1494
+ function readState(path) {
1495
+ let parsed;
1496
+ try {
1497
+ parsed = JSON.parse(readFileSync2(path, "utf8"));
1498
+ } catch {
1499
+ throw usageError(`The state of the export (${path}) cannot be read.`, "Start the export again in another folder.");
1500
+ }
1501
+ if (!isRecord(parsed) || parsed.state_version !== 1 || typeof parsed.kind !== "string") {
1502
+ throw usageError(`The state of the export (${path}) is not from this version of the CLI.`, "Start the export again in another folder.");
1503
+ }
1504
+ return parsed;
1505
+ }
1506
+ function lock(dir) {
1507
+ const path = join3(dir, LOCK_FILE);
1508
+ for (let attempt = 0; attempt < 2; attempt++) {
1509
+ try {
1510
+ const fd = openSync3(path, "wx", 384);
1511
+ writeSync3(fd, String(process.pid));
1512
+ closeSync3(fd);
1513
+ return;
1514
+ } catch (error) {
1515
+ if (error.code !== "EEXIST") throw error;
1516
+ const pid = Number(readFileSync2(path, "utf8").trim());
1517
+ if (Number.isInteger(pid) && pid > 0 && pid !== process.pid && isAlive(pid)) {
1518
+ throw new CliError(`Another run of the CLI (process ${pid}) is using ${dir}.`, {
1519
+ exitCode: ExitCode.EXISTS,
1520
+ code: "export_locked",
1521
+ hint: "Wait for it to finish, or stop it, and try again."
1522
+ });
1523
+ }
1524
+ unlinkSync2(path);
1525
+ }
1526
+ }
1527
+ }
1528
+ function isAlive(pid) {
1529
+ try {
1530
+ process.kill(pid, 0);
1531
+ return true;
1532
+ } catch (error) {
1533
+ return error.code === "EPERM";
1534
+ }
1535
+ }
1536
+ function exists(dir, hint) {
1537
+ return new CliError(`The folder ${dir} already exists and is not empty.`, { exitCode: ExitCode.EXISTS, code: "output_exists", hint, details: { path: dir } });
1538
+ }
1539
+ function quote(value) {
1540
+ return /^[\w./:@=-]+$/.test(value) ? value : `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
1541
+ }
1542
+ function baseManifest(state, used, finishedAt) {
1543
+ return {
1544
+ manifest_version: 1,
1545
+ kind: state.kind,
1546
+ generator: { name: PACKAGE_NAME, version: VERSION },
1547
+ api: {
1548
+ base_url: state.base_url,
1549
+ spec: { source: SPEC_SOURCE.branch ? `developers-portal ${SPEC_SOURCE.branch}` : null, commit: SPEC_SOURCE.commit, sha256: SPEC_SOURCE.sha256 },
1550
+ routes: used.map((route) => ({ route: `${route.method} ${route.path}`, pending: route.pending?.source ?? null }))
1551
+ },
1552
+ started_at: state.started_at,
1553
+ finished_at: finishedAt,
1554
+ runs: state.runs,
1555
+ api_calls: state.api_calls,
1556
+ options: state.options
1557
+ };
1558
+ }
1559
+
1560
+ // src/commands/export-common.ts
1561
+ function formatCount(n) {
1562
+ return n.toLocaleString("en-US");
1563
+ }
1564
+ function countOf(n, singular, plural = `${singular}s`) {
1565
+ return `${formatCount(n)} ${n === 1 ? singular : plural}`;
1566
+ }
1567
+ function finishExport(ctx, session, manifest, summary) {
1568
+ const entry = session.complete(manifest);
1569
+ ctx.out.result({ status: "complete", output_dir: session.dir, manifest_file: entry, manifest }, () => [
1570
+ `Export complete: ${summary}.`,
1571
+ `Manifest: ${session.path(entry.path)} (sha256 ${entry.sha256})`,
1572
+ `Files: ${manifest.files.length} \xB7 API calls: ${manifest.api_calls.toLocaleString("en-US")}`
1573
+ ]);
1574
+ }
1575
+ function withResumeHint(error, resumeCommand) {
1576
+ const cliError = toCliError(error);
1577
+ if (cliError.exitCode === ExitCode.USAGE || cliError.exitCode === ExitCode.EXISTS) return cliError;
1578
+ const resume = `What was exported up to the last complete page is saved. To continue from there:
1579
+ ${resumeCommand}`;
1580
+ cliError.hint = cliError.hint ? `${cliError.hint}
1581
+ ${resume}` : resume;
1582
+ cliError.details.resume_command = resumeCommand;
1583
+ return cliError;
1584
+ }
1585
+
1586
+ // src/commands/contacts.ts
1587
+ var OPTION_NAMES = {
1588
+ format: "format",
1589
+ tag: "tag_ids",
1590
+ tagMode: "tag_mode",
1591
+ createdFrom: "created_from",
1592
+ createdTo: "created_to",
1593
+ onlyWithPhone: "only_with_phone",
1594
+ tags: "with_tags",
1595
+ customFields: "with_custom_fields"
1596
+ };
1597
+ var CONTACTS_FLAG_NAMES = Object.keys(OPTION_NAMES);
1598
+ var BASE_COLUMNS = [
1599
+ "id",
1600
+ "name",
1601
+ "phone",
1602
+ "email",
1603
+ "whatsapp_id",
1604
+ "whatsapp_username",
1605
+ "country_code",
1606
+ "status",
1607
+ "created_at",
1608
+ "device_id",
1609
+ "adviser_id",
1610
+ "active_conversation",
1611
+ "unread_messages",
1612
+ "favorite",
1613
+ "lead_score",
1614
+ "lead_score_category"
1615
+ ];
1616
+ var PER_PAGE = 100;
1617
+ function normalize(flags) {
1618
+ const format = (flags.format ?? "csv").toLowerCase();
1619
+ if (format !== "csv" && format !== "jsonl") throw usageError(`--format must be csv or jsonl: ${flags.format}`);
1620
+ const tagMode = flags.tagMode?.toLowerCase() ?? null;
1621
+ if (tagMode !== null && !["any", "all", "exclude"].includes(tagMode)) throw usageError(`--tag-mode must be any, all or exclude: ${flags.tagMode}`);
1622
+ const tagIds = idList(flags.tag, "--tag");
1623
+ if (tagMode !== null && tagIds.length === 0) throw usageError("--tag-mode needs at least one --tag.");
1624
+ if (Boolean(flags.createdFrom) !== Boolean(flags.createdTo)) {
1625
+ throw usageError("--created-from and --created-to go together: the API needs both.", 'For "from a date until today", put today in --created-to.');
1626
+ }
1627
+ return {
1628
+ format,
1629
+ tag_ids: tagIds,
1630
+ tag_mode: tagMode,
1631
+ created_from: flags.createdFrom ? parseContactBound(flags.createdFrom, "--created-from") : null,
1632
+ created_to: flags.createdTo ? parseContactBound(flags.createdTo, "--created-to") : null,
1633
+ only_with_phone: flags.onlyWithPhone === true,
1634
+ with_tags: flags.tags !== false,
1635
+ with_custom_fields: flags.customFields !== false
1636
+ };
1637
+ }
1638
+ function listQuery(options) {
1639
+ return {
1640
+ perPage: PER_PAGE,
1641
+ orderBy: "id",
1642
+ orderByMethod: "asc",
1643
+ ...options.tag_ids.length ? { "tag_ids[]": options.tag_ids } : {},
1644
+ ...options.tag_mode ? { tag_mode: options.tag_mode } : {},
1645
+ ...options.created_from ? { created_from: options.created_from, created_to: options.created_to } : {},
1646
+ ...options.only_with_phone ? { only_with_phone: true } : {}
1647
+ };
1648
+ }
1649
+ async function contactsExport(ctx, flags, explicitFlags) {
1650
+ const resume = flags.resume === true;
1651
+ if (resume && !flags.outputDir) throw usageError("--resume needs the --output-dir of the export that was interrupted.");
1652
+ const options = normalize(flags);
1653
+ const { baseUrl } = ctx.credentials();
1654
+ const client = ctx.client();
1655
+ const dir = flags.outputDir ?? `funnelchat-contacts-export-${timestampForName(ctx.io.clock())}`;
1656
+ const opened = ExportSession.open({
1657
+ dir,
1658
+ cwd: ctx.io.cwd,
1659
+ kind: "contacts",
1660
+ resume,
1661
+ baseUrl,
1662
+ options,
1663
+ explicit: new Set([...explicitFlags].map((flag) => OPTION_NAMES[flag]).filter((name) => Boolean(name))),
1664
+ initial: () => ({
1665
+ state_version: 1,
1666
+ kind: "contacts",
1667
+ cli_version: VERSION,
1668
+ base_url: baseUrl,
1669
+ options,
1670
+ started_at: new Date(ctx.io.clock()).toISOString(),
1671
+ runs: 1,
1672
+ api_calls: 0,
1673
+ columns: null,
1674
+ next: null,
1675
+ done: false,
1676
+ last_id: null,
1677
+ file: { offset: 0, rows: 0 },
1678
+ header_written: false,
1679
+ api_total_at_start: null,
1680
+ vanished: 0,
1681
+ observed: { first_created_at: null, last_created_at: null }
1682
+ })
1683
+ });
1684
+ if ("alreadyComplete" in opened) {
1685
+ ctx.out.result({ status: "already_complete", manifest: opened.alreadyComplete }, () => `The export was already complete: ${opened.alreadyComplete}`);
1686
+ return;
1687
+ }
1688
+ const session = opened.session;
1689
+ const state = session.state;
1690
+ const o = state.options;
1691
+ const callsBefore = state.api_calls;
1692
+ const countCalls = () => {
1693
+ state.api_calls = callsBefore + client.requests;
1694
+ };
1695
+ const resumeCommand = `funnelchat contacts export --output-dir ${quote(dir)} --resume`;
1696
+ const fileName = o.format === "csv" ? "contacts.csv" : "contacts.jsonl";
1697
+ let file = null;
1698
+ try {
1699
+ ctx.out.progress(resume ? `Resuming the contacts export in ${session.dir}` : `Exporting contacts to ${session.dir}`);
1700
+ if (o.format === "csv" && o.with_custom_fields && state.columns === null) {
1701
+ const columns = [];
1702
+ for await (const field of client.all(routes.customFieldsList, { query: { perPage: PER_PAGE, orderBy: "created_at", orderByMethod: "asc" } })) {
1703
+ if (typeof field.id === "number") columns.push({ id: field.id, name: typeof field.name === "string" ? field.name : String(field.id) });
1704
+ }
1705
+ state.columns = columns;
1706
+ countCalls();
1707
+ session.save();
1708
+ }
1709
+ const opened2 = state.file.offset === 0 && !state.header_written ? PartFile.create(session.path(fileName)) : PartFile.resume(session.path(fileName), state.file);
1710
+ let entry;
1711
+ if (opened2 instanceof PartFile) {
1712
+ file = opened2;
1713
+ if (o.format === "csv" && !state.header_written) {
1714
+ file.append(CSV_BOM + csvRow(csvHeader(o, state.columns ?? [])), 0);
1715
+ file.sync();
1716
+ state.header_written = true;
1717
+ state.file = file.position;
1718
+ session.save();
1719
+ }
1720
+ const baseQuery = listQuery(o);
1721
+ while (!state.done) {
1722
+ if (ctx.io.signal?.aborted) throw new InterruptedError();
1723
+ const query = mergeNext(baseQuery, state.next ?? { page: 1 });
1724
+ const page = await client.page(routes.contactsList, { query });
1725
+ if (state.api_total_at_start === null) state.api_total_at_start = totalOf(page.body);
1726
+ let text = "";
1727
+ let rows = 0;
1728
+ for (const contact of page.items) {
1729
+ const id = typeof contact.id === "number" ? contact.id : Number(contact.id);
1730
+ if (state.last_id !== null && id <= state.last_id) continue;
1731
+ const enriched = await enrich(client, contact, id, o);
1732
+ if (enriched === null) {
1733
+ state.vanished++;
1734
+ } else {
1735
+ text += o.format === "csv" ? csvRow(csvValues(enriched, o, state.columns ?? [])) : `${JSON.stringify(enriched)}
1736
+ `;
1737
+ rows++;
1738
+ observe(state, contact.created_at);
1739
+ }
1740
+ state.last_id = state.last_id === null ? id : Math.max(state.last_id, id);
1741
+ }
1742
+ file.append(text, rows);
1743
+ file.sync();
1744
+ state.file = file.position;
1745
+ state.next = page.next;
1746
+ state.done = page.next === null;
1747
+ countCalls();
1748
+ session.save();
1749
+ ctx.out.progress(` contacts: ${formatCount(state.file.rows)}${state.api_total_at_start !== null ? ` of ${formatCount(state.api_total_at_start)}` : ""}`);
1750
+ }
1751
+ entry = file.finalize();
1752
+ file = null;
1753
+ } else {
1754
+ entry = opened2;
1755
+ }
1756
+ const manifest = {
1757
+ ...baseManifest(state, usedRoutes(o), new Date(ctx.io.clock()).toISOString()),
1758
+ range: {
1759
+ created_from: o.created_from,
1760
+ created_to: o.created_to,
1761
+ observed_first_created_at: state.observed.first_created_at,
1762
+ observed_last_created_at: state.observed.last_created_at
1763
+ },
1764
+ counts: {
1765
+ contacts: entry.rows,
1766
+ ...state.api_total_at_start !== null ? { api_total_at_start: state.api_total_at_start } : {},
1767
+ vanished_during_export: state.vanished
1768
+ },
1769
+ files: [entry],
1770
+ notes: [
1771
+ "Contacts ordered by ascending id, as GET /api/v1/contacts pages them (by page number).",
1772
+ "If contacts are deleted during the export, paging by page number can skip some: compare counts.contacts with counts.api_total_at_start.",
1773
+ ...o.with_tags ? ["Tags: GET /api/v1/contacts/{contact}/tags, one request per contact (the listing does not include them)."] : [],
1774
+ ...o.with_custom_fields ? ["Custom fields: GET /api/v1/contacts/{contact}/custom-fields, one request per contact (the listing does not include them)."] : [],
1775
+ ...o.format === "csv" ? ["CSV with a UTF-8 BOM and CRLF line endings. A cell that starts with =, +, - or @ (and is not a number) gets a ' in front so the spreadsheet does not run it as a formula."] : []
1776
+ ]
1777
+ };
1778
+ finishExport(ctx, session, manifest, `${countOf(entry.rows, "contact")} in ${session.path(entry.path)}`);
1779
+ } catch (error) {
1780
+ throw withResumeHint(error, resumeCommand);
1781
+ } finally {
1782
+ file?.close();
1783
+ session.release();
1784
+ }
1785
+ }
1786
+ async function enrich(client, contact, id, o) {
1787
+ try {
1788
+ const tags = o.with_tags ? await all(client, routes.contactTags, id) : void 0;
1789
+ const customFields = o.with_custom_fields ? await all(client, routes.contactCustomFields, id) : void 0;
1790
+ return { ...contact, ...tags ? { tags } : {}, ...customFields ? { custom_fields: customFields } : {} };
1791
+ } catch (error) {
1792
+ if (error instanceof ApiError && error.status === 404 && error.apiCode !== "route_not_found") return null;
1793
+ throw error;
1794
+ }
1795
+ }
1796
+ async function all(client, route, contact) {
1797
+ const items = [];
1798
+ for await (const item of client.all(route, { path: { contact }, query: { perPage: PER_PAGE } })) items.push(item);
1799
+ return items;
1800
+ }
1801
+ function observe(state, createdAt) {
1802
+ if (typeof createdAt !== "string" || createdAt === "") return;
1803
+ const o = state.observed;
1804
+ if (o.first_created_at === null || createdAt < o.first_created_at) o.first_created_at = createdAt;
1805
+ if (o.last_created_at === null || createdAt > o.last_created_at) o.last_created_at = createdAt;
1806
+ }
1807
+ function csvHeader(o, columns) {
1808
+ return [...BASE_COLUMNS, ...o.with_tags ? ["tags", "tag_ids"] : [], ...columns.map((c) => `custom_field:${c.name}`)];
1809
+ }
1810
+ function csvValues(contact, o, columns) {
1811
+ const leadScore = isRecord(contact.lead_score) ? contact.lead_score : void 0;
1812
+ const base = BASE_COLUMNS.map((column) => {
1813
+ if (column === "lead_score") return leadScore?.score;
1814
+ if (column === "lead_score_category") return leadScore?.category;
1815
+ return contact[column];
1816
+ });
1817
+ const tags = Array.isArray(contact.tags) ? contact.tags : [];
1818
+ const tagCells = o.with_tags ? [tags.map((t) => t.name).join("; "), tags.map((t) => t.id).join(";")] : [];
1819
+ const fields = Array.isArray(contact.custom_fields) ? contact.custom_fields : [];
1820
+ const fieldCells = columns.map((column) => {
1821
+ const field = fields.find((f) => f.id === column.id) ?? fields.find((f) => f.name === column.name);
1822
+ return field?.value;
1823
+ });
1824
+ return [...base, ...tagCells, ...fieldCells];
1825
+ }
1826
+ function usedRoutes(o) {
1827
+ return [
1828
+ routes.contactsList,
1829
+ ...o.with_tags ? [routes.contactTags] : [],
1830
+ ...o.with_custom_fields ? [routes.contactCustomFields] : [],
1831
+ ...o.with_custom_fields && o.format === "csv" ? [routes.customFieldsList] : []
1832
+ ];
1833
+ }
1834
+
1835
+ // src/commands/conversations.ts
1836
+ import { readFileSync as readFileSync3, rmSync as rmSync4 } from "fs";
1837
+ var OPTION_NAMES2 = {
1838
+ contact: "contact_ids",
1839
+ device: "device_id",
1840
+ since: "since",
1841
+ partSize: "part_size"
1842
+ };
1843
+ var CONVERSATIONS_FLAG_NAMES = Object.keys(OPTION_NAMES2);
1844
+ var PER_PAGE2 = 100;
1845
+ var DEFAULT_PART_SIZE = 5e4;
1846
+ var CONVERSATIONS_FILE = "conversations.jsonl";
1847
+ function partName(index) {
1848
+ return `messages-${String(index).padStart(5, "0")}.jsonl`;
1849
+ }
1850
+ function normalize2(flags) {
1851
+ const contactIds = idList(flags.contact, "--contact");
1852
+ const deviceId = flags.device ? positiveInt(flags.device, "--device") : null;
1853
+ const partSize = flags.partSize ? positiveInt(flags.partSize, "--part-size") : DEFAULT_PART_SIZE;
1854
+ return { contact_ids: contactIds, device_id: deviceId, since: flags.since ? parseSince(flags.since) : null, part_size: partSize };
1855
+ }
1856
+ async function conversationsExport(ctx, flags, explicitFlags) {
1857
+ const resume = flags.resume === true;
1858
+ if (resume && !flags.outputDir) throw usageError("--resume needs the --output-dir of the export that was interrupted.");
1859
+ const options = normalize2(flags);
1860
+ const { baseUrl } = ctx.credentials();
1861
+ const client = ctx.client();
1862
+ const dir = flags.outputDir ?? `funnelchat-conversations-export-${timestampForName(ctx.io.clock())}`;
1863
+ const opened = ExportSession.open({
1864
+ dir,
1865
+ cwd: ctx.io.cwd,
1866
+ kind: "conversations",
1867
+ resume,
1868
+ baseUrl,
1869
+ options,
1870
+ explicit: new Set([...explicitFlags].map((flag) => OPTION_NAMES2[flag]).filter((name) => Boolean(name))),
1871
+ initial: () => ({
1872
+ state_version: 1,
1873
+ kind: "conversations",
1874
+ cli_version: VERSION,
1875
+ base_url: baseUrl,
1876
+ options,
1877
+ started_at: new Date(ctx.io.clock()).toISOString(),
1878
+ runs: 1,
1879
+ api_calls: 0,
1880
+ phase: "list",
1881
+ list: { next: null, done: false, contact_index: 0, file: { offset: 0, rows: 0 } },
1882
+ conversations_file: null,
1883
+ messages: { contact_index: 0, next: null, part_index: 1, part: { offset: 0, rows: 0 }, parts: [], total: 0 },
1884
+ vanished_contacts: [],
1885
+ observed: { first_message_time: null, last_message_time: null }
1886
+ })
1887
+ });
1888
+ if ("alreadyComplete" in opened) {
1889
+ ctx.out.result({ status: "already_complete", manifest: opened.alreadyComplete }, () => `The export was already complete: ${opened.alreadyComplete}`);
1890
+ return;
1891
+ }
1892
+ const session = opened.session;
1893
+ const state = session.state;
1894
+ const o = state.options;
1895
+ const callsBefore = state.api_calls;
1896
+ const save = () => {
1897
+ state.api_calls = callsBefore + client.requests;
1898
+ session.save();
1899
+ };
1900
+ const checkAbort = () => {
1901
+ if (ctx.io.signal?.aborted) throw new InterruptedError();
1902
+ };
1903
+ const resumeCommand = `funnelchat conversations export --output-dir ${quote(dir)} --resume`;
1904
+ let file = null;
1905
+ try {
1906
+ ctx.out.progress(resume ? `Resuming the conversations export in ${session.dir}` : `Exporting conversations to ${session.dir}`);
1907
+ if (state.phase === "list") {
1908
+ const resumed = state.list.file.offset === 0 ? PartFile.create(session.path(CONVERSATIONS_FILE)) : PartFile.resume(session.path(CONVERSATIONS_FILE), state.list.file);
1909
+ if (resumed instanceof PartFile) {
1910
+ file = resumed;
1911
+ const seen = new Set(readContactIds(file.partPath, state.list.file.offset));
1912
+ if (o.contact_ids.length) {
1913
+ for (let i = state.list.contact_index; i < o.contact_ids.length; i++) {
1914
+ checkAbort();
1915
+ const id = o.contact_ids[i];
1916
+ const page = await client.page(routes.conversationsList, { query: { contactId: id, perPage: 1 } });
1917
+ const conversation = page.items.find((item) => conversationContactId(item) === id);
1918
+ if (!conversation) {
1919
+ throw new CliError(`Contact ${id} does not exist in the account, or the key cannot see it.`, {
1920
+ exitCode: ExitCode.NOT_FOUND,
1921
+ code: "contact_not_found",
1922
+ hint: "Check the id: it is the `id` of GET /api/v1/contacts."
1923
+ });
1924
+ }
1925
+ if (!seen.has(id)) {
1926
+ file.append(`${JSON.stringify(conversation)}
1927
+ `, 1);
1928
+ seen.add(id);
1929
+ }
1930
+ file.sync();
1931
+ state.list.file = file.position;
1932
+ state.list.contact_index = i + 1;
1933
+ save();
1934
+ }
1935
+ state.list.done = true;
1936
+ save();
1937
+ }
1938
+ const baseQuery = {
1939
+ perPage: PER_PAGE2,
1940
+ orderBy: "asc",
1941
+ ...o.device_id !== null ? { deviceId: o.device_id } : {},
1942
+ ...o.since ? { updated_since: o.since } : {}
1943
+ };
1944
+ while (!state.list.done) {
1945
+ checkAbort();
1946
+ const query = state.list.next ? mergeNext(baseQuery, state.list.next) : baseQuery;
1947
+ const page = await client.page(routes.conversationsList, { query });
1948
+ let text = "";
1949
+ let rows = 0;
1950
+ for (const conversation of page.items) {
1951
+ const id = conversationContactId(conversation);
1952
+ if (!Number.isSafeInteger(id) || seen.has(id)) continue;
1953
+ seen.add(id);
1954
+ text += `${JSON.stringify(conversation)}
1955
+ `;
1956
+ rows++;
1957
+ }
1958
+ file.append(text, rows);
1959
+ file.sync();
1960
+ state.list.file = file.position;
1961
+ state.list.next = page.next;
1962
+ state.list.done = page.next === null;
1963
+ save();
1964
+ ctx.out.progress(` conversations: ${formatCount(state.list.file.rows)}`);
1965
+ }
1966
+ state.conversations_file = file.finalize();
1967
+ file = null;
1968
+ } else {
1969
+ state.conversations_file = resumed;
1970
+ }
1971
+ state.phase = "messages";
1972
+ save();
1973
+ }
1974
+ const conversationsFile = state.conversations_file;
1975
+ const contactIds = readContactIds(session.path(conversationsFile.path));
1976
+ const m = state.messages;
1977
+ const rotate = () => {
1978
+ if (!file) return;
1979
+ m.parts.push(file.finalize());
1980
+ file = null;
1981
+ m.part_index++;
1982
+ m.part = { offset: 0, rows: 0 };
1983
+ save();
1984
+ };
1985
+ const currentPart = () => {
1986
+ while (!file) {
1987
+ const name = session.path(partName(m.part_index));
1988
+ const reopened = m.part.offset === 0 ? PartFile.create(name) : PartFile.resume(name, m.part);
1989
+ if (reopened instanceof PartFile) {
1990
+ file = reopened;
1991
+ } else {
1992
+ m.parts.push(reopened);
1993
+ m.part_index++;
1994
+ m.part = { offset: 0, rows: 0 };
1995
+ save();
1996
+ }
1997
+ }
1998
+ return file;
1999
+ };
2000
+ if (m.part.offset > 0) currentPart();
2001
+ if (file && file.rows >= o.part_size) rotate();
2002
+ for (let i = m.contact_index; i < contactIds.length; i++) {
2003
+ const contactId = contactIds[i];
2004
+ let messages = 0;
2005
+ for (; ; ) {
2006
+ checkAbort();
2007
+ const query = { perPage: PER_PAGE2, ...o.since ? { updated_since: o.since } : {}, ...m.next ?? {} };
2008
+ let page;
2009
+ try {
2010
+ page = await client.page(routes.contactMessages, { path: { contactId }, query });
2011
+ } catch (error) {
2012
+ if (error instanceof ApiError && error.status === 404 && error.apiCode !== "route_not_found") {
2013
+ state.vanished_contacts.push(contactId);
2014
+ m.next = null;
2015
+ break;
2016
+ }
2017
+ throw error;
2018
+ }
2019
+ const rows = o.device_id === null ? page.items : page.items.filter((message) => Number(message.device_id) === o.device_id);
2020
+ if (rows.length) {
2021
+ const part = currentPart();
2022
+ part.append(rows.map((message) => `${JSON.stringify(message)}
2023
+ `).join(""), rows.length);
2024
+ part.sync();
2025
+ m.part = part.position;
2026
+ for (const message of rows) observe2(state, message.time);
2027
+ }
2028
+ m.total += rows.length;
2029
+ messages += rows.length;
2030
+ m.next = page.next;
2031
+ if (!page.next) m.contact_index = i + 1;
2032
+ save();
2033
+ if (file && file.rows >= o.part_size) rotate();
2034
+ if (!page.next) break;
2035
+ }
2036
+ m.contact_index = i + 1;
2037
+ m.next = null;
2038
+ save();
2039
+ ctx.out.progress(` conversation ${formatCount(i + 1)} of ${formatCount(contactIds.length)} (contact ${contactId}): ${countOf(messages, "message")} \xB7 total ${formatCount(m.total)}`);
2040
+ }
2041
+ if (file) {
2042
+ if (file.rows > 0) rotate();
2043
+ else {
2044
+ file.close();
2045
+ rmSync4(file.partPath, { force: true });
2046
+ file = null;
2047
+ }
2048
+ }
2049
+ const manifest = {
2050
+ ...baseManifest(state, [routes.conversationsList, routes.contactMessages], new Date(ctx.io.clock()).toISOString()),
2051
+ range: {
2052
+ since: o.since,
2053
+ until: null,
2054
+ observed_first_message_time: state.observed.first_message_time,
2055
+ observed_last_message_time: state.observed.last_message_time
2056
+ },
2057
+ counts: {
2058
+ conversations: contactIds.length,
2059
+ messages: m.total,
2060
+ message_parts: m.parts.length,
2061
+ vanished_contacts: state.vanished_contacts.length
2062
+ },
2063
+ files: [conversationsFile, ...m.parts],
2064
+ notes: [
2065
+ "conversations.jsonl: one conversation per line, as GET /api/v1/conversations returns it (by contact_id, without repeats).",
2066
+ "messages-NNNNN.jsonl: one message per line, as GET /api/v1/contacts/{contactId}/messages returns it; conversations in the order of conversations.jsonl and, within each one, from the newest message to the oldest (descending id, the API's order).",
2067
+ ...o.since ? ['since is sent as updated_since: conversations and messages created or changed since that date. The API has no "until" filter.'] : [],
2068
+ ...o.device_id !== null ? ["device_id picks the conversations on the API side (deviceId) and the messages on the CLI side: the API does not filter messages by number."] : [],
2069
+ "observed_*_message_time: the time field of the messages, as the API returns it (YYYY-MM-DD HH:MM:SS, without a time zone)."
2070
+ ]
2071
+ };
2072
+ finishExport(
2073
+ ctx,
2074
+ session,
2075
+ manifest,
2076
+ `${countOf(contactIds.length, "conversation")} and ${countOf(m.total, "message")} in ${countOf(m.parts.length, "part")}, in ${session.dir}`
2077
+ );
2078
+ } catch (error) {
2079
+ throw withResumeHint(error, resumeCommand);
2080
+ } finally {
2081
+ file?.close();
2082
+ session.release();
2083
+ }
2084
+ }
2085
+ function conversationContactId(row) {
2086
+ return Number(row.contact_id ?? row.id);
2087
+ }
2088
+ function readContactIds(path, limit) {
2089
+ const content = readFileSync3(path).subarray(0, limit).toString("utf8");
2090
+ const ids = /* @__PURE__ */ new Set();
2091
+ for (const line of content.split("\n")) {
2092
+ if (line.trim() === "") continue;
2093
+ const parsed = JSON.parse(line);
2094
+ const id = isRecord(parsed) ? conversationContactId(parsed) : NaN;
2095
+ if (Number.isSafeInteger(id)) ids.add(id);
2096
+ }
2097
+ return [...ids];
2098
+ }
2099
+ function observe2(state, time) {
2100
+ if (typeof time !== "string" || time === "") return;
2101
+ const o = state.observed;
2102
+ if (o.first_message_time === null || time < o.first_message_time) o.first_message_time = time;
2103
+ if (o.last_message_time === null || time > o.last_message_time) o.last_message_time = time;
2104
+ }
2105
+
2106
+ // src/commands/mac.ts
2107
+ async function macStatus(ctx) {
2108
+ const body = await ctx.client().get(routes.macStatus);
2109
+ const status = isRecord(body) ? body : { value: body };
2110
+ const n = (value) => typeof value === "number" ? value.toLocaleString("en-US") : "-";
2111
+ const count = status.current_count ?? status.mac_count;
2112
+ const limit = status.limit ?? status.cycle_capacity;
2113
+ const remaining = status.remaining ?? status.total_available;
2114
+ ctx.out.result({ mac: status }, () => [
2115
+ `MACs in the period: ${n(count)} of ${n(limit)} (${n(status.usage_percentage)} %) \xB7 ${n(remaining)} left`,
2116
+ ` state ${String(status.state ?? "-")} \xB7 cap enforcement ${status.enforcement_enabled === true ? "on" : status.enforcement_enabled === false ? "off" : "-"}`,
2117
+ ...typeof status.inactive_contacts_count === "number" ? [` contacts inactive by MAC: ${n(status.inactive_contacts_count)} (sends with a key to them get 429 mac_lockout_24h)`] : []
2118
+ ]);
2119
+ }
2120
+
2121
+ // src/commands/numbers.ts
2122
+ var OFFICIAL_PROVIDER = 2;
2123
+ var CAPABILITY_LABELS = {
2124
+ contacts: "contacts",
2125
+ conversations: "conversations",
2126
+ templates: "templates",
2127
+ health: "health and limit"
2128
+ };
2129
+ function capabilitiesOf(device) {
2130
+ const official = Number(device.provider) === OFFICIAL_PROVIDER;
2131
+ return ["contacts", "conversations", ...official ? ["templates", "health"] : []];
2132
+ }
2133
+ function connectionOf(device) {
2134
+ return Number(device.provider) === OFFICIAL_PROVIDER ? "official" : "qr";
2135
+ }
2136
+ async function listDevices(ctx) {
2137
+ const devices = [];
2138
+ for await (const device of ctx.client().all(routes.devicesList, { query: { perPage: 100 } })) devices.push(device);
2139
+ return devices;
2140
+ }
2141
+ async function numbersList(ctx) {
2142
+ const devices = await listDevices(ctx);
2143
+ const rows = devices.map((device) => ({
2144
+ ...device,
2145
+ connection: connectionOf(device),
2146
+ cli_capabilities: capabilitiesOf(device)
2147
+ }));
2148
+ ctx.out.result(
2149
+ { numbers: rows },
2150
+ () => rows.length === 0 ? "The account has no numbers." : table(
2151
+ ["id", "alias", "number", "connection", "connected", "health", "what the CLI reads"],
2152
+ rows.map((d) => [
2153
+ d.id,
2154
+ d.alias,
2155
+ d.number,
2156
+ d.connection === "official" ? "official" : "QR",
2157
+ Number(d.connected) === 1 ? "yes" : "no",
2158
+ isRecord(d.health) ? String(d.health.state ?? "-") : null,
2159
+ d.cli_capabilities.map((c) => CAPABILITY_LABELS[c]).join(", ")
2160
+ ])
2161
+ )
2162
+ );
2163
+ }
2164
+ async function numbersHealth(ctx, ids) {
2165
+ let targets;
2166
+ const wanted = idList(ids, "number");
2167
+ if (wanted.length) {
2168
+ targets = wanted.map((id) => ({ id, label: String(id) }));
2169
+ } else {
2170
+ const devices = (await listDevices(ctx)).filter((d) => connectionOf(d) === "official");
2171
+ targets = devices.map((d) => ({ id: Number(d.id), label: String(d.alias || d.number || d.id) }));
2172
+ if (targets.length === 0) {
2173
+ ctx.out.result({ numbers: [] }, () => "The account has no official numbers: health and limit exist only for the official WhatsApp API.");
2174
+ return;
2175
+ }
2176
+ }
2177
+ const results = [];
2178
+ for (const target of targets) {
2179
+ try {
2180
+ const health = await ctx.client().get(routes.deviceHealth, { path: { device: target.id } });
2181
+ results.push({ device_id: target.id, ...isRecord(health) ? health : { health } });
2182
+ } catch (error) {
2183
+ if (error instanceof ApiError && error.status === 422 && error.apiCode === "device_not_meta") {
2184
+ results.push({ device_id: target.id, error: "device_not_meta" });
2185
+ continue;
2186
+ }
2187
+ throw error;
2188
+ }
2189
+ }
2190
+ ctx.out.result(
2191
+ { numbers: results },
2192
+ () => table(
2193
+ ["id", "state", "quality", "limit", "per day", "throughput", "updated", "stale"],
2194
+ results.map((r) => {
2195
+ if (r.error === "device_not_meta") return [r.device_id, "not an official number", null, null, null, null, null, null];
2196
+ const h = isRecord(r.health) ? r.health : {};
2197
+ return [
2198
+ r.device_id,
2199
+ str(h.state),
2200
+ str(h.quality_level),
2201
+ str(h.messaging_limit_tier),
2202
+ h.messaging_limit_per_day === null || h.messaging_limit_per_day === void 0 ? "no limit reported" : Number(h.messaging_limit_per_day).toLocaleString("en-US"),
2203
+ str(r.throughput_level),
2204
+ str(h.last_fetched_at),
2205
+ h.stale === true ? "yes" : h.stale === false ? "no" : null
2206
+ ];
2207
+ })
2208
+ )
2209
+ );
2210
+ }
2211
+ function str(value) {
2212
+ return value === null || value === void 0 || value === "" ? null : String(value);
2213
+ }
2214
+
2215
+ // src/commands/templates.ts
2216
+ var STATUSES = ["PENDING", "APPROVED", "REJECTED", "PENDING_DELETION", "PAUSED", "DISABLED", "ARCHIVED"];
2217
+ async function templatesList(ctx, flags) {
2218
+ const status = flags.status?.toUpperCase();
2219
+ if (status && !STATUSES.includes(status)) throw usageError(`--status must be one of ${STATUSES.join(", ")}: ${flags.status}`);
2220
+ const query = {
2221
+ perPage: 100,
2222
+ ...flags.device ? { deviceId: positiveInt(flags.device, "--device") } : {},
2223
+ ...status ? { status } : {}
2224
+ };
2225
+ const templates = [];
2226
+ for await (const template of ctx.client().all(routes.templatesList, { query })) templates.push(template);
2227
+ ctx.out.result(
2228
+ { templates },
2229
+ () => templates.length === 0 ? "No templates match those filters." : table(
2230
+ ["id", "name", "language", "category", "status", "number"],
2231
+ templates.map((t) => [t.id, t.name, t.language_code, t.category, t.status, t.device_id])
2232
+ )
2233
+ );
2234
+ }
2235
+
2236
+ // src/main.ts
2237
+ var EXIT_CODES_HELP = `
2238
+ Exit codes:
2239
+ 0 all good
2240
+ 1 unexpected error
2241
+ 2 wrong usage (invalid option or value)
2242
+ 3 no key, a key the API rejects (401) or a missing permission (403)
2243
+ 4 not found in your account (404)
2244
+ 5 an API limit the CLI does not wait for (daily cap or a long Retry-After): it can be resumed
2245
+ 6 API or network error, after the retries
2246
+ 7 the output already exists: the CLI never overwrites a file
2247
+ 130 interrupted with Ctrl+C: an export can be resumed
2248
+
2249
+ Environment variables:
2250
+ FUNNELCHAT_API_KEY the key (wins over the saved profile, except with --profile)
2251
+ FUNNELCHAT_PROFILE the profile to use
2252
+ FUNNELCHAT_BASE_URL the base URL
2253
+ FUNNELCHAT_RATE requests per second
2254
+ FUNNELCHAT_CONFIG_DIR where to keep the configuration
2255
+
2256
+ The CLI only reads: it sends no messages and changes nothing in your account.`;
2257
+ async function run(argv, io) {
2258
+ let exitCode = ExitCode.OK;
2259
+ const program = new Command("funnelchat");
2260
+ program.description("Export conversations and contacts from your Funnelchat account through the public API, and check your numbers, templates and MACs.").version(VERSION, "-V, --version", "Shows the version.").helpOption("-h, --help", "Shows the help.").helpCommand("help [command]", "Shows the help of a command.").option("--profile <name>", "Configuration profile to use.").option("--base-url <url>", "Base URL of the API (https://api.funnelchat.com by default).").option("--staging", "Uses the staging API (https://api-staging.funnelchat.com).").option("--json", "JSON output, for scripts.").option("-v, --verbose", "Shows every request to the API (never the key).").option("-q, --quiet", "No progress messages.").option("--rate <requests>", "Requests per second at most (1 by default).").option("--max-wait <seconds>", "How long to wait for a Retry-After on its own before stopping (60 by default).").addHelpText("after", EXIT_CODES_HELP).showSuggestionAfterError(true).exitOverride().configureOutput({
2261
+ writeOut: (text) => io.stdout.write(text),
2262
+ writeErr: (text) => io.stderr.write(text)
2263
+ });
2264
+ const action = (fn) => async (...args) => {
2265
+ const command = args[args.length - 1];
2266
+ const ctx = new Context(io, command.optsWithGlobals());
2267
+ try {
2268
+ await fn(ctx, command, ...args.slice(0, -1));
2269
+ exitCode = ExitCode.OK;
2270
+ } catch (error) {
2271
+ exitCode = ctx.out.error(error);
2272
+ }
2273
+ };
2274
+ program.command("login").description("Saves your API key in a profile (a file with 0600 permissions). Reads it from the keyboard without showing it, or from standard input.").option("--no-verify", "Does not check it against the API before saving it.").action(action((ctx, command) => login(ctx, command.opts())));
2275
+ program.command("logout").description("Deletes the saved key of the active profile (or of --profile).").action(action((ctx) => logout(ctx)));
2276
+ program.command("whoami").description("Shows which account the key belongs to, without showing the key.").action(action((ctx) => whoami(ctx)));
2277
+ const profiles = program.command("profiles").description("Saved profiles.");
2278
+ profiles.command("list", { isDefault: true }).description("Lists the profiles (never shows the keys).").action(action((ctx) => profilesList(ctx)));
2279
+ profiles.command("use").argument("<name>", "Profile to activate.").description("Chooses the profile used by default.").action(action((ctx, _command, name) => profilesUse(ctx, name)));
2280
+ const numbers = program.command("numbers").description("Your WhatsApp numbers.");
2281
+ numbers.command("list").description("Lists the numbers, with their connection and what the CLI can export or read for each one.").action(action((ctx) => numbersList(ctx)));
2282
+ numbers.command("health").argument("[numbers...]", "Ids of the numbers (all the official ones by default).").description("Health, quality, messaging limit and throughput of the official numbers.").action(action((ctx, _command, ids) => numbersHealth(ctx, ids ?? [])));
2283
+ const contacts = program.command("contacts").description("Contacts.");
2284
+ contacts.command("export").description("Exports the contacts with their tags and custom fields, as CSV or JSONL.").option("-o, --output-dir <dir>", "New or empty folder for the export (funnelchat-contacts-export-<date> by default).").option("--format <format>", "csv (default) or jsonl.").option("--tag <id>", "Only contacts with this tag (can be repeated or comma-separated).", collect).option("--tag-mode <mode>", "With several --tag: any (default), all or exclude (none of them).").option("--created-from <date>", "Created from this date (YYYY-MM-DD, the whole day). Goes with --created-to.").option("--created-to <date>", "Created up to this date (YYYY-MM-DD, the whole day). Goes with --created-from.").option("--only-with-phone", "Only contacts that can be messaged.").option("--no-tags", "Without tags (one request less per contact).").option("--no-custom-fields", "Without custom fields (one request less per contact).").option("--resume", "Resumes an interrupted export in --output-dir, from the last saved page.").action(
2285
+ action((ctx, command) => contactsExport(ctx, command.opts(), explicitOptions(command, CONTACTS_FLAG_NAMES)))
2286
+ );
2287
+ const conversations = program.command("conversations").description("Conversations and their messages.");
2288
+ conversations.command("export").description("Exports the messages of every conversation in JSONL parts, with a manifest.").option("-o, --output-dir <dir>", "New or empty folder for the export (funnelchat-conversations-export-<date> by default).").option("--contact <id>", "Only the conversation of this contact (can be repeated or comma-separated).", collect).option("--device <id>", "Only conversations of this number, and of them only its messages.").option("--since <date>", "Conversations and messages created or changed since this date (YYYY-MM-DD in UTC, or a date and time with a zone).").option("--part-size <messages>", `Messages per part (${DEFAULT_PART_SIZE.toLocaleString("en-US")} by default).`).option("--resume", "Resumes an interrupted export in --output-dir, from the last saved cursor.").action(
2289
+ action(
2290
+ (ctx, command) => conversationsExport(ctx, command.opts(), explicitOptions(command, CONVERSATIONS_FLAG_NAMES))
2291
+ )
2292
+ );
2293
+ const templates = program.command("templates").description("WhatsApp templates.");
2294
+ templates.command("list").description("Lists the templates, with their status and their number.").option("--device <id>", "Only those of this number.").option("--status <status>", "APPROVED, PENDING, REJECTED, PAUSED, DISABLED\u2026").action(action((ctx, command) => templatesList(ctx, command.opts())));
2295
+ const mac = program.command("mac").description("Monthly active contacts (MACs).");
2296
+ mac.command("status").description("How many MACs you have used in the period, the limit and how many are left.").action(action((ctx) => macStatus(ctx)));
2297
+ for (const command of program.commands) {
2298
+ command.exitOverride().configureOutput({ writeOut: (text) => io.stdout.write(text), writeErr: (text) => io.stderr.write(text) });
2299
+ for (const sub of command.commands) {
2300
+ sub.exitOverride().configureOutput({ writeOut: (text) => io.stdout.write(text), writeErr: (text) => io.stderr.write(text) });
2301
+ }
2302
+ }
2303
+ try {
2304
+ await program.parseAsync(argv, { from: "user" });
2305
+ } catch (error) {
2306
+ if (error instanceof CommanderError) {
2307
+ if (["commander.helpDisplayed", "commander.version", "commander.help"].includes(error.code)) return ExitCode.OK;
2308
+ if (error.code === "commander.executeSubCommandAsync") return ExitCode.UNEXPECTED;
2309
+ return ExitCode.USAGE;
2310
+ }
2311
+ throw error;
2312
+ }
2313
+ return exitCode;
2314
+ }
2315
+
2316
+ // src/cli.ts
2317
+ var controller = new AbortController();
2318
+ var interrupted = false;
2319
+ process.on("SIGINT", () => {
2320
+ if (interrupted) process.exit(130);
2321
+ interrupted = true;
2322
+ process.stderr.write("\nStopping\u2026 (Ctrl+C again to quit now)\n");
2323
+ controller.abort();
2324
+ });
2325
+ if (typeof globalThis.fetch !== "function") {
2326
+ process.stderr.write("error: this version of Node has no fetch. Use Node 18 or later.\n");
2327
+ process.exit(1);
2328
+ }
2329
+ var code = await run(process.argv.slice(2), {
2330
+ stdout: process.stdout,
2331
+ stderr: process.stderr,
2332
+ stdin: process.stdin,
2333
+ env: process.env,
2334
+ cwd: process.cwd(),
2335
+ fetch: globalThis.fetch,
2336
+ sleep: realSleep,
2337
+ clock: Date.now,
2338
+ signal: controller.signal
2339
+ });
2340
+ process.exitCode = code;
2341
+ //# sourceMappingURL=cli.js.map