@flowapt/flowiq-cli 0.9.7 → 0.9.9

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.
@@ -4,10 +4,10 @@
4
4
  // expect/expectNot checks client-side, and prints the transcript.
5
5
  //
6
6
  // test resolve <org_id> [--agent <id>]
7
- // test send <org_id> <message...> [--agent] [--clear] [--sender] [--image] [--timeout] [--json]
7
+ // test send <org_id> <message...> [--agent] [--clear] [--sender] [--image] [--timeout] [--json] [--force]
8
8
  // test scenario <org_id> <file.json> [--agent] [--no-clear-between] [--json] [--sender] [--timeout]
9
9
  // test qa <org_id> [--agent] ... (bundled generic smoke pack)
10
- // test clear <org_id> [--agent]
10
+ // test clear <org_id> [--agent] [--force]
11
11
  // test stress <org_id> [pack.json] [--agent] [--concurrency 6] [--timeout] [--out] [--contact]
12
12
  // test cleanup <org_id> [--agent] --confirm (deletes stress contacts)
13
13
  // test extract <org_id> [--out] (rebuild transcripts from DB)
@@ -28,7 +28,9 @@ function requireOrg(orgId) {
28
28
  }
29
29
  }
30
30
 
31
- async function runTurn(orgId, { agent, message, image, clear, sender, timeout }) {
31
+ // force: the server refuses send / clear on a test contact that had real
32
+ // (non-web) inbound in the last 6 h, i.e. a live customer conversation.
33
+ async function runTurn(orgId, { agent, message, image, clear, sender, timeout, force }) {
32
34
  return http.post("agent-test", {
33
35
  action: "send",
34
36
  organization_id: orgId,
@@ -38,11 +40,18 @@ async function runTurn(orgId, { agent, message, image, clear, sender, timeout })
38
40
  clear: !!clear,
39
41
  sender,
40
42
  timeout_ms: timeout,
43
+ force: !!force,
41
44
  });
42
45
  }
43
46
 
44
- async function clearConv(orgId, agent) {
45
- return http.post("agent-test", { action: "clear", organization_id: orgId, agent_id: agent });
47
+ async function clearConv(orgId, agent, force) {
48
+ return http.post("agent-test", { action: "clear", organization_id: orgId, agent_id: agent, force: !!force });
49
+ }
50
+
51
+ let warned = false;
52
+ function printWarnings(r) {
53
+ if (warned) return;
54
+ for (const w of r?.warnings || []) { console.error(`⚠ ${w}`); warned = true; }
46
55
  }
47
56
 
48
57
  // A bare string is ONE needle, not a list of characters. Iterating a string
@@ -88,9 +97,11 @@ export async function send(orgId, messageParts, opts = {}) {
88
97
  if (!message) { console.error("usage: test send <org_id> <message...>"); process.exit(1); }
89
98
  let r;
90
99
  try {
91
- r = await runTurn(orgId, { agent: opts.agent, message, image: opts.image, clear: opts.clear, sender: opts.sender, timeout: opts.timeout });
100
+ r = await runTurn(orgId, { agent: opts.agent, message, image: opts.image, clear: opts.clear, sender: opts.sender, timeout: opts.timeout, force: opts.force });
92
101
  } catch (e) { console.error(`Send failed: ${e.message}`); process.exit(1); }
93
102
  if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
103
+ printWarnings(r);
104
+ if (opts.clear && r.previous_memory_cutoff !== undefined) console.log(`# cleared first; previous memory_cutoff: ${r.previous_memory_cutoff ?? "(none)"}`);
94
105
  console.log(`# org=${r.org?.name} agent=${r.agent?.name}${r.agent?.is_active ? " (active)" : " (override)"} contact=${r.contact?.id}`);
95
106
  printTurn({ title: "send", tq: "" }, 0, message, r, []);
96
107
  }
@@ -103,14 +114,15 @@ async function runPack(orgId, pack, opts) {
103
114
  for (let s = 0; s < scenarios.length; s++) {
104
115
  const sc = scenarios[s];
105
116
  if (clearBetween) {
106
- try { await clearConv(orgId, opts.agent); } catch (e) { console.error(` (clear failed: ${e.message})`); }
117
+ try { printWarnings(await clearConv(orgId, opts.agent, opts.force)); } catch (e) { console.error(` (clear failed: ${e.message})`); }
107
118
  }
108
119
  const scResult = { id: sc.id, tq: sc.tq, title: sc.title, turns: [] };
109
120
  for (let t = 0; t < sc.turns.length; t++) {
110
121
  const turn = sc.turns[t];
111
122
  let r;
112
123
  try {
113
- r = await runTurn(orgId, { agent: opts.agent, message: turn.text, sender: opts.sender, timeout: opts.timeout });
124
+ r = await runTurn(orgId, { agent: opts.agent, message: turn.text, sender: opts.sender, timeout: opts.timeout, force: opts.force });
125
+ printWarnings(r);
114
126
  } catch (e) {
115
127
  r = { status: "error", reply: null, tool_calls: [], raw: { error: e.message } };
116
128
  }
@@ -144,10 +156,12 @@ export async function qa(orgId, opts = {}) {
144
156
  export async function clear(orgId, opts = {}) {
145
157
  requireOrg(orgId);
146
158
  let resp;
147
- try { resp = await clearConv(orgId, opts.agent); }
159
+ try { resp = await clearConv(orgId, opts.agent, opts.force); }
148
160
  catch (e) { console.error(`Clear failed: ${e.message}`); process.exit(1); }
161
+ printWarnings(resp);
149
162
  const tools = Number(resp.tools_cleared || 0);
150
163
  console.log(`Cleared web conversation for contact ${resp.contact_id} (history hidden via memory_cutoff; ${tools} remembered tool result(s) set aside in more_data.tool_usage_history_cleared; onboarding/disclaimer state NOT reset).`);
164
+ console.log(`Previous memory_cutoff: ${resp.previous_memory_cutoff ?? "(none)"}${resp.previous_memory_cutoff ? " (to undo, set contacts.memory_cutoff back to it)" : ""}`);
151
165
  }
152
166
 
153
167
  // ---------- phase 2: stress + extract ----------
@@ -320,8 +320,8 @@ export async function resolve(updateId, opts = {}) {
320
320
  process.exit(1);
321
321
  }
322
322
  const status = opts.status || "resolved";
323
- if (!["resolved", "declined"].includes(status)) {
324
- console.error(`Error: --status must be resolved or declined (got "${status}").`);
323
+ if (!["resolved", "declined", "in_progress"].includes(status)) {
324
+ console.error(`Error: --status must be resolved, declined or in_progress (got "${status}").`);
325
325
  process.exit(1);
326
326
  }
327
327
  // R-3: a client-facing note is mandatory on resolve (--yes does NOT waive it).
@@ -332,7 +332,7 @@ export async function resolve(updateId, opts = {}) {
332
332
  }
333
333
 
334
334
  if (!opts.yes) {
335
- console.log(`About to mark update ${updateId} as ${status.toUpperCase()}.`);
335
+ console.log(`About to mark update ${updateId} as ${status.toUpperCase()}${status === "in_progress" ? " (not closed: no resolution stamp)" : ""}.`);
336
336
  if (opts.note) console.log(`The client will read: FlowIQ: ${opts.note}`);
337
337
  if (opts.internal) console.log(`Staff-only note: ${opts.internal}`);
338
338
  const answer = await confirmPrompt("Type y to confirm: ");
@@ -363,6 +363,7 @@ export async function resolve(updateId, opts = {}) {
363
363
  console.log(` title: ${resp.title}`);
364
364
  console.log(` was: ${resp.previous_status}`);
365
365
  console.log(` by: ${resp.resolved_by_email ?? resp.resolved_by}`);
366
+ if (resp.status === "in_progress") console.log(` still open: resolve it later with --note once the fix is proven.`);
366
367
  if (resp.superadmin_comment) console.log(` client: FlowIQ: ${resp.superadmin_comment}`);
367
368
  if (resp.internal_notes) console.log(` internal: ${resp.internal_notes}`);
368
369
  if (resp.already_resolved) console.log(` ⚠ was already terminal (${resp.previous_status}) — note/status overwritten.`);
@@ -61,6 +61,7 @@ export async function list(orgId, opts = {}) {
61
61
  user: opts.user,
62
62
  since: opts.since,
63
63
  until: opts.until,
64
+ before: opts.before,
64
65
  limit: opts.limit,
65
66
  });
66
67
  } catch (e) {
@@ -80,7 +81,10 @@ export async function list(orgId, opts = {}) {
80
81
  }
81
82
 
82
83
  console.log("");
83
- console.log(`${entries.length} entr${entries.length === 1 ? "y" : "ies"} (newest first)`);
84
+ const total = resp.total ?? entries.length;
85
+ console.log(resp.capped
86
+ ? `${entries.length} of ${total.toLocaleString()} matching entries (newest first; limit ${resp.limit}${resp.requested_limit > resp.limit ? `, the most one call returns — you asked for ${resp.requested_limit}` : ""})`
87
+ : `${entries.length} entr${entries.length === 1 ? "y" : "ies"} (newest first)`);
84
88
  console.log("");
85
89
  console.log(` ${pad("WHEN (SAST)", 17)} ${pad("WHO", 24)} ${pad("WHAT", 26)} SUMMARY`);
86
90
  console.log(` ${"-".repeat(17)} ${"-".repeat(24)} ${"-".repeat(26)} ${"-".repeat(40)}`);
@@ -101,6 +105,9 @@ export async function list(orgId, opts = {}) {
101
105
  for (const e of entries) {
102
106
  console.log(` ${e.id} ${e.endpoint} ${e.action}`);
103
107
  }
108
+ if (resp.capped && resp.next_before) {
109
+ console.log(`\n ${(total - entries.length).toLocaleString()} older match(es) not shown: add --before ${resp.next_before}`);
110
+ }
104
111
  console.log("");
105
112
  }
106
113
 
@@ -1935,15 +1935,19 @@ export async function listRemote(orgId, opts = {}) {
1935
1935
  ...(opts.limit ? { limit: Number(opts.limit) } : {}),
1936
1936
  ...(opts.template ? { template: opts.template } : {}),
1937
1937
  ...(opts.since ? { since: opts.since } : {}),
1938
+ ...(opts.minRecipients ? { min_recipients: Number(opts.minRecipients) } : {}),
1938
1939
  });
1939
1940
  } catch (e) { console.error(`Listing failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
1940
1941
  if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
1941
1942
  const rows = resp.broadcasts || [];
1942
- if (!rows.length) { console.log(`No broadcasts found for ${resp.organization_name}${opts.template || opts.since ? " matching the filters" : ""}.`); return; }
1943
+ if (!rows.length) { console.log(`No broadcasts found for ${resp.organization_name}${opts.template || opts.since || opts.minRecipients ? " matching the filters" : ""}.`); return; }
1943
1944
  // Full ids one per row, deliberately — an id you can't paste into `bc status`
1944
1945
  // is useless (the same lesson the audit listing learned).
1945
1946
  const sast = (iso) => { try { return new Date(iso).toLocaleString("sv-SE", { timeZone: "Africa/Johannesburg" }).slice(0, 16); } catch { return iso; } };
1946
- console.log(`Broadcasts — ${resp.organization_name} (${rows.length} shown, newest first; times SAST)`);
1947
+ const shown = resp.capped
1948
+ ? `${rows.length} of ${resp.total.toLocaleString()} shown`
1949
+ : `${rows.length} shown`;
1950
+ console.log(`Broadcasts — ${resp.organization_name} (${shown}${resp.min_recipients ? `, ${resp.min_recipients}+ recipients` : ""}, newest first; times SAST)`);
1947
1951
  for (const b of rows) {
1948
1952
  console.log("");
1949
1953
  console.log(` ${b.id}`);
@@ -1951,6 +1955,9 @@ export async function listRemote(orgId, opts = {}) {
1951
1955
  console.log(` status ${b.status ?? "?"} · ${b.total_recipients ?? "?"} recipient(s)`);
1952
1956
  }
1953
1957
  console.log("");
1958
+ if (resp.capped) {
1959
+ console.log(`⚠ ${(resp.total - rows.length).toLocaleString()} older broadcast(s) not shown (limit ${resp.limit}). Narrow with --since, --template${resp.min_recipients ? "" : " or --min-recipients 2 (hides one-recipient sends like welcomes and cart reminders)"}.`);
1960
+ }
1954
1961
  console.log(`Delivery detail: flowiq bc status ${orgId} <broadcast_id> [--failures]`);
1955
1962
  }
1956
1963
 
@@ -1984,6 +1991,15 @@ export async function status(orgId, broadcastId, opts = {}) {
1984
1991
  if (b.total_recipients) {
1985
1992
  console.log(` ${((accepted / b.total_recipients) * 100).toFixed(1)}% of ${b.total_recipients} recipients accepted${d.linked < b.total_recipients ? " (still sending?)" : ""}`);
1986
1993
  }
1994
+ const p = resp.payload;
1995
+ if (p && (Object.keys(p.body_parameters || {}).length || Object.keys(p.button_parameters || {}).length || Object.keys(p.header_parameters || {}).length)) {
1996
+ console.log(` sent with:`);
1997
+ const slot = (k) => String(k).replace(/^param/, "{{") + (String(k).startsWith("param") ? "}}" : "");
1998
+ for (const [k, v] of Object.entries(p.header_parameters || {})) console.log(` header ${slot(k)} ${String(v).replace(/\s+/g, " ")}`);
1999
+ for (const [k, v] of Object.entries(p.body_parameters || {})) console.log(` body ${slot(k)} ${String(v).replace(/\s+/g, " ")}`);
2000
+ for (const [k, v] of Object.entries(p.button_parameters || {})) console.log(` button ${slot(k)} ${v}`);
2001
+ for (const l of p.links || []) console.log(` link ${l.short_code} → ${l.destination} (${l.clicks} click${l.clicks === 1 ? "" : "s"})`);
2002
+ }
1987
2003
  if (resp.reply_routing) {
1988
2004
  const rr = resp.reply_routing;
1989
2005
  console.log(` replies → ${describeRouting(rr)}${rr.enabled === false ? " (switched OFF)" : ""}`);
@@ -101,10 +101,19 @@ export async function doctor(opts = {}) {
101
101
  }
102
102
  } catch (e) {
103
103
  lines.push(` ${BAD} auth ${e.message.split("\n")[0]}`);
104
- problems.push({
105
- what: "the API rejected this key",
106
- fix: e.status === 401 || e.status === 403 ? "flowiq auth login" : "check api_url / network, then retry",
107
- });
104
+ if (e.code === "EVERCELCHALLENGE") {
105
+ problems.push({
106
+ what: "Vercel's bot protection is challenging this network; the key was never checked",
107
+ fix: "wait a few minutes or switch network (hotspot), then flowiq doctor again; do NOT re-login",
108
+ });
109
+ } else {
110
+ // Only a JSON 401/403 from our API means the key itself was refused.
111
+ const keyRefused = (e.status === 401 || e.status === 403) && e.code !== "ENONJSON";
112
+ problems.push({
113
+ what: keyRefused ? "the API rejected this key" : "the API could not be reached properly",
114
+ fix: keyRefused ? "flowiq auth login" : "check api_url / network, then retry",
115
+ });
116
+ }
108
117
  }
109
118
  }
110
119
 
@@ -244,7 +244,7 @@ export async function show(ref, opts = {}) {
244
244
  }
245
245
  }
246
246
  console.log(`\nSame problem? flowiq gaps hit ${r.number} --note "what you were doing"`);
247
- console.log(`Maintainers (${(resp.editors || []).join(", ")}): flowiq gaps status ${r.number} --to done --version <x.y.z> --note "…"`);
247
+ console.log(`Maintainers (${(resp.editors || []).join(", ")}): flowiq gaps status ${r.number} --to done --fixed-in <x.y.z> --note "…"`);
248
248
  }
249
249
 
250
250
  // ── hit ─────────────────────────────────────────────────────────────────────
@@ -324,7 +324,7 @@ export async function status(ref, opts = {}) {
324
324
  ref: r0,
325
325
  status: to,
326
326
  note: opts.note ?? null,
327
- version: opts.version ?? null,
327
+ version: opts.fixedIn ?? null,
328
328
  duplicate_of: dupRef,
329
329
  notify: opts.notify !== false,
330
330
  });
@@ -37,6 +37,32 @@ async function resolvePushPath(identifier) {
37
37
  throw new Error(`File not found. Tried:\n ${candidates.join("\n ")}`);
38
38
  }
39
39
 
40
+ const sastDay = (iso) => {
41
+ try { return new Date(iso).toLocaleString("sv-SE", { timeZone: "Africa/Johannesburg" }).slice(0, 16); }
42
+ catch { return String(iso).slice(0, 16); }
43
+ };
44
+
45
+ // The keyword-scheduler cron (every 30 min) flips `active` on at start_date and
46
+ // off at end_date; the runtime itself reads `active` only. So an ACTIVE row with
47
+ // a future start is answering already, and an active row past its end has not
48
+ // been switched off yet.
49
+ export function scheduleLabel(k, now = Date.now()) {
50
+ const start = k.start_date ? Date.parse(k.start_date) : null;
51
+ const end = k.end_date ? Date.parse(k.end_date) : null;
52
+ const parts = [];
53
+ if (start && start > now) {
54
+ parts.push(k.active === false
55
+ ? ` · starts ${sastDay(k.start_date)} SAST`
56
+ : ` · ⚠ LIVE BEFORE ITS START (${sastDay(k.start_date)} SAST): active, so it answers now`);
57
+ }
58
+ if (end) {
59
+ parts.push(end <= now && k.active !== false
60
+ ? ` · ⚠ PAST ITS END (${sastDay(k.end_date)} SAST) but still active`
61
+ : ` · ends ${sastDay(k.end_date)}${end > now ? " SAST" : ""}`);
62
+ }
63
+ return parts.join("");
64
+ }
65
+
40
66
  export async function pull(orgId) {
41
67
  if (!UUID_RE.test(orgId)) {
42
68
  console.error(`Error: "${orgId}" is not a valid organization UUID.`);
@@ -59,9 +85,24 @@ export async function pull(orgId) {
59
85
  console.log(`${overwriting ? "Overwrote" : "Wrote"} ${filePath}`);
60
86
  console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
61
87
  console.log(` keywords: ${resp.keyword_count}`);
62
- for (const k of resp.keywords || []) {
88
+ // Active rows first, in the order the runtime tries them (first match wins),
89
+ // then the inactive ones.
90
+ const all = resp.keywords || [];
91
+ const order = resp.runtime_order || [];
92
+ const byId = new Map(all.map((k) => [k.id, k]));
93
+ const listed = [
94
+ ...order.map((id) => byId.get(id)).filter(Boolean),
95
+ ...all.filter((k) => !order.includes(k.id)),
96
+ ];
97
+ if (order.length) console.log(` (active rows in runtime order: the first matching row answers)`);
98
+ for (const k of listed) {
63
99
  const acts = (k.keyword_actions || []).length;
64
- console.log(` ${String(k.type).padEnd(28)} [${(k.keywords || []).join(", ")}] · ${acts} action(s)${k.active === false ? " · INACTIVE" : ""}${k.end_date ? ` · ends ${k.end_date.slice(0, 10)}` : ""}`);
100
+ console.log(` ${String(k.type).padEnd(28)} [${(k.keywords || []).join(", ")}] · ${acts} action(s)${k.active === false ? " · INACTIVE" : ""}${scheduleLabel(k)}`);
101
+ }
102
+ for (const c of resp.collisions || []) {
103
+ console.log("");
104
+ console.log(` ⚠ TRIGGER COLLISION: ${c.triggers.map((t) => `"${t}"`).join(", ")} matches both "${c.winner.type}" and "${c.shadowed.type}".`);
105
+ console.log(` ${c.note}.`);
65
106
  }
66
107
  if (resp.has_attribute_actions) {
67
108
  console.log("");
@@ -115,6 +115,7 @@ export async function shorten(orgId, opts = {}) {
115
115
  content,
116
116
  domain: opts.domain,
117
117
  dry_run: dryRun,
118
+ confirm_new_campaign: !!opts.newCampaign,
118
119
  });
119
120
  } catch (e) {
120
121
  console.error(`Shorten failed: ${e.message}`);
@@ -128,6 +129,13 @@ export async function shorten(orgId, opts = {}) {
128
129
  : "no UTM tags (plain short link)";
129
130
  console.log(`${resp.organization_name} · ${resp.domain}`);
130
131
  console.log(`${utm}\n`);
132
+ if (resp.campaign_conflicts?.length) {
133
+ const c = resp.campaign_conflicts[0];
134
+ console.log(`⚠ CAMPAIGN FORK: this org already runs ${resp.campaign_conflicts.map((x) => `"${x.campaign}" (${x.links} link${x.links === 1 ? "" : "s"})`).join(", ")},`);
135
+ console.log(` the same campaign as "${resp.utm?.campaign}" spelled differently. The Broadcasts tab would show two rows.`);
136
+ console.log(` Reuse it: --campaign ${c.campaign} --raw-campaign or confirm a separate campaign: --new-campaign`);
137
+ console.log("");
138
+ }
131
139
 
132
140
  for (const r of resp.results) {
133
141
  const label = {
@@ -5,6 +5,7 @@
5
5
 
6
6
  import fs from "node:fs/promises";
7
7
  import path from "node:path";
8
+ import { randomUUID } from "node:crypto";
8
9
  import { http } from "../http.js";
9
10
  import { fmtSast } from "./audit.js";
10
11
 
@@ -54,15 +55,16 @@ export async function pull(orgId, opts = {}) {
54
55
  process.exit(1);
55
56
  }
56
57
 
57
- await fs.mkdir(PROMPTS_DIR, { recursive: true });
58
58
  // Non-active agents get an agent-suffixed filename so pulling multiple agents
59
59
  // from the same org doesn't clobber each other's working copy. The active
60
- // agent keeps the plain <org>.json name (backwards-compatible).
60
+ // agent keeps the plain <org>.json name (backwards-compatible). --out writes
61
+ // anywhere else (a scratch copy that leaves the working file alone).
61
62
  const orgSlug = slugify(resp.organization_name, resp.organization_id);
62
63
  const slug = resp.is_active === false
63
64
  ? `${orgSlug}-${slugify(resp.agent_name, resp.agent_id.slice(0, 8))}`
64
65
  : orgSlug;
65
- const filePath = path.join(PROMPTS_DIR, `${slug}.json`);
66
+ const filePath = opts.out ? path.resolve(opts.out) : path.join(PROMPTS_DIR, `${slug}.json`);
67
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
66
68
  const overwriting = await fileExists(filePath);
67
69
  await fs.writeFile(filePath, JSON.stringify(resp, null, 2) + "\n", "utf8");
68
70
 
@@ -82,6 +84,18 @@ export async function pull(orgId, opts = {}) {
82
84
  }
83
85
  }
84
86
 
87
+ /** Give every section without an id a fresh uuid. Returns the titles it touched. */
88
+ export function mintSectionIds(sections) {
89
+ const touched = [];
90
+ for (const sec of sections) {
91
+ if (sec && typeof sec === "object" && (typeof sec.id !== "string" || !sec.id.trim())) {
92
+ sec.id = randomUUID();
93
+ touched.push(sec.title || "(untitled)");
94
+ }
95
+ }
96
+ return touched;
97
+ }
98
+
85
99
  export async function push(identifier) {
86
100
  let filePath;
87
101
  try {
@@ -107,6 +121,14 @@ export async function push(identifier) {
107
121
  process.exit(1);
108
122
  }
109
123
 
124
+ // A section added by hand has no id yet. Mint one and write it back to the
125
+ // file BEFORE pushing, so a later pull or push keeps the same id.
126
+ const minted = mintSectionIds(data.prompt_sections);
127
+ if (minted.length) {
128
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2) + "\n", "utf8");
129
+ console.log(`Added an id to ${minted.length} new section(s) in ${filePath}: ${minted.map((t) => `"${t}"`).join(", ")}`);
130
+ }
131
+
110
132
  let resp;
111
133
  try {
112
134
  resp = await http.post("prompts", {
@@ -265,6 +265,45 @@ async function rawGet(orgId, path, query, { label } = {}) {
265
265
  return resp.data;
266
266
  }
267
267
 
268
+ // Shopify's own product search (title, SKU, vendor, tags…), so a partial title
269
+ // on a 3,594-product catalogue is found without scanning pages. Anything
270
+ // Shopify's search syntax takes works here too: "sku:75461", "handle:foo".
271
+ const STOCK_SEARCH_GQL = `query StockSearch($q: String!) {
272
+ products(first: 50, query: $q) {
273
+ edges { node {
274
+ legacyResourceId title status
275
+ variants(first: 100) { edges { node {
276
+ legacyResourceId title sku price inventoryPolicy inventoryQuantity
277
+ inventoryItem { legacyResourceId }
278
+ } } }
279
+ } }
280
+ }
281
+ }`;
282
+
283
+ async function searchProducts(orgId, search) {
284
+ console.error(` $ flowiq shopify gql <org> --query '${STOCK_SEARCH_GQL.replace(/\s+/g, " ")}' --var q=${JSON.stringify(search)} # Shopify product search`);
285
+ const resp = await http.post("store-api", {
286
+ platform: "shopify", organization_id: orgId, mode: "graphql",
287
+ graphql: { query: STOCK_SEARCH_GQL, variables: { q: search } },
288
+ });
289
+ if (resp.graphql_errors?.length) throw new Error(`Shopify search failed: ${resp.graphql_errors.map((e) => e.message).join("; ")}`);
290
+ // Map to the REST shape the rest of the recipe reads.
291
+ return (resp.data?.products?.edges || []).map(({ node: p }) => ({
292
+ id: Number(p.legacyResourceId),
293
+ title: p.title,
294
+ status: String(p.status || "").toLowerCase(),
295
+ variants: (p.variants?.edges || []).map(({ node: v }) => ({
296
+ id: Number(v.legacyResourceId),
297
+ title: v.title,
298
+ sku: v.sku,
299
+ price: v.price,
300
+ inventory_policy: String(v.inventoryPolicy || "").toLowerCase(),
301
+ inventory_quantity: v.inventoryQuantity,
302
+ inventory_item_id: v.inventoryItem?.legacyResourceId ? Number(v.inventoryItem.legacyResourceId) : null,
303
+ })),
304
+ }));
305
+ }
306
+
268
307
  export async function shopifyStock(orgId, search, opts = {}) {
269
308
  requireOrg(orgId);
270
309
  if (!search || !String(search).trim()) {
@@ -278,17 +317,22 @@ export async function shopifyStock(orgId, search, opts = {}) {
278
317
  const p = await rawGet(orgId, "products.json", { title: search, limit: 50 }, { label: "find the product" });
279
318
  products = (p?.products || []).filter((x) =>
280
319
  String(x.title).toLowerCase().includes(String(search).toLowerCase()));
320
+ let fromSearch = false;
281
321
  if (!products.length) {
282
- const all = await rawGet(orgId, "products.json", { limit: 250, fields: "id,title,variants,status" },
283
- { label: "no title match — scanning the catalogue" });
284
- products = (all?.products || []).filter((x) =>
285
- String(x.title).toLowerCase().includes(String(search).toLowerCase()));
322
+ // No exact title: ask Shopify's search, then keep the titles holding every
323
+ // word of the search (or, when none do, what Shopify ranked closest).
324
+ const found = await searchProducts(orgId, String(search));
325
+ const words = String(search).toLowerCase().split(/\s+/).filter((w) => w && !w.includes(":"));
326
+ const strict = found.filter((x) => words.every((w) => String(x.title).toLowerCase().includes(w)));
327
+ products = strict.length ? strict : found;
328
+ fromSearch = products.length > 0 && !strict.length && words.length > 0;
286
329
  }
287
330
  if (!products.length) {
288
- console.error(`\nNothing matched "${search}". This searches product TITLES only — ` +
289
- `the passthrough can query anything else Shopify exposes.`);
331
+ console.error(`\nShopify's product search found nothing for "${search}" (it searches the whole catalogue: ` +
332
+ `title, SKU, vendor, tags). Try fewer words, or "sku:<sku>" / "handle:<handle>".`);
290
333
  process.exit(1);
291
334
  }
335
+ if (fromSearch) console.error(` (no title holds every word of "${search}"; showing Shopify's closest matches)`);
292
336
  const itemIds = products.flatMap((x) => (x.variants || []).map((v) => v.inventory_item_id)).filter(Boolean);
293
337
  // 2. inventory_item_id → per-location levels 3. location_id → names
294
338
  levels = itemIds.length
@@ -335,6 +379,9 @@ export async function shopifyStock(orgId, search, opts = {}) {
335
379
  }
336
380
  console.error("");
337
381
  console.log(renderTable(rows) ?? "(no variants)");
382
+ if (products.flatMap((x) => x.variants || []).length > 50) {
383
+ console.error(`\n⚠ per-location levels cover the first 50 variants only; narrow the search for the rest.`);
384
+ }
338
385
  console.error(`\n${products.length} product(s) · ${rows.length} variant(s). ` +
339
386
  `"not tracked" means Shopify holds no inventory record at that location — it is not a zero.`);
340
387
  }
@@ -91,6 +91,10 @@ export async function create(orgId, opts = {}) {
91
91
  } catch (e) {
92
92
  console.error(`Create failed: ${e.message}`);
93
93
  if (e.body) console.error(JSON.stringify(e.body, null, 2));
94
+ if (e.status === 504 || /TIMEOUT/i.test(e.message)) {
95
+ console.error(`\n⚠ The template may already be created at Meta (the upload can outlive the proxy). Do NOT retry yet; check first:`);
96
+ console.error(` flowiq templates status ${organizationId} --live --name ${templateRequest.name}`);
97
+ }
94
98
  process.exit(1);
95
99
  }
96
100
 
@@ -103,6 +107,8 @@ export async function status(orgId, opts = {}) {
103
107
  console.error(`Error: "${orgId}" is not a valid organization UUID.`);
104
108
  process.exit(1);
105
109
  }
110
+ if (opts.watch && !opts.live) opts.live = true;
111
+ if (opts.live) return liveStatus(orgId, opts);
106
112
  let resp;
107
113
  try {
108
114
  resp = await http.get("meta-templates", { organization_id: orgId, name: opts.name });
@@ -112,18 +118,55 @@ export async function status(orgId, opts = {}) {
112
118
  }
113
119
  const rows = resp.templates || [];
114
120
  if (!rows.length) {
115
- console.log(opts.name ? `(no templates matching "${opts.name}")` : "(no templates on record for this org)");
121
+ // An empty FlowIQ table is not "no templates": templates made in Meta's
122
+ // own tools, or on a swapped WABA, exist only at Meta (Health Matrix, 22 Sep).
123
+ console.log(opts.name
124
+ ? `No template matching "${opts.name}" is recorded in FlowIQ. Meta may still hold one: flowiq templates status ${orgId} --live --name ${opts.name}`
125
+ : `No templates are recorded in FlowIQ for this org. Meta may still hold some: flowiq templates status ${orgId} --live (or templates pull ${orgId}).`);
116
126
  return;
117
127
  }
118
- console.log(`${rows.length} template row${rows.length === 1 ? "" : "s"}${opts.name ? ` matching "${opts.name}"` : ""}:`);
128
+ console.log(`${rows.length} template row${rows.length === 1 ? "" : "s"}${opts.name ? ` matching "${opts.name}"` : ""} recorded in FlowIQ:`);
119
129
  for (const t of rows) {
120
130
  const name = t.template_name || t.name || "(unnamed)";
121
- const st = t.status || "(no status)";
131
+ // meta_status is what Meta's status webhook last reported; `status` is often
132
+ // left at its submit-time value.
133
+ const st = t.meta_status || t.status || "(no status)";
122
134
  const cat = t.category ? ` [${t.category}]` : "";
123
135
  console.log(` ${name.padEnd(40)} ${String(st).padEnd(16)}${cat}`);
124
136
  }
125
- console.log("\nNote: status is captured at submit time (older rows may be null). For the live");
126
- console.log("Meta-side status + rejection reasons, run `templates pull <org_id>`.");
137
+ console.log(`\nThis is FlowIQ's record (Meta's webhook keeps it current). Straight from Meta, with rejection reasons:`);
138
+ console.log(` flowiq templates status ${orgId} --live${opts.name ? ` --name ${opts.name}` : ""} [--watch]`);
139
+ }
140
+
141
+ const TERMINAL = new Set(["APPROVED", "REJECTED", "DISABLED", "PAUSED", "DELETED", "LIMIT_EXCEEDED"]);
142
+
143
+ async function liveStatus(orgId, opts) {
144
+ const intervalMs = 15000, maxMs = 15 * 60 * 1000, started = Date.now();
145
+ for (;;) {
146
+ let resp;
147
+ try {
148
+ resp = await http.get("templates", { organization_id: orgId, name: opts.name });
149
+ } catch (e) {
150
+ console.error(`Live status failed: ${e.message}`);
151
+ process.exit(1);
152
+ }
153
+ const rows = resp.templates || [];
154
+ const stamp = new Date().toLocaleTimeString("en-GB", { timeZone: "Africa/Johannesburg" });
155
+ if (!rows.length) {
156
+ console.log(`${stamp} Meta holds no template${opts.name ? ` matching "${opts.name}"` : "s"} on ${resp.organization_name}'s WABA.`);
157
+ } else {
158
+ console.log(`${stamp} ${rows.length} template${rows.length === 1 ? "" : "s"} at Meta (${resp.organization_name})${opts.name ? ` matching "${opts.name}"` : ""}:`);
159
+ for (const t of rows) {
160
+ const moved = t.previous_category && t.previous_category !== t.category ? ` (was ${t.previous_category})` : "";
161
+ const why = t.rejected_reason && t.rejected_reason !== "NONE" ? ` reason: ${t.rejected_reason}` : "";
162
+ console.log(` ${String(t.name).padEnd(40)} ${String(t.status).padEnd(16)} [${t.category}${moved}] ${t.language}${t.parameter_format ? ` · ${t.parameter_format}` : ""}${why}`);
163
+ }
164
+ }
165
+ const pending = rows.filter((t) => !TERMINAL.has(String(t.status)));
166
+ if (!opts.watch || (rows.length && !pending.length)) return;
167
+ if (Date.now() - started > maxMs) { console.log(`Still not decided after 15 min; stopped watching.`); return; }
168
+ await new Promise((r) => setTimeout(r, intervalMs));
169
+ }
127
170
  }
128
171
 
129
172
  export async function list() {
package/src/http.js CHANGED
@@ -70,7 +70,7 @@ export function isBelowServerMinimum() {
70
70
  * serialised — and always name the HTTP status, since a 504 tells you "retry or
71
71
  * narrow it" while a 400 never will.
72
72
  */
73
- export function describeError(parsed, method, endpoint, status) {
73
+ export function describeError(parsed, method, endpoint, status, { json = true } = {}) {
74
74
  const pick = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
75
75
  const nested = (v) => {
76
76
  if (!v || typeof v !== "object") return null;
@@ -95,12 +95,27 @@ export function describeError(parsed, method, endpoint, status) {
95
95
  if (!detail) return where;
96
96
 
97
97
  // A timeout is transient and the remedy is specific, so say so rather than
98
- // leaving the reader to work out whether they hit a hard limit.
99
- const timedOut = status === 504 || /TIMEOUT|timed out/i.test(detail);
100
- return timedOut
101
- ? `${detail} [${where}] — this is a TIMEOUT, not a limit: retry, or narrow the request ` +
102
- `(fewer pages via --max-pages, a smaller --q limit, or a tighter filter).`
103
- : `${detail} [${where}]`;
98
+ // leaving the reader to work out whether they hit a hard limit. Only a JSON
99
+ // body is scanned for the word: an HTML page (a Vercel challenge) carries
100
+ // "timeout" in its inline script and is not one.
101
+ const timedOut = status === 504 || (json && /TIMEOUT|timed out/i.test(detail));
102
+ if (!timedOut) return `${detail} [${where}]`;
103
+ // A write can finish on the server after the proxy gave up, so "retry" is the
104
+ // wrong first move there (a template created twice, a send fired twice).
105
+ if (method !== "GET" && endpoint !== "store-api") {
106
+ return `${detail} [${where}] — this is a TIMEOUT: the server may still have finished the write. ` +
107
+ `Check the result (the command's list / status / pull verb, or flowiq audit) before retrying.`;
108
+ }
109
+ return `${detail} [${where}] — this is a TIMEOUT, not a limit: retry, or narrow the request ` +
110
+ `(fewer pages via --max-pages, a smaller --q limit, or a tighter filter).`;
111
+ }
112
+
113
+ /** Vercel's bot protection answers with an HTML challenge page (403/429),
114
+ * before the request ever reaches our API. It is about this network, not the key. */
115
+ export function isVercelChallenge(status, headers, text) {
116
+ const mitigated = headers?.get?.("x-vercel-mitigated") || "";
117
+ if (/challenge/i.test(mitigated)) return true;
118
+ return (status === 403 || status === 429) && /Vercel Security Checkpoint|vercel-challenge|_vercel\/challenge/i.test(text || "");
104
119
  }
105
120
 
106
121
  async function call(method, endpoint, { query, body } = {}) {
@@ -138,8 +153,19 @@ async function call(method, endpoint, { query, body } = {}) {
138
153
  const text = await resp.text();
139
154
  let parsed, isJson = true;
140
155
  try { parsed = text ? JSON.parse(text) : {}; } catch { parsed = { _raw: text }; isJson = false; }
156
+ if (!resp.ok && isVercelChallenge(resp.status, resp.headers, text)) {
157
+ const err = new Error(
158
+ `Vercel is challenging this network (bot protection) [${method} ${endpoint} → HTTP ${resp.status}]. ` +
159
+ `This is not your key and the request never reached the API: wait a few minutes, or try another ` +
160
+ `network (a phone hotspot), then retry.`
161
+ );
162
+ err.status = resp.status;
163
+ err.code = "EVERCELCHALLENGE";
164
+ err.body = parsed;
165
+ throw err;
166
+ }
141
167
  if (!resp.ok) {
142
- const err = new Error(describeError(parsed, method, endpoint, resp.status));
168
+ const err = new Error(describeError(parsed, method, endpoint, resp.status, { json: isJson }));
143
169
  err.status = resp.status;
144
170
  err.body = parsed;
145
171
  throw err;