@flowapt/flowiq-cli 0.4.5 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -84,6 +84,11 @@ Notes:
84
84
  push using the `## SECTION: <title>` format.
85
85
  - Sections support optional `hidden: boolean` + `channels: string[]`
86
86
  (`web` / `whatsapp` / `messenger` / `instagram`) — validated on push.
87
+ - **Time-boxed sections:** optional `active_from` / `active_until`
88
+ (`"YYYY-MM-DD"` or `"YYYY-MM-DDTHH:mm"`) + `active_tz` (IANA, default
89
+ `Africa/Johannesburg`). Outside the window the section is skipped at
90
+ prompt assembly server-side — an expired promo needs NO cleanup push.
91
+ Validated on push (format, from ≤ until, real timezone).
87
92
  - For legacy agents without a sections array, pull auto-bootstraps a
88
93
  single "Main" section from `settings.system_prompt` and returns
89
94
  `bootstrapped: true`. The CLI prints a heads-up before push.
@@ -256,6 +261,16 @@ flowiq bc scheduled cancel <org_id> <queue_id> --confirm
256
261
  the abort message says so — fix by passing `--header-media <real video>`.
257
262
  `map` refuses to save a mismatched default into the campaign file. An
258
263
  unreachable URL only warns (`media unverified`), never blocks.
264
+ - **One campaign = one template (v0.4.6).** A campaign's saved mapping, header
265
+ media and write-ahead status log (dedup) all belong to the template it was
266
+ built for. Running `bc send` / `map` with a **different `--template` on an
267
+ existing `--campaign`** now **ABORTS** (`Campaign "X" was built for template
268
+ A …`) — reusing the slug would silently send A's header media and skip the
269
+ recipients A already reached (the header-media check only catches a media-TYPE
270
+ change, not a same-type creative swap). Use a fresh `--campaign` for a new
271
+ template. In **tag mode the campaign slug defaults to the tag**, so to send
272
+ template A then template B to the SAME tag, pass a distinct `--campaign` for
273
+ each. `resume` passes no `--template`, so it is never affected.
259
274
  - **`list-remote <org>` (v0.3.9)**: list the org's broadcasts **newest-first** with
260
275
  the **full broadcastId** per row + template, status, recipient count and SAST
