@flowapt/flowiq-cli 0.9.9 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,8 +8,12 @@
8
8
  // flowiq tag segment <org> --min-orders 2 --min-spent 1000 --region ZA-GP --tag gp-repeat
9
9
  // flowiq tag attributes <org> --filter allow_broadcast_true --tag broadcastable
10
10
  // flowiq tag messages <org> --min-count 3 --sender user-whatsapp --tag engaged
11
+ // flowiq tag ids <org> --ids a,b,c | --ids-file ids.txt --tag <name> [--commit]
12
+ // flowiq tag holders <org> <tag> [--limit 200] [--all] [--csv]
13
+ // flowiq tag field <org> --field whatsapp_id --any 27794975464 --exact --tag x --commit
11
14
  // flowiq tag list <org>
12
- // flowiq tag remove <org> <tag>[,<tag>...] --confirm
15
+ // flowiq tag remove <org> <tag>[,<tag>...] --confirm (every contact)
16
+ // flowiq tag remove <org> <tag> --contacts a,b | --contacts-file f --confirm (those only)
13
17
  //
14
18
  // Every match mode prints the dry-run count + a sample; nothing writes until
15
19
  // --commit AND (for large writes) a typed confirmation. Appends only — it
@@ -91,12 +95,76 @@ export async function field(orgId, opts = {}) {
91
95
  requireOrg(orgId);
92
96
  const keywords = splitList(opts.any || opts.all);
93
97
  if (!keywords) { console.error("Error: pass --any \"a,b\" (OR) or --all \"a,b\" (AND)."); process.exit(1); }
98
+ if (opts.exact && opts.all) { console.error("Error: --exact matches whole values, so it is an OR over --any (drop --all)."); process.exit(1); }
94
99
  const payload = {
95
100
  mode: "field", organization_id: orgId, field_name: opts.field || "order_history",
96
- keywords, logic: opts.all ? "AND" : "OR", case_sensitive: !!opts.caseSensitive,
101
+ keywords, logic: opts.all ? "AND" : "OR", case_sensitive: !!opts.caseSensitive, exact: !!opts.exact,
97
102
  };
98
103
  await confirmLargeWrite(orgId, payload, opts, "Field search");
99
- await runMatch("Field search", payload, opts);
104
+ await runMatch(opts.exact ? "Exact field match" : "Field search", payload, opts);
105
+ }
106
+
107
+ async function readIds(csv, file) {
108
+ const fs = await import("node:fs/promises");
109
+ let ids = splitList(csv) || [];
110
+ if (file) {
111
+ const text = await fs.readFile(file, "utf8").catch((e) => { console.error(`Error: cannot read ${file}: ${e.message}`); process.exit(1); });
112
+ ids.push(...text.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean));
113
+ }
114
+ ids = [...new Set(ids)];
115
+ const bad = ids.filter((id) => !UUID_RE.test(id));
116
+ if (bad.length) console.error(`⚠ ${bad.length} value(s) are not contact UUIDs and will be ignored (e.g. ${bad[0]})`);
117
+ return ids.filter((id) => UUID_RE.test(id));
118
+ }
119
+
120
+ // `flowiq tag ids <org> --ids a,b | --ids-file f --tag <name> [--commit]`
121
+ export async function ids(orgId, opts = {}) {
122
+ requireOrg(orgId);
123
+ const list = await readIds(opts.ids, opts.idsFile);
124
+ if (!list.length) { console.error("Error: pass --ids a,b,c or --ids-file <path> (contact UUIDs, one per line or comma-separated)."); process.exit(1); }
125
+ const payload = { mode: "ids", organization_id: orgId, contact_ids: list };
126
+ await confirmLargeWrite(orgId, payload, opts, "Id list");
127
+ const commit = !!opts.commit;
128
+ if (commit && !opts.tag) { console.error("Error: --tag <name> is required with --commit."); process.exit(1); }
129
+ let resp;
130
+ try { resp = await http.post("tag", { ...payload, dry_run: !commit, tag: commit ? opts.tag.trim() : undefined }); }
131
+ catch (e) { console.error(`Tag ids failed: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
132
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
133
+ if (!commit) {
134
+ console.log(`Id list: ${resp.matched} of ${list.length} id(s) belong to ${resp.organization_name}${resp.not_found ? ` · ${resp.not_found} not in this org (ignored)` : ""}.`);
135
+ renderSample(resp.sample);
136
+ console.log(`\nAdd --tag <name> --commit to apply. (dry run — nothing written)`);
137
+ return;
138
+ }
139
+ console.log(`Id list: matched ${resp.matched} · newly tagged ${resp.tagged} · already had "${resp.tag}" ${resp.already_had}${resp.not_found ? ` · not in org ${resp.not_found}` : ""}.`);
140
+ console.log(`\nVerify: flowiq tag holders ${orgId} ${resp.tag} · Undo: flowiq tag remove ${orgId} ${resp.tag} --contacts-file <the same file> --confirm`);
141
+ }
142
+
143
+ // `flowiq tag holders <org> <tag> [--limit] [--all] [--csv]` — who carries a tag.
144
+ export async function holders(orgId, tagName, opts = {}) {
145
+ requireOrg(orgId);
146
+ if (!tagName) { console.error("Error: pass the tag name, e.g. flowiq tag holders <org> vip"); process.exit(1); }
147
+ const pageSize = opts.all ? 1000 : Math.min(Number(opts.limit) || 200, 1000);
148
+ const contacts = [];
149
+ let offset = 0, resp;
150
+ for (;;) {
151
+ try { resp = await http.post("tag", { mode: "holders", organization_id: orgId, tag: tagName, limit: pageSize, offset }); }
152
+ catch (e) { console.error(`Holders failed: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
153
+ contacts.push(...(resp.contacts || []));
154
+ if (!opts.all || !resp.has_more || !(resp.contacts || []).length) break;
155
+ offset += resp.contacts.length;
156
+ }
157
+ if (opts.json) { console.log(JSON.stringify({ ...resp, contacts }, null, 2)); return; }
158
+ if (opts.csv) {
159
+ console.log("id,full_name,whatsapp_id,email,allow_broadcast,archived,blocked");
160
+ for (const c of contacts) console.log([c.id, c.full_name, c.whatsapp_id, c.email, c.allow_broadcast, c.archived, c.blocked].map((v) => { const s = v == null ? "" : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }).join(","));
161
+ return;
162
+ }
163
+ console.log(`"${tagName}" on ${resp.organization_name}: ${resp.total} contact(s)${contacts.length < resp.total ? ` (showing ${contacts.length}; --all for every page, --csv to export)` : ""}`);
164
+ for (const c of contacts) {
165
+ const flags = [c.allow_broadcast === false && "no-broadcast", c.archived && "archived", c.blocked && "blocked", c.bot_status === false && "AI off"].filter(Boolean).join(", ");
166
+ console.log(` ${(c.full_name || "—").slice(0, 28).padEnd(28)} ${String(c.whatsapp_id || c.email || "").padEnd(16)} ${c.id}${flags ? ` (${flags})` : ""}`);
167
+ }
100
168
  }
101
169
 
102
170
  export async function cohort(orgId, opts = {}) {
@@ -170,12 +238,14 @@ export async function remove(orgId, tagArg, opts = {}) {
170
238
  requireOrg(orgId);
171
239
  const tags = splitList(tagArg);
172
240
  if (!tags || !tags.length) { console.error("Error: pass one or more tag names (comma-separated)."); process.exit(1); }
241
+ const contactIds = opts.contacts || opts.contactsFile ? await readIds(opts.contacts, opts.contactsFile) : null;
242
+ if ((opts.contacts || opts.contactsFile) && !contactIds.length) { console.error("Error: --contacts / --contacts-file gave no contact UUIDs."); process.exit(1); }
173
243
  if (!opts.confirm) {
174
- console.log(`DRY RUN — would remove ${tags.length} tag(s) from all contacts: ${tags.join(", ")}. Add --confirm to remove.`);
244
+ console.log(`DRY RUN — would remove ${tags.length} tag(s) from ${contactIds ? `${contactIds.length} listed contact(s)` : "ALL contacts"}: ${tags.join(", ")}. Add --confirm to remove.`);
175
245
  return;
176
246
  }
177
247
  let resp;
178
- try { resp = await http.post("tag", { mode: "remove", organization_id: orgId, tags }); }
179
- catch (e) { console.error(`Remove failed: ${e.message}`); process.exit(1); }
180
- console.log(`Removed ${tags.join(", ")} from ${resp.contacts_touched} contact(s) on ${resp.organization_name}.`);
248
+ try { resp = await http.post("tag", { mode: "remove", organization_id: orgId, tags, ...(contactIds ? { contact_ids: contactIds } : {}) }); }
249
+ catch (e) { console.error(`Remove failed: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
250
+ console.log(`Removed ${tags.join(", ")} from ${resp.contacts_touched} contact(s) on ${resp.organization_name}${contactIds ? ` (of ${contactIds.length} listed${resp.not_found ? `, ${resp.not_found} not in this org` : ""})` : ""}.`);
181
251
  }
@@ -343,8 +343,37 @@ export async function show(orgId, name, opts = {}) {
343
343
 
344
344
  const rows = resp.templates || [];
345
345
  if (!rows.length) {
346
- console.error(`No template on this org matching "${name}". List them with: flowiq templates status ${orgId}`);
347
- process.exit(1);
346
+ // Not in FlowIQ's table is not "does not exist": a template made in Meta's
347
+ // own tools or on a swapped WABA lives only at Meta (Health Matrix, 22 Sep
348
+ // 2026). Read it from Meta before giving up.
349
+ let live;
350
+ try { live = await http.get("templates", { organization_id: orgId, name }); } catch { live = null; }
351
+ const liveRows = (live?.templates || []).filter((t) => t.name === name) .concat((live?.templates || []).filter((t) => t.name !== name));
352
+ if (!liveRows.length) {
353
+ console.error(`No template on this org matching "${name}", in FlowIQ or at Meta. List them with: flowiq templates status ${orgId} --live`);
354
+ process.exit(1);
355
+ }
356
+ const t = liveRows[0];
357
+ if (liveRows.length > 1 && t.name !== name) {
358
+ console.error(`"${name}" matches ${liveRows.length} templates at Meta — name one exactly:`);
359
+ for (const r of liveRows) console.error(` ${r.name} [${r.status}] ${r.category}`);
360
+ process.exit(1);
361
+ }
362
+ if (opts.json) { console.log(JSON.stringify({ source: "meta", template: t }, null, 2)); return; }
363
+ console.log(`${t.name} [${t.status}] ${t.category}${t.previous_category && t.previous_category !== t.category ? ` (was ${t.previous_category})` : ""} · ${t.language}${t.parameter_format ? ` · ${t.parameter_format}` : ""}`);
364
+ console.log(` org: ${orgId}`);
365
+ console.log(` source: META ONLY — not recorded in FlowIQ's templates table (made in Meta's tools or on another WABA), so it cannot be picked in the send dialog until it is pulled in`);
366
+ if (t.id) console.log(` meta id: ${t.id}`);
367
+ if (t.rejected_reason && t.rejected_reason !== "NONE") console.log(` rejected: ${t.rejected_reason}`);
368
+ for (const c of t.components || []) {
369
+ const label = String(c.type || "").toLowerCase().padEnd(9);
370
+ if (c.type === "BODY" || c.type === "FOOTER") console.log(` ${label} ${String(c.text || "").replace(/\n/g, "\n ")}`);
371
+ else if (c.type === "HEADER") console.log(` ${label} ${c.format}${c.text ? `: ${c.text}` : ""}`);
372
+ else if (c.type === "BUTTONS") for (const b of c.buttons || []) console.log(` button ${b.type}: ${b.text}${b.url ? ` → ${b.url}` : ""}${b.phone_number ? ` → ${b.phone_number}` : ""}`);
373
+ else if (c.type === "CAROUSEL") console.log(` carousel ${(c.cards || []).length} card(s)`);
374
+ }
375
+ console.log(`\n The full Meta record: flowiq templates pull ${orgId}`);
376
+ return;
348
377
  }
349
378
  // Prefer an exact name match; otherwise if the substring is ambiguous, say so.
350
379
  let row = rows.find((r) => r.template_name === name);
@@ -444,22 +473,54 @@ export async function attempts(orgId, opts = {}) {
444
473
  if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
445
474
 
446
475
  const rows = resp.attempts || [];
447
- if (!rows.length) {
476
+ const logs = resp.status_logs || [];
477
+ if (!rows.length && !logs.length) {
448
478
  console.log(opts.failed ? "(no failed submissions on record for this org)" : "(no template submissions on record for this org — the ledger started 21 Sep 2026)");
449
479
  return;
450
480
  }
451
- console.log(`${rows.length} submission${rows.length === 1 ? "" : "s"}${opts.failed ? " (failed only)" : ""}, newest first (SAST):`);
481
+ // Meta's history per template, newest first: what happened AFTER submit.
482
+ // A category flip shows as the same status with a new category.
483
+ const historyOf = (name) => logs.filter((l) => l.template_name === name);
484
+ const describeHistory = (name, submittedCategory) => {
485
+ const h = historyOf(name).slice().reverse(); // oldest first
486
+ if (!h.length) return [];
487
+ const lines = [];
488
+ let lastCat = submittedCategory || null;
489
+ for (const l of h) {
490
+ const flipped = l.category && lastCat && l.category !== lastCat;
491
+ lines.push(`${sast(l.created_at)} ${l.status}${l.category ? ` / ${l.category}` : ""}${flipped ? ` ⚠ CATEGORY MOVED ${lastCat} → ${l.category}` : ""}${l.reason ? ` (${l.reason})` : ""}`);
492
+ if (l.category) lastCat = l.category;
493
+ }
494
+ return lines;
495
+ };
496
+ if (rows.length) console.log(`${rows.length} submission${rows.length === 1 ? "" : "s"}${opts.failed ? " (failed only)" : ""}, newest first (SAST):`);
497
+ const named = new Set();
452
498
  for (const a of rows) {
453
499
  const mark = a.outcome === "submitted" ? "✓" : "✗";
454
500
  const who = a.source ? ` via ${a.source}` : "";
455
501
  console.log(`\n ${mark} ${sast(a.created_at)} ${a.template_name || "(unnamed)"}${a.category ? ` [${a.category}]` : ""}${who}`);
456
502
  if (a.outcome === "submitted") {
457
503
  console.log(` submitted to Meta — id ${a.meta_template_id || "?"}, status at submit ${a.meta_status || "?"}`);
504
+ const hist = describeHistory(a.template_name, a.category);
505
+ named.add(a.template_name);
506
+ if (hist.length) for (const line of hist) console.log(` then: ${line}`);
507
+ else console.log(` no status webhook from Meta recorded yet (still ${a.meta_status || "PENDING"} as far as FlowIQ knows: flowiq templates status ${orgId} --live --name ${a.template_name})`);
458
508
  } else {
459
509
  console.log(` FAILED (HTTP ${a.http_status ?? "?"}) ${a.error || ""}`);
460
510
  if (a.message && a.message !== a.error) console.log(` ${a.message}`);
461
511
  if (a.meta_error?.error_user_msg && a.meta_error.error_user_msg !== a.message) console.log(` Meta: ${a.meta_error.error_user_msg}`);
462
512
  }
463
513
  }
464
- console.log(`\nA failed row is the reason the template is not in \`templates status\` — fix what the message names and submit again.`);
514
+ // Templates with Meta history but no submission row (submitted before the
515
+ // ledger existed, or from Meta's own tools): still worth showing when asked
516
+ // for by name, or when nothing else matched.
517
+ const orphan = [...new Set(logs.map((l) => l.template_name))].filter((n) => !named.has(n));
518
+ if (orphan.length && (opts.name || !rows.length)) {
519
+ console.log(`\nMeta status history with no submission row (older than the ledger, or submitted outside FlowIQ):`);
520
+ for (const n of orphan.slice(0, 20)) {
521
+ console.log(`\n ${n}`);
522
+ for (const line of describeHistory(n, null)) console.log(` ${line}`);
523
+ }
524
+ }
525
+ if (rows.length) console.log(`\nA failed row is the reason the template is not in \`templates status\` — fix what the message names and submit again.`);
465
526
  }
@@ -0,0 +1,69 @@
1
+ // `flowiq tickets list|show|status` — escalation tickets (server: api/cli/tickets.js).
2
+
3
+ import { http } from "../http.js";
4
+ import { fmtSast } from "./audit.js";
5
+
6
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
+ const pad = (s, n) => String(s ?? "").padEnd(n);
8
+ function fail(prefix, e) { console.error(`${prefix}: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
9
+
10
+ export function parseOlderThan(v) {
11
+ if (v == null || v === "") return undefined;
12
+ const m = /^(\d+)([hdw])$/i.exec(String(v).trim());
13
+ if (!m) { console.error(`Error: --older-than takes 48h / 3d / 1w (got "${v}").`); process.exit(1); }
14
+ return Number(m[1]) * { h: 1, d: 24, w: 168 }[m[2].toLowerCase()];
15
+ }
16
+
17
+ export async function list(orgId, opts = {}) {
18
+ if (!UUID_RE.test(orgId || "")) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
19
+ let resp;
20
+ try { resp = await http.get("tickets", { organization_id: orgId, status: opts.status, contact_id: opts.contact, older_than_hours: parseOlderThan(opts.olderThan), limit: opts.limit }); }
21
+ catch (e) { fail("List failed", e); }
22
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
23
+ if (!resp.tickets.length) { console.log(`No ${resp.statuses.join("/")} ticket(s) on ${resp.organization_name}${opts.olderThan ? ` older than ${opts.olderThan}` : ""}.`); return; }
24
+ console.log(`${resp.organization_name}: ${resp.capped ? `${resp.tickets.length} of ${resp.total}` : resp.tickets.length} ${resp.statuses.join("/")} ticket(s), newest first`);
25
+ console.log("");
26
+ console.log(` ${pad("OPENED (SAST)", 17)} ${pad("AGE", 6)} ${pad("STATUS", 12)} ${pad("CONTACT", 26)} ${pad("TEAM", 14)} SUMMARY`);
27
+ for (const t of resp.tickets) {
28
+ const who = `${t.contact?.full_name ?? "?"}${t.contact?.whatsapp_id ? ` ${t.contact.whatsapp_id}` : ""}`.slice(0, 26);
29
+ console.log(` ${pad(fmtSast(t.created_at), 17)} ${pad(`${t.age_hours}h`, 6)} ${pad(t.status, 12)} ${pad(who, 26)} ${pad(t.team ?? "", 14)} ${String(t.summary ?? "").replace(/\s+/g, " ").slice(0, 70)}`);
30
+ console.log(` ${" ".repeat(17)} ${t.id}${t.contact?.bot_status === false ? " AI off on contact" : ""}${t.reopened_count ? ` reopened ${t.reopened_count}x` : ""}`);
31
+ }
32
+ if (resp.capped) console.log(`\n ${resp.total - resp.tickets.length} more; narrow with --contact / --older-than or raise --limit (max 500).`);
33
+ console.log(`\nflowiq tickets show <ticket_id> · flowiq tickets status <ticket_id> --set resolved --commit`);
34
+ }
35
+
36
+ export async function show(ticketId, opts = {}) {
37
+ if (!UUID_RE.test(ticketId || "")) { console.error(`Error: "${ticketId}" is not a valid ticket UUID.`); process.exit(1); }
38
+ let resp;
39
+ try { resp = await http.get("tickets", { ticket_id: ticketId }); } catch (e) { fail("Read failed", e); }
40
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
41
+ const t = resp.ticket;
42
+ console.log(`Ticket ${t.id} on ${resp.organization_name}: ${t.status}${t.type ? ` (${t.type})` : ""}`);
43
+ console.log(` contact: ${t.contact?.full_name ?? "?"} ${t.contact?.whatsapp_id ?? ""} (${t.contact_id})${t.contact?.bot_status === false ? " · AI off" : ""}`);
44
+ console.log(` opened: ${fmtSast(t.created_at)} SAST (${t.age_hours}h ago)${t.team ? ` · team ${t.team}` : ""}`);
45
+ console.log(` summary: ${t.summary ?? "(none)"}`);
46
+ if (t.resolved_at) console.log(` resolved: ${fmtSast(t.resolved_at)} SAST by ${t.resolved_by ?? "?"}`);
47
+ if (t.csat_sent_at) console.log(` csat sent: ${fmtSast(t.csat_sent_at)} SAST`);
48
+ if (t.reopened_count) console.log(` reopened: ${t.reopened_count}x (${t.data?.reopen_reason ?? ""})`);
49
+ if (Array.isArray(t.data?.status_history) && t.data.status_history.length) {
50
+ console.log(" history:");
51
+ for (const h of t.data.status_history.slice(-10)) console.log(` ${fmtSast(h.at ?? h.timestamp ?? h.changed_at)} ${h.from ?? "?"} → ${h.to ?? h.status ?? "?"} by ${h.by ?? "?"}${h.note ? ` (${h.note})` : ""}`);
52
+ }
53
+ }
54
+
55
+ export async function status(ticketId, opts = {}) {
56
+ if (!UUID_RE.test(ticketId || "")) { console.error(`Error: "${ticketId}" is not a valid ticket UUID.`); process.exit(1); }
57
+ if (!opts.set) { console.error("Error: --set open|in_progress|resolved|closed is required."); process.exit(1); }
58
+ let resp;
59
+ try { resp = await http.post("tickets", { action: "status", ticket_id: ticketId, status: opts.set, note: opts.note, dry_run: !opts.commit }); }
60
+ catch (e) { fail("Status change failed", e); }
61
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
62
+ if (resp.unchanged) { console.log(`Ticket is already ${opts.set}.`); return; }
63
+ if (resp.dry_run) {
64
+ console.log(`Ticket ${ticketId} (${resp.ticket.contact?.full_name ?? resp.ticket.contact_id}, ${resp.organization_name}): ${resp.ticket.status} → ${resp.would_set.status}${resp.would_reenable_bot ? "\n the org re-enables AI on resolve: the contact's agent would be switched back on" : ""}`);
65
+ console.log("DRY RUN — add --commit to write.");
66
+ return;
67
+ }
68
+ console.log(`✓ ticket ${ticketId}: ${resp.previous_status} → ${resp.ticket.status}${resp.bot_reenabled ? " · contact's AI re-enabled" : ""}`);
69
+ }
@@ -0,0 +1,97 @@
1
+ // `flowiq widgets list|show|pull|push|embed|activate` — the web chat widgets
2
+ // (server: api/cli/widgets.js). A push is an id-keyed PATCH, never a full
3
+ // replace: these render on live storefronts.
4
+
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { http } from "../http.js";
8
+ import { fmtSast } from "./audit.js";
9
+
10
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
+ const WIDGET_DIR = path.resolve(process.cwd(), ".flowiq", "widgets");
12
+ export const WIDGET_EMBED_HOST = "https://widget.flowapt.com";
13
+ // The bags a pull writes into the file; a push sends back only what differs.
14
+ const PULL_META = ["id", "organization_id", "created_by", "created_at"];
15
+
16
+ function fail(prefix, e) { console.error(`${prefix}: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
17
+ const requireUuid = (v, what) => { if (!UUID_RE.test(v || "")) { console.error(`Error: "${v}" is not a valid ${what} UUID.`); process.exit(1); } };
18
+
19
+ export function embedSnippet(orgId, widgetId) {
20
+ return `<!-- FlowIQ Chat Widget -->\n<script\n src="${WIDGET_EMBED_HOST}/widget/loader.js"\n data-organization-id="${orgId}"${widgetId ? `\n data-widget-id="${widgetId}"` : ""}\n></script>`;
21
+ }
22
+
23
+ export async function list(orgId, opts = {}) {
24
+ requireUuid(orgId, "organization");
25
+ let resp;
26
+ try { resp = await http.get("widgets", { organization_id: orgId }); } catch (e) { fail("List failed", e); }
27
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
28
+ if (!resp.count) { console.log(`${resp.organization_name} has no web chat widget.`); return; }
29
+ console.log(`${resp.organization_name}: ${resp.count} widget(s)`);
30
+ for (const w of resp.widgets) {
31
+ console.log(` ${w.id} ${w.name ?? "(unnamed)"}${w.is_active ? " [active: the org-only embed resolves to this one]" : ""}`);
32
+ console.log(` agent ${w.agent_name ?? w.agent_id ?? "(org active agent)"}${w.agent_display_name ? ` shown as "${w.agent_display_name}"` : ""} · ${w.widget_position ?? "bottom-right"} · header "${w.header_text ?? ""}"${w.checkout_storefront ? " · checkout storefront" : ""} · created ${fmtSast(w.created_at)}`);
33
+ }
34
+ console.log(`\nflowiq widgets pull <widget_id> · widgets embed ${orgId} [<widget_id>]`);
35
+ }
36
+
37
+ async function fetchWidget(widgetId) {
38
+ requireUuid(widgetId, "widget");
39
+ try { return await http.get("widgets", { widget_id: widgetId }); } catch (e) { fail("Widget read failed", e); }
40
+ }
41
+
42
+ export async function show(widgetId, opts = {}) {
43
+ const resp = await fetchWidget(widgetId);
44
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
45
+ const w = resp.widget;
46
+ console.log(`${w.name ?? "(unnamed)"} ${w.id} on ${resp.organization_name}${w.is_active ? " [active]" : ""}`);
47
+ const scalar = Object.entries(w).filter(([k, v]) => !PULL_META.includes(k) && (v === null || typeof v !== "object"));
48
+ for (const [k, v] of scalar) console.log(` ${k.padEnd(26)} ${v === null ? "(unset)" : String(v).replace(/\s+/g, " ").slice(0, 140)}`);
49
+ const bags = Object.entries(w).filter(([k, v]) => v !== null && typeof v === "object");
50
+ for (const [k, v] of bags) console.log(` ${k.padEnd(26)} ${JSON.stringify(v).slice(0, 200)}${JSON.stringify(v).length > 200 ? "…" : ""}`);
51
+ console.log(`\nFull config: flowiq widgets pull ${w.id}`);
52
+ }
53
+
54
+ export async function pull(widgetId, opts = {}) {
55
+ const resp = await fetchWidget(widgetId);
56
+ const w = resp.widget;
57
+ const slug = String(resp.organization_name || w.organization_id).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
58
+ const file = opts.out ? path.resolve(opts.out) : path.join(WIDGET_DIR, `${slug}-${w.id.slice(0, 8)}.json`);
59
+ await fs.mkdir(path.dirname(file), { recursive: true });
60
+ await fs.writeFile(file, JSON.stringify({ organization_name: resp.organization_name, widget: w }, null, 2) + "\n", "utf8");
61
+ console.log(`Wrote ${file}\n edit the fields under "widget", then: flowiq widgets push ${file} (dry run) → --commit`);
62
+ }
63
+
64
+ export async function push(file, opts = {}) {
65
+ let data;
66
+ try { data = JSON.parse(await fs.readFile(path.resolve(file), "utf8")); } catch (e) { console.error(`Cannot read ${file}: ${e.message}`); process.exit(1); }
67
+ const w = data.widget || data;
68
+ requireUuid(w.id, "widget");
69
+ const patch = {};
70
+ for (const [k, v] of Object.entries(w)) if (!PULL_META.includes(k)) patch[k] = v;
71
+ let resp;
72
+ try { resp = await http.post("widgets", { action: "push", widget_id: w.id, patch, dry_run: !opts.commit }); } catch (e) { fail("Push failed", e); }
73
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
74
+ if (resp.unchanged) { console.log("Nothing differs from the live widget."); return; }
75
+ console.log(`${resp.dry_run ? "Would change" : "Changed"} ${resp.changes.length} field(s) on widget ${resp.widget_id} (${resp.organization_name}):`);
76
+ for (const c of resp.changes) console.log(` ${c.field.padEnd(30)} ${JSON.stringify(c.before)?.slice(0, 60) ?? "(unset)"} → ${JSON.stringify(c.after)?.slice(0, 60) ?? "(unset)"}`);
77
+ if (resp.dry_run) console.log("\nDRY RUN — nothing written. Add --commit to apply (the storefront picks it up on the next widget load).");
78
+ else console.log("\n✓ live. Nested keys merge; a key set to null was removed.");
79
+ }
80
+
81
+ export async function embed(orgId, widgetId, opts = {}) {
82
+ requireUuid(orgId, "organization");
83
+ if (widgetId) requireUuid(widgetId, "widget");
84
+ const snippet = embedSnippet(orgId, widgetId || null);
85
+ if (opts.json) { console.log(JSON.stringify({ organization_id: orgId, widget_id: widgetId || null, snippet })); return; }
86
+ console.log(snippet);
87
+ if (!widgetId) console.error("\n(org-only embed: resolves to the org's single widget, else the one marked active; pass the widget id to pin one)");
88
+ }
89
+
90
+ export async function activate(widgetId, opts = {}) {
91
+ requireUuid(widgetId, "widget");
92
+ let resp;
93
+ try { resp = await http.post("widgets", { action: "activate", widget_id: widgetId }); } catch (e) { fail("Activate failed", e); }
94
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
95
+ if (resp.unchanged) { console.log("Already the active widget."); return; }
96
+ console.log(`✓ ${widgetId} is now the active widget${resp.previously_active?.length ? ` (was: ${resp.previously_active.map((p) => p.name ?? p.id).join(", ")})` : ""}.`);
97
+ }