@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 +43 -0
- package/TEAM-GUIDE.md +9 -1
- package/package.json +1 -1
- package/src/commands/broadcast.js +17 -0
- package/src/commands/store-api.js +34 -2
- package/src/http.js +50 -3
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.
|
|
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;
|