261
276
  created time — the discovery step `status` / `retry` need (previously the id
@@ -853,8 +868,36 @@ Looking up stock for "1 litre Oil Pourer" — the calls being made:
853
868
  `stock` prints **"not tracked"** where Shopify returns `available: null` — that
854
869
  means no inventory record exists at that location, which is not the same as zero.
855
870
 
871
+ #### Counting things — read `precision` before you trust a number
872
+
873
+ Shopify's `*Count` fields return `{count, precision}`. **`precision: "AT_LEAST"`
874
+ means the number is a CAP (10,000), not an answer.** The CLI now warns on stderr
875
+ whenever a capped count comes back, because it is very easy to read 10,000 as a
876
+ real total.
877
+
878
+ **`customersCount` additionally IGNORES its `query:` filter.** Verified 4 Aug 2026
879
+ straight against Shopify with no FlowIQ layer involved, on API versions 2024-01,
880
+ 2024-10 and 2025-01: an impossible email filter still returns `10000 / AT_LEAST`.
881
+ Its sibling `ordersCount` honours the same argument and returns
882
+ `1372 / EXACT`, so this is specific to that field, not a general limit. **This is
883
+ Shopify's behaviour, not the CLI's** — but sizing an audience with it gives you a
884
+ plausible, wrong number, so the CLI calls it out.
885
+
886
+ Count a filtered customer cohort with the connection instead, which filters
887
+ correctly, and paginate:
888
+
889
+ ```bash
890
+ flowiq shopify gql <org> -q 'query { customers(first: 250, query: "orders_count:>=20") {
891
+ edges { node { id } } pageInfo { hasNextPage endCursor } } }'
892
+ ```
893
+
856
894
  Notes:
857
895
 
896
+ * **A long `--all` can TIME OUT rather than truncate.** The server budgets ~25s and
897
+ then returns a partial result, but a very wide pull can still exceed the function
898
+ limit. You now get an explicit timeout message naming the remedy (retry, fewer
899
+ `--max-pages`, a smaller `--q limit`) — previously this surfaced as
900
+ `Request failed: [object Object]`, which read like a hard limit and wasn't.
858
901
  * **`--all` is bounded and says so.** It stops at `--max-pages`, at a ~3.5MB
859
902
  response size, or at a time budget, and then reports `⚠ TRUNCATED — <reason>`
860
903
  on stderr. A partial result is never presented as a complete one.
package/TEAM-GUIDE.md CHANGED
@@ -83,6 +83,8 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
83
83
  | **Find an org's ID** (needed by nearly every command) | `flowiq org list` — or `flowiq org list african` to filter |
84
84
  | What's the stock on a product, per branch? | `flowiq shopify stock <org_id> "olive oil"` — shows each location by NAME, and says "not tracked" rather than a confusing 0 |
85
85
  | Has this order shipped? What's the tracking? | `flowiq shopify order <org_id> '#14728'` — status, courier, tracking number + link |
86
+ | **Size a customer cohort** | Use `customers(first:250, query:…)` and paginate — **NOT `customersCount(query:…)`, which Shopify ignores the filter on** and answers 10,000 every time. Any count showing `precision: AT_LEAST` is a cap, not a total; the CLI warns you. |
87
+ | A big `--all` failed with a timeout | It's transient, not a limit — retry, or narrow it with `--max-pages` / a smaller `--q limit`. The message now says so. |
86
88
  | Ask a client's Shopify store anything (read-only) | `flowiq shopify get <org_id> orders.json --q status=any --q limit=250` — any Admin REST endpoint; writes are refused |
87
89
  | Ask a client's Shopify store something REST can't express | `flowiq shopify gql <org_id> -q 'query { shop { name } }'` — you write the GraphQL; any `mutation` is refused |
88
90
  | Ask a client's WooCommerce store anything (read-only) | `flowiq woo get <org_id> orders --q status=completed --all` |
@@ -104,7 +106,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
104
106
  | Split a whole tagged audience into batches of N (e.g. 90k → 7000s) | `flowiq seg plan <org_id> --tag-prefix 3-aug-bc --from-tag "3-aug-bc" --batch-size 7000` → `flowiq seg apply <org_id> 3-aug-bc --commit --yes` (makes `…-batch-01…13`; works at any size — big applies are chunked internally) |
105
107
  | Schedule a broadcast for later instead of sending now | `flowiq bc send <org_id> --tag <batch-tag> --template <name> --at "2026-08-05 09:00" --commit` (time is SAST; fires on its own; audience resolved at send time) |
106
108
  | See / approve / cancel what's scheduled | `flowiq bc scheduled list <org_id>` → `flowiq bc scheduled approve <org_id> <queue_id>` or `flowiq bc scheduled cancel <org_id> <queue_id> --confirm` |
107
- | Send to one batch tag | `flowiq bc send <org_id> --tag <batch-tag> --template … --body param1="Hi {{first_name}}" --commit` (per-contact tokens: the 6 contact fields + `{{attributes.<key>}}`) |
109
+ | Send to one batch tag | `flowiq bc send <org_id> --tag <batch-tag> --template … --body param1="Hi {{first_name}}" --commit` (per-contact tokens: the 6 contact fields + `{{attributes.<key>}}`). Sending a **different template to the SAME tag**? Add a distinct `--campaign <name>` — otherwise the CLI aborts (a campaign belongs to one template; reusing it would send the first template's image + skip everyone it already reached). |
108
110
  | Send a broadcast whose template has an IMAGE/VIDEO/DOCUMENT header | Same as above — the media is automatic (the template's own stored header). Override with `--header-media <public-url>` if needed. The CLI verifies the resolved media's actual type against the header format — `header video (video ✓ video/mp4)` means verified; a mismatch (e.g. a video template whose stored default is secretly a png — templates made before 3 Aug 2026 can carry this) ABORTS and tells you to pass `--header-media` with the real file. |
109
111
  | Send via the SAME engine as the dashboard's "Python" toggle | add `--python` to a `bc send --tag …` (fire-and-forget; python resolves the tag + sends + tracks; no CLI resume for this engine). **Any tag send over 10 recipients uses python automatically.** |
110
112
  | Find a broadcast's id (don't have the `broadcastId`?) | `flowiq bc list-remote <org_id>` — the org's broadcasts newest-first with full ids (`--template <substr>` / `--since <date>` / `--limit <n>` to narrow) |
@@ -141,6 +143,12 @@ flowiq prompts push phytoceutics
141
143
  flowiq test send 08becb51-… "question that exercises your change"
142
144
  ```
143
145
 
146
+ **Scheduling a promo section:** add `"active_from": "2026-08-07"`,
147
+ `"active_until": "2026-08-10"` (and optionally `"active_tz": "Europe/Lisbon"`,
148
+ default is SAST) to a section in the JSON and push. The AI only sees the
149
+ section inside that window — when the promo ends it disappears by itself,
150
+ no cleanup push needed. Date-only values mean start-of-day / end-of-day.
151
+
144
152
  ### Example: safely change custom tools
145
153
 
146
154
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, fine-tuning, pin-board tasks, webhooks, templates, agent-updates, chat exports, and live agent testing without ever touching service-role credentials.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -563,6 +563,13 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
563
563
  let cfg = await loadCampaign(campaign);
564
564
  const templateName = opts.template || cfg?.template_name;
565
565
  if (!templateName) { console.error("Error: --template required (or an existing campaign config)."); process.exit(1); }
566
+ // Same guard as runTagPipeline: a campaign's saved mapping, header media and status-log
567
+ // dedup all belong to the template it was built for. A different --template on the same
568
+ // --campaign must use a fresh slug, not silently inherit them. (resume passes no --template.)
569
+ if (cfg && opts.template && cfg.template_name && cfg.template_name !== opts.template) {
570
+ console.error(`Campaign "${campaign}" was built for template ${cfg.template_name}, but --template ${opts.template} was passed — reusing it would inherit that mapping/header media and skip contacts it already reached. Use a different --campaign for ${opts.template}.`);
571
+ process.exit(1);
572
+ }
566
573
  const csvPath = opts.csv || cfg?.csv?.path;
567
574
  if (!csvPath) { console.error("Error: --csv required (or an existing campaign config)."); process.exit(1); }
568
575
 
@@ -837,6 +844,16 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
837
844
  const tag = opts.tag || cfg?.tag;
838
845
  const templateName = opts.template || cfg?.template_name;
839
846
  if (!tag || !templateName) { console.error("Error: --tag and --template required (or an existing tag campaign config)."); process.exit(1); }
847
+ // A tag campaign owns a status log (dedup) and any saved header media, BOTH tied to the
848
+ // template it was built for. Reusing this --campaign slug with a different --template would
849
+ // silently send the old template's header media and skip the recipients it already reached
850
+ // — and the header-media check only catches a media-TYPE change (image→video), not a
851
+ // same-type creative swap. A new template needs its own --campaign. (resume passes no
852
+ // --template, so opts.template is undefined there and this never fires.)
853
+ if (cfg && opts.template && cfg.template_name && cfg.template_name !== opts.template) {
854
+ console.error(`Campaign "${campaign}" was built for template ${cfg.template_name}, but --template ${opts.template} was passed — reusing it would send ${cfg.template_name}'s header media and skip contacts it already reached. Use a different --campaign for ${opts.template}.`);
855
+ process.exit(1);
856
+ }
840
857
  const bodyLiterals = Object.keys(opts.body || {}).length ? opts.body : (cfg?.body_params_literal ?? {});
841
858
  const buttonLiteral = (opts.button && opts.button.param1) ?? cfg?.button_param_literal ?? null;
842
859
 
@@ -76,6 +76,20 @@ function resolveQueryText(opts) {
76
76
  process.exit(1);
77
77
  }
78
78
 
79
+ /** Field names in a GraphQL result whose `precision` says the count is a cap. */
80
+ function findCapped(node, path = [], hits = []) {
81
+ if (!node || typeof node !== "object") return hits;
82
+ if (Array.isArray(node)) {
83
+ for (const v of node) findCapped(v, path, hits);
84
+ return hits;
85
+ }
86
+ if (node.precision === "AT_LEAST" && typeof node.count === "number") {
87
+ hits.push(`${path[path.length - 1] || "count"} (${node.count.toLocaleString()})`);
88
+ }
89
+ for (const [k, v] of Object.entries(node)) findCapped(v, [...path, k], hits);
90
+ return hits;
91
+ }
92
+
79
93
  function banner(resp) {
80
94
  const bits = [];
81
95
  bits.push(`${resp.organization?.name ?? "?"} · ${resp.platform}${resp.mode === "graphql" ? " graphql" : ""}`);
@@ -95,6 +109,23 @@ function banner(resp) {
95
109
  console.error(`⚠ TRUNCATED — ${resp.truncated_reason}. Not all records were returned.`);
96
110
  console.error(" Narrow the query, or raise --max-pages and page through with the store's own cursor.");
97
111
  }
112
+ // Shopify's *Count fields return {count, precision}. `AT_LEAST` means the
113
+ // number is a CAP (10,000), not an answer — and it is quiet enough to be read
114
+ // as a real total. Worse, `customersCount` IGNORES its `query:` argument
115
+ // entirely (verified 4 Aug 2026 straight against Shopify on api 2024-01,
116
+ // 2024-10 and 2025-01: an impossible email filter still returns 10000), so a
117
+ // filtered cohort size comes back looking plausible and is wrong. Its sibling
118
+ // `ordersCount` honours the same argument and returns EXACT, so this is
119
+ // field-specific, not a general count-field limit.
120
+ const capped = findCapped(resp.data);
121
+ if (capped.length) {
122
+ console.error(`⚠ ${capped.join(", ")} returned precision "AT_LEAST" — that is a CAP, not a count.`);
123
+ console.error(` The real number is at least that; never size an audience from it.`);
124
+ }
125
+ if (/customersCount\s*\(/.test(resp.__query || "") && /\bquery\s*:/.test(resp.__query || "")) {
126
+ console.error(`⚠ customersCount IGNORES its query: filter — Shopify returns the same capped number`);
127
+ console.error(` whether you filter or not. Count with customers(first:N, query:…) and paginate instead.`);
128
+ }
98
129
  if (resp.graphql_errors?.length) {
99
130
  console.error(`⚠ GraphQL returned ${resp.graphql_errors.length} error(s):`);
100
131
  for (const err of resp.graphql_errors.slice(0, 5)) {
@@ -165,7 +196,7 @@ function emit(resp, opts) {
165
196
  console.log(text);
166
197
  }
167
198
 
168
- async function send(payload, opts) {
199
+ async function send(payload, opts, sentQuery) {
169
200
  let resp;
170
201
  try {
171
202
  resp = await http.post("store-api", payload);
@@ -174,6 +205,7 @@ async function send(payload, opts) {
174
205
  if (e.body?.upstream_status) console.error(` upstream HTTP ${e.body.upstream_status}`);
175
206
  process.exit(1);
176
207
  }
208
+ if (sentQuery) Object.defineProperty(resp, "__query", { value: sentQuery, enumerable: false });
177
209
  emit(resp, opts);
178
210
  }
179
211
 
@@ -204,7 +236,7 @@ export async function shopifyGql(orgId, opts = {}) {
204
236
  mode: "graphql",
205
237
  graphql: { query, variables: opts.var && Object.keys(opts.var).length ? opts.var : undefined },
206
238
  api_version: opts.apiVersion,
207
- }, opts);
239
+ }, opts, query);
208
240
  }
209
241
 
210
242
  // ---------------------------------------------------------------------------
package/src/http.js CHANGED
@@ -17,6 +17,55 @@ try {
17
17
  CLI_VERSION = JSON.parse(readFileSync(path.join(dir, "..", "package.json"), "utf8")).version || "unknown";
18
18
  } catch { /* version is a nice-to-have; never block a request on it */ }
19
19
 
20
+ /**
21
+ * Turn an error body into a STRING a human can act on.
22
+ *
23
+ * Our own endpoints answer `{error, message}` with string values, but the
24
+ * hosting platform does not: a killed or crashed function replies
25
+ * `{"error":{"code":"FUNCTION_INVOCATION_TIMEOUT","message":"…"}}`. The old code
26
+ * did `new Error(parsed.message || parsed.error)`, and `new Error(someObject)`
27
+ * stringifies to the useless **"[object Object]"** — which is exactly what a
28
+ * long `--all` run reported, hiding a plain timeout behind a message that looked
29
+ * like a bug in the CLI. Reported 4 Aug 2026 after it cost three runs to
30
+ * diagnose.
31
+ *
32
+ * So: prefer a string message, then a nested code/message, then the whole body
33
+ * serialised — and always name the HTTP status, since a 504 tells you "retry or
34
+ * narrow it" while a 400 never will.
35
+ */
36
+ export function describeError(parsed, method, endpoint, status) {
37
+ const pick = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
38
+ const nested = (v) => {
39
+ if (!v || typeof v !== "object") return null;
40
+ const msg = pick(v.message) || pick(v.error) || pick(v.reason);
41
+ const code = pick(v.code) || pick(v.type);
42
+ if (msg && code) return `${msg} (${code})`;
43
+ return msg || code || null;
44
+ };
45
+
46
+ let detail =
47
+ pick(parsed?.message) ||
48
+ pick(parsed?.error) ||
49
+ nested(parsed?.error) ||
50
+ nested(parsed) ||
51
+ pick(parsed?._raw);
52
+
53
+ if (!detail && parsed && typeof parsed === "object" && Object.keys(parsed).length) {
54
+ try { detail = JSON.stringify(parsed).slice(0, 400); } catch { /* fall through */ }
55
+ }
56
+
57
+ const where = `${method} ${endpoint} → HTTP ${status}`;
58
+ if (!detail) return where;
59
+
60
+ // A timeout is transient and the remedy is specific, so say so rather than
61
+ // leaving the reader to work out whether they hit a hard limit.
62
+ const timedOut = status === 504 || /TIMEOUT|timed out/i.test(detail);
63
+ return timedOut
64
+ ? `${detail} [${where}] — this is a TIMEOUT, not a limit: retry, or narrow the request ` +
65
+ `(fewer pages via --max-pages, a smaller --q limit, or a tighter filter).`
66
+ : `${detail} [${where}]`;
67
+ }
68
+
20
69
  async function call(method, endpoint, { query, body } = {}) {
21
70
  const cfg = await loadConfig();
22
71
  if (!cfg.token) {
@@ -52,9 +101,7 @@ async function call(method, endpoint, { query, body } = {}) {
52
101
  let parsed, isJson = true;
53
102
  try { parsed = text ? JSON.parse(text) : {}; } catch { parsed = { _raw: text }; isJson = false; }
54
103
  if (!resp.ok) {
55
- const err = new Error(
56
- parsed.message || parsed.error || `${method} ${endpoint} → HTTP ${resp.status}`
57
- );
104
+ const err = new Error(describeError(parsed, method, endpoint, resp.status));
58
105
  err.status = resp.status;
59
106
  err.body = parsed;
60
107
  throw err;