@flowapt/flowiq-cli 0.4.4 → 0.4.6

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
@@ -256,6 +256,16 @@ flowiq bc scheduled cancel <org_id> <queue_id> --confirm
256
256
  the abort message says so — fix by passing `--header-media <real video>`.
257
257
  `map` refuses to save a mismatched default into the campaign file. An
258
258
  unreachable URL only warns (`media unverified`), never blocks.
259
+ - **One campaign = one template (v0.4.6).** A campaign's saved mapping, header
260
+ media and write-ahead status log (dedup) all belong to the template it was
261
+ built for. Running `bc send` / `map` with a **different `--template` on an
262
+ existing `--campaign`** now **ABORTS** (`Campaign "X" was built for template
263
+ A …`) — reusing the slug would silently send A's header media and skip the
264
+ recipients A already reached (the header-media check only catches a media-TYPE
265
+ change, not a same-type creative swap). Use a fresh `--campaign` for a new
266
+ template. In **tag mode the campaign slug defaults to the tag**, so to send
267
+ template A then template B to the SAME tag, pass a distinct `--campaign` for
268
+ each. `resume` passes no `--template`, so it is never affected.
259
269
  - **`list-remote <org>` (v0.3.9)**: list the org's broadcasts **newest-first** with
260
270
  the **full broadcastId** per row + template, status, recipient count and SAST
261
271
  created time — the discovery step `status` / `retry` need (previously the id
@@ -578,6 +588,18 @@ flowiq audit show <audit_id> --out entry.json # dump the whole entry
578
588
  Filters: `--endpoint --action --status --agent --target --user --since --until
579
589
  --limit` (default 50, max 200) `--json`.
580
590
 
591
+ **What gets logged.** Every command that CHANGES something writes a row —
592
+ including two that were missed until 4 Aug 2026 and are now covered:
593
+ `flowiq test` (it creates the test contact, can re-enable a contact's bot, and
594
+ clears the conversation) and `flowiq auth refresh` (it mints a new key and
595
+ revokes the old one). Reads and dry-runs deliberately write nothing.
596
+
597
+ **What is never logged:** the content itself. `messages` / `export chats` /
598
+ `shopify` / `woo` record *who read what*, never a copy of the conversation or
599
+ the store data. `flowiq test` records the prompt you sent, the tools the agent
600
+ called and how long its reply was — not the reply. `auth refresh` records key
601
+ ids and prefixes — never a key.
602
+
581
603
  **What's captured, and how much:**
582
604
 
583
605
  | Surface | Action | Content stored |
@@ -814,10 +836,63 @@ Flags:
814
836
  | `--var <k=v>` | GraphQL variable, repeatable. JSON values are parsed (`--var ids='["1"]'`). |
815
837
  | `--json` | Print the full envelope (metadata + data) instead of just the payload. |
816
838
  | `--compact` | Single-line JSON. |
839
+ | `--table` | Render the returned list as a table instead of JSON. A rendering only — same data, nothing reshaped or filtered. |
817
840
  | `--out <file>` | Write the JSON to a file instead of stdout. |
818
841
 
842
+ #### Two recipes — composed of the SAME calls, and they print them
843
+
844
+ Some questions take 3-4 chained calls and a manual id-join (stock needs
845
+ product → `inventory_item_id` → `inventory_levels` → `locations`). These two do
846
+ that chaining for you — and **echo every raw call they make as a runnable
847
+ command**, so the API stays the interface and you can take those lines further
848
+ than the recipe goes. Nothing here is expressible only through the recipe.
849
+
850
+ ```bash
851
+ flowiq shopify stock <org> "olive oil" # stock per LOCATION NAME, per variant
852
+ flowiq shopify order <org> '#14728' # order + fulfilments + tracking url
853
+ flowiq shopify stock <org> "olive oil" --raw # the underlying JSON instead
854
+ ```
855
+
856
+ ```
857
+ Looking up stock for "1 litre Oil Pourer" — the calls being made:
858
+ $ flowiq shopify get <org> products.json --q title="1 litre Oil Pourer" --q limit=50
859
+ $ flowiq shopify get <org> inventory_levels.json --q inventory_item_ids=45186940764314
860
+ $ flowiq shopify get <org> locations.json --q fields=id,name
861
+ ```
862
+
863
+ `stock` prints **"not tracked"** where Shopify returns `available: null` — that
864
+ means no inventory record exists at that location, which is not the same as zero.
865
+
866
+ #### Counting things — read `precision` before you trust a number
867
+
868
+ Shopify's `*Count` fields return `{count, precision}`. **`precision: "AT_LEAST"`
869
+ means the number is a CAP (10,000), not an answer.** The CLI now warns on stderr
870
+ whenever a capped count comes back, because it is very easy to read 10,000 as a
871
+ real total.
872
+
873
+ **`customersCount` additionally IGNORES its `query:` filter.** Verified 4 Aug 2026
874
+ straight against Shopify with no FlowIQ layer involved, on API versions 2024-01,
875
+ 2024-10 and 2025-01: an impossible email filter still returns `10000 / AT_LEAST`.
876
+ Its sibling `ordersCount` honours the same argument and returns
877
+ `1372 / EXACT`, so this is specific to that field, not a general limit. **This is
878
+ Shopify's behaviour, not the CLI's** — but sizing an audience with it gives you a
879
+ plausible, wrong number, so the CLI calls it out.
880
+
881
+ Count a filtered customer cohort with the connection instead, which filters
882
+ correctly, and paginate:
883
+
884
+ ```bash
885
+ flowiq shopify gql <org> -q 'query { customers(first: 250, query: "orders_count:>=20") {
886
+ edges { node { id } } pageInfo { hasNextPage endCursor } } }'
887
+ ```
888
+
819
889
  Notes:
820
890
 
891
+ * **A long `--all` can TIME OUT rather than truncate.** The server budgets ~25s and
892
+ then returns a partial result, but a very wide pull can still exceed the function
893
+ limit. You now get an explicit timeout message naming the remedy (retry, fewer
894
+ `--max-pages`, a smaller `--q limit`) — previously this surfaced as
895
+ `Request failed: [object Object]`, which read like a hard limit and wasn't.
821
896
  * **`--all` is bounded and says so.** It stops at `--max-pages`, at a ~3.5MB
822
897
  response size, or at a time budget, and then reports `⚠ TRUNCATED — <reason>`
823
898
  on stderr. A partial result is never presented as a complete one.
@@ -844,6 +919,10 @@ flowiq org create --name "New Client" [--slug new-client] [--owner client@email.
844
919
  # person to already have an account (otherwise invite them later in the app).
845
920
  # Provider defaults to meta. Prints the new org id + the next-step commands.
846
921
 
922
+ flowiq org list # every org + its UUID (the lookup everything else needs)
923
+ flowiq org list african # filter by name or slug
924
+ flowiq org list --all # include inactive orgs
925
+
847
926
  flowiq org info <organization_id> # platform, storefront url, active agent
848
927
  ```
849
928
 
package/TEAM-GUIDE.md CHANGED
@@ -80,6 +80,11 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
80
80
  | Edit keyword auto-replies (incl. competition entry keywords, add/remove-tag, set-agent and delay actions) | `flowiq kw pull <org_id>` → edit → `flowiq kw push <slug> --dry-run` → `flowiq kw push <slug>` |
81
81
  | Send a **different auto-reply depending on the contact** (e.g. "we already have your email" vs "send us your email") | add `"when": {"field":"email","op":"is_not_empty"}` to one action and `is_empty` to the other — see *Keywords* in `flowiq guide --reference`. CLI-only, no dashboard field yet |
82
82
  | Make a follow-up button **close the customer's ticket** ("Query solved") or **re-alert the team** ("I still need help") | keyword action `{"type":"update_ticket_status","status":"resolved"}` / `{"type":"renotify_ticket"}` — see *Keywords* in `flowiq guide --reference`. CLI-only, no dashboard field yet |
83
+ | **Find an org's ID** (needed by nearly every command) | `flowiq org list` — or `flowiq org list african` to filter |
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
+ | 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. |
83
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 |
84
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 |
85
90
  | Ask a client's WooCommerce store anything (read-only) | `flowiq woo get <org_id> orders --q status=completed --all` |
@@ -101,7 +106,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
101
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) |
102
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) |
103
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` |
104
- | 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). |
105
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. |
106
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.** |
107
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) |
@@ -111,6 +116,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
111
116
  | **Get an OLD version of a prompt back** | `flowiq prompts history <org_id>` (pick the version) → `flowiq prompts restore <org_id> <audit_id>` (dry-run) → `… --commit` |
112
117
  | **See who changed what, and when** | `flowiq audit <org_id>` — add `--endpoint prompts`, `--user <name>`, `--since 2026-07-01` to narrow |
113
118
  | See exactly what a change looked like (before → after) | `flowiq audit show <audit_id> --content` (or `--out entry.json`) |
119
+ | Know what the log does and doesn't keep | Anything that CHANGES something is logged — including `flowiq test` (it creates the test contact and clears conversations) and `flowiq auth refresh`. Reads and dry-runs are not. The log never stores the content itself: chats, store data and agent replies are recorded as "who read what", never copied. |
114
120
  | Read a contact's chat | `flowiq m pull <contact_id>` then open the JSON |
115
121
  | Export an org's full chat history | `flowiq export chats <org_id>` |
116
122
  | Check / create WhatsApp templates | `flowiq tpl pull <org_id>` / `flowiq tpl create <org_id> --request-file req.json` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
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
 
@@ -6,6 +6,37 @@ import { http } from "../http.js";
6
6
 
7
7
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
8
 
9
+ // `flowiq org list [search]` — the discovery step every other command needed.
10
+ // Until this existed the only way to turn "African Oils" into a UUID was the
11
+ // dashboard or raw SQL, which made every org-scoped command awkward to start.
12
+ export async function list(search, opts = {}) {
13
+ let resp;
14
+ try {
15
+ resp = await http.get("org", { list: "1", search: search || undefined });
16
+ } catch (e) {
17
+ console.error(`Lookup failed: ${e.message}`);
18
+ process.exit(1);
19
+ }
20
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
21
+
22
+ const orgs = (resp.orgs || []).filter((o) => (opts.all ? true : !o.inactive));
23
+ if (!orgs.length) {
24
+ console.log(search ? `No organizations match "${search}".` : "No organizations found.");
25
+ return;
26
+ }
27
+ const pad = (s, n) => String(s ?? "").padEnd(n);
28
+ const nameW = Math.min(34, Math.max(4, ...orgs.map((o) => (o.name || "").length)));
29
+ console.log(`${pad("NAME", nameW)} ${pad("ID", 36)} ${pad("PLATFORM", 9)}STORE`);
30
+ for (const o of orgs) {
31
+ console.log(
32
+ `${pad((o.name || "").slice(0, nameW), nameW)} ${pad(o.id, 36)} ` +
33
+ `${pad(o.platform || "—", 9)}${o.store || ""}${o.inactive ? " (inactive)" : ""}`
34
+ );
35
+ }
36
+ const hidden = (resp.orgs || []).length - orgs.length;
37
+ console.log(`\n${orgs.length} organization(s)${hidden ? ` · ${hidden} inactive hidden (--all to show)` : ""}`);
38
+ }
39
+
9
40
  export async function info(orgId, opts = {}) {
10
41
  if (!UUID_RE.test(orgId)) {
11
42
  console.error(`Error: "${orgId}" is not a valid organization UUID.`);
@@ -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)) {
@@ -103,9 +134,59 @@ function banner(resp) {
103
134
  }
104
135
  }
105
136
 
137
+ /**
138
+ * Print a flat array of objects as a table. Only ever a RENDERING of whatever
139
+ * the API returned — it never changes, filters or reshapes the data, so
140
+ * `--table` and the default JSON are the same answer in two skins.
141
+ */
142
+ function renderTable(rows) {
143
+ if (!Array.isArray(rows) || rows.length === 0) return null;
144
+ const flat = rows.map((r) => {
145
+ if (r === null || typeof r !== "object" || Array.isArray(r)) return { value: r };
146
+ const o = {};
147
+ for (const [k, v] of Object.entries(r)) {
148
+ o[k] = v === null || v === undefined ? ""
149
+ : typeof v === "object" ? (Array.isArray(v) ? `[${v.length}]` : "{…}")
150
+ : String(v);
151
+ }
152
+ return o;
153
+ });
154
+ const cols = [...new Set(flat.flatMap((r) => Object.keys(r)))];
155
+ if (!cols.length) return null;
156
+ const w = {};
157
+ for (const c of cols) w[c] = Math.min(48, Math.max(c.length, ...flat.map((r) => (r[c] ?? "").length)));
158
+ const cut = (s, n) => (s.length > n ? s.slice(0, n - 1) + "…" : s.padEnd(n));
159
+ const lines = [cols.map((c) => cut(c.toUpperCase(), w[c])).join(" ")];
160
+ lines.push(cols.map((c) => "─".repeat(w[c])).join(" "));
161
+ for (const r of flat) lines.push(cols.map((c) => cut(r[c] ?? "", w[c])).join(" "));
162
+ return lines.join("\n");
163
+ }
164
+
165
+ /** The single array in a Shopify-style `{orders: [...]}` wrapper, or the array itself. */
166
+ function primaryArray(data) {
167
+ if (Array.isArray(data)) return data;
168
+ if (data && typeof data === "object") {
169
+ const keys = Object.keys(data).filter((k) => Array.isArray(data[k]));
170
+ if (keys.length === 1) return data[keys[0]];
171
+ }
172
+ return null;
173
+ }
174
+
106
175
  function emit(resp, opts) {
107
176
  banner(resp);
108
177
  const payload = opts.json ? resp : resp.data;
178
+
179
+ if (opts.table && !opts.json) {
180
+ const rows = primaryArray(resp.data);
181
+ const table = rows ? renderTable(rows) : renderTable([resp.data]);
182
+ if (table) {
183
+ if (opts.out) { writeFileSync(opts.out, table + "\n"); console.error(`✓ wrote ${opts.out}`); return; }
184
+ console.log(table);
185
+ return;
186
+ }
187
+ console.error("(--table: response is not a flat list; printing JSON)");
188
+ }
189
+
109
190
  const text = JSON.stringify(payload, null, opts.compact ? 0 : 2);
110
191
  if (opts.out) {
111
192
  writeFileSync(opts.out, text);
@@ -115,7 +196,7 @@ function emit(resp, opts) {
115
196
  console.log(text);
116
197
  }
117
198
 
118
- async function send(payload, opts) {
199
+ async function send(payload, opts, sentQuery) {
119
200
  let resp;
120
201
  try {
121
202
  resp = await http.post("store-api", payload);
@@ -124,6 +205,7 @@ async function send(payload, opts) {
124
205
  if (e.body?.upstream_status) console.error(` upstream HTTP ${e.body.upstream_status}`);
125
206
  process.exit(1);
126
207
  }
208
+ if (sentQuery) Object.defineProperty(resp, "__query", { value: sentQuery, enumerable: false });
127
209
  emit(resp, opts);
128
210
  }
129
211
 
@@ -154,7 +236,155 @@ export async function shopifyGql(orgId, opts = {}) {
154
236
  mode: "graphql",
155
237
  graphql: { query, variables: opts.var && Object.keys(opts.var).length ? opts.var : undefined },
156
238
  api_version: opts.apiVersion,
157
- }, opts);
239
+ }, opts, query);
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Recipes — NOT wrappers.
244
+ //
245
+ // These answer the two questions people actually ask ("what's the stock on X",
246
+ // "did this order ship") which otherwise take 3-4 chained calls and a manual
247
+ // id-join. They are composed of THE SAME `shopify get` passthrough calls you
248
+ // could type yourself, and they PRINT EVERY CALL THEY MAKE as a runnable
249
+ // command — so the raw API stays the interface, and anyone (or any agent)
250
+ // reading the output can copy those lines, change them, and go further than
251
+ // the recipe does. Nothing here is expressible only through the recipe.
252
+ // `--raw` dumps the underlying JSON instead of the summary.
253
+ // ---------------------------------------------------------------------------
254
+
255
+ /** Run a passthrough GET, echoing the equivalent command so it can be reused. */
256
+ async function rawGet(orgId, path, query, { label } = {}) {
257
+ const qs = Object.entries(query || {})
258
+ .flatMap(([k, v]) => (Array.isArray(v) ? v.map((x) => [k, x]) : [[k, v]]))
259
+ .map(([k, v]) => `--q ${k}=${/[ "']/.test(String(v)) ? JSON.stringify(String(v)) : v}`)
260
+ .join(" ");
261
+ console.error(` $ flowiq shopify get <org> ${path}${qs ? " " + qs : ""}${label ? ` # ${label}` : ""}`);
262
+ const resp = await http.post("store-api", {
263
+ platform: "shopify", organization_id: orgId, mode: "rest", path, query: query || {},
264
+ });
265
+ return resp.data;
266
+ }
267
+
268
+ export async function shopifyStock(orgId, search, opts = {}) {
269
+ requireOrg(orgId);
270
+ if (!search || !String(search).trim()) {
271
+ console.error('Error: give me something to look for, e.g. flowiq shopify stock <org> "olive oil"');
272
+ process.exit(1);
273
+ }
274
+ console.error(`Looking up stock for "${search}" — the calls being made:`);
275
+ let products, levels, locations;
276
+ try {
277
+ // 1. name → products + variants (variants carry inventory_item_id)
278
+ const p = await rawGet(orgId, "products.json", { title: search, limit: 50 }, { label: "find the product" });
279
+ products = (p?.products || []).filter((x) =>
280
+ String(x.title).toLowerCase().includes(String(search).toLowerCase()));
281
+ 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()));
286
+ }
287
+ if (!products.length) {
288
+ console.error(`\nNothing matched "${search}". This searches product TITLES only — ` +
289
+ `the passthrough can query anything else Shopify exposes.`);
290
+ process.exit(1);
291
+ }
292
+ const itemIds = products.flatMap((x) => (x.variants || []).map((v) => v.inventory_item_id)).filter(Boolean);
293
+ // 2. inventory_item_id → per-location levels 3. location_id → names
294
+ levels = itemIds.length
295
+ ? (await rawGet(orgId, "inventory_levels.json",
296
+ { inventory_item_ids: itemIds.slice(0, 50).join(","), limit: 250 },
297
+ { label: "per-location levels" }))?.inventory_levels || []
298
+ : [];
299
+ locations = (await rawGet(orgId, "locations.json", { fields: "id,name" },
300
+ { label: "turn location ids into names" }))?.locations || [];
301
+ } catch (e) {
302
+ console.error(`\nRequest failed: ${e.message}`);
303
+ process.exit(1);
304
+ }
305
+
306
+ if (opts.raw) { console.log(JSON.stringify({ products, inventory_levels: levels, locations }, null, 2)); return; }
307
+
308
+ const locName = new Map(locations.map((l) => [l.id, l.name]));
309
+ const byItem = new Map();
310
+ for (const l of levels) {
311
+ if (!byItem.has(l.inventory_item_id)) byItem.set(l.inventory_item_id, []);
312
+ byItem.get(l.inventory_item_id).push(l);
313
+ }
314
+ const rows = [];
315
+ for (const p of products) {
316
+ for (const v of p.variants || []) {
317
+ const ls = byItem.get(v.inventory_item_id) || [];
318
+ const per = ls.map((l) => {
319
+ // `available: null` is Shopify saying "this item is not TRACKED at this
320
+ // location" — not zero, and not an error. Say so, rather than printing
321
+ // null and letting the reader guess.
322
+ const qty = l.available === null || l.available === undefined ? "not tracked" : String(l.available);
323
+ return `${locName.get(l.location_id) || l.location_id}: ${qty}`;
324
+ });
325
+ rows.push({
326
+ product: p.title,
327
+ variant: v.title === "Default Title" ? "—" : v.title,
328
+ sku: v.sku || "",
329
+ price: v.price ?? "",
330
+ policy: v.inventory_policy === "continue" ? "oversell ok" : "stop at 0",
331
+ total: v.inventory_quantity ?? "",
332
+ per_location: per.length ? per.join(" · ") : "(no level rows — untracked)",
333
+ });
334
+ }
335
+ }
336
+ console.error("");
337
+ console.log(renderTable(rows) ?? "(no variants)");
338
+ console.error(`\n${products.length} product(s) · ${rows.length} variant(s). ` +
339
+ `"not tracked" means Shopify holds no inventory record at that location — it is not a zero.`);
340
+ }
341
+
342
+ export async function shopifyOrder(orgId, ref, opts = {}) {
343
+ requireOrg(orgId);
344
+ const name = String(ref).trim();
345
+ console.error(`Looking up order ${name} — the calls being made:`);
346
+ let order;
347
+ try {
348
+ if (/^\d{10,}$/.test(name)) {
349
+ order = (await rawGet(orgId, `orders/${name}.json`, {}, { label: "by Shopify order id" }))?.order;
350
+ } else {
351
+ const r = await rawGet(orgId, "orders.json",
352
+ { name, status: "any", limit: 5 }, { label: "by order name" });
353
+ order = (r?.orders || [])[0];
354
+ }
355
+ } catch (e) {
356
+ console.error(`\nRequest failed: ${e.message}`);
357
+ process.exit(1);
358
+ }
359
+ if (!order) {
360
+ console.error(`\nNo order matching "${name}". Note Shopify matches the ORDER NAME (#14728), not the ` +
361
+ `confirmation number customers are shown on the thank-you page.`);
362
+ process.exit(1);
363
+ }
364
+ if (opts.raw) { console.log(JSON.stringify(order, null, 2)); return; }
365
+
366
+ const f = order.fulfillments || [];
367
+ console.error("");
368
+ console.log(`${order.name} ${order.financial_status || "?"} / ${order.fulfillment_status || "unfulfilled"}`);
369
+ console.log(` placed ${order.created_at}`);
370
+ console.log(` total ${order.currency} ${order.total_price}`);
371
+ console.log(` customer ${[order.customer?.first_name, order.customer?.last_name].filter(Boolean).join(" ") || "—"}` +
372
+ `${order.email ? ` · ${order.email}` : ""}`);
373
+ console.log(` items ${(order.line_items || []).map((li) => `${li.quantity}× ${li.title}`).join(", ") || "—"}`);
374
+ if (!f.length) {
375
+ console.log(` shipping nothing fulfilled yet`);
376
+ } else {
377
+ for (const ff of f) {
378
+ console.log(` shipment ${ff.status}` +
379
+ ` · ${ff.tracking_company || "no carrier recorded"}` +
380
+ ` · ${ff.tracking_number || "no tracking number"}` +
381
+ ` · courier status: ${ff.shipment_status || "none reported"}` +
382
+ ` · ${(ff.line_items || []).length} item(s)`);
383
+ if (ff.tracking_url) console.log(` ${ff.tracking_url}`);
384
+ }
385
+ }
386
+ console.error(`\nFull payload: add --raw (or use the passthrough directly — ` +
387
+ `flowiq shopify get <org> orders/${order.id}.json)`);
158
388
  }
159
389
 
160
390
  // ---------------------------------------------------------------------------
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;
package/src/index.js CHANGED
@@ -211,6 +211,11 @@ export function run(argv) {
211
211
 
212
212
  // org (read-only org summary for the prompt-builder skill, creds stripped)
213
213
  const org = program.command("org").description("Org info (read) + create a new organization");
214
+ org.command("list [search]")
215
+ .description("List organizations with their UUIDs — the lookup every other command needs (filter by name/slug)")
216
+ .option("--all", "include inactive organizations")
217
+ .option("--json", "raw JSON")
218
+ .action((search, opts) => orgCmd.list(search, opts));
214
219
  org.command("create")
215
220
  .description("Create a NEW organization (org row + admin membership; owner defaults to you)")
216
221
  .requiredOption("--name <name>", "organization name")
@@ -627,9 +632,20 @@ export function run(argv) {
627
632
  .option("--max-pages <n>", "page limit when using --all (default 10, cap 50)")
628
633
  .option("--api-version <ver>", "Shopify Admin API version (default 2024-01)")
629
634
  .option("--json", "print the full envelope (metadata + data) instead of just the payload")
635
+ .option("--table", "render the returned list as a table instead of JSON (same data, no reshaping)")
630
636
  .option("--compact", "single-line JSON")
631
637
  .option("--out <file>", "write the JSON to a file instead of stdout")
632
638
  .action((orgId, path, opts) => storeApiCmd.shopifyGet(orgId, path, opts));
639
+ // Recipes: the same passthrough calls, composed — and every call is PRINTED
640
+ // as a runnable command so the raw API stays the interface.
641
+ shopify.command("stock <organization_id> <search>")
642
+ .description("Stock per location for products matching a title — chains products → inventory_levels → locations and PRINTS each raw call it makes")
643
+ .option("--raw", "print the underlying JSON from every call instead of the summary table")
644
+ .action((orgId, search, opts) => storeApiCmd.shopifyStock(orgId, search, opts));
645
+ shopify.command("order <organization_id> <name-or-id>")
646
+ .description("One order with its fulfilments + tracking, e.g. `order <org> #14728` — PRINTS the raw call it makes")
647
+ .option("--raw", "print the full order JSON instead of the summary")
648
+ .action((orgId, ref, opts) => storeApiCmd.shopifyOrder(orgId, ref, opts));
633
649
  shopify.command("gql <organization_id>")
634
650
  .description("Run a Shopify Admin GraphQL QUERY (you write it). Any document containing a mutation is refused.")
635
651
  .option("-q, --query <graphql>", "the query document, inline")
@@ -637,6 +653,7 @@ export function run(argv) {
637
653
  .option("--var <k=v>", "GraphQL variable (repeatable; JSON values parsed)", storeApiCmd.collectVar, {})
638
654
  .option("--api-version <ver>", "Shopify Admin API version (default 2024-01)")
639
655
  .option("--json", "print the full envelope (metadata + data) instead of just the payload")
656
+ .option("--table", "render the returned list as a table instead of JSON (same data, no reshaping)")
640
657
  .option("--compact", "single-line JSON")
641
658
  .option("--out <file>", "write the JSON to a file instead of stdout")
642
659
  .action((orgId, opts) => storeApiCmd.shopifyGql(orgId, opts));
@@ -650,6 +667,7 @@ export function run(argv) {
650
667
  .option("--all", "page through every result (bounded by --max-pages and a size/time cap)")
651
668
  .option("--max-pages <n>", "page limit when using --all (default 10, cap 50)")
652
669
  .option("--json", "print the full envelope (metadata + data) instead of just the payload")
670
+ .option("--table", "render the returned list as a table instead of JSON (same data, no reshaping)")
653
671
  .option("--compact", "single-line JSON")
654
672
  .option("--out <file>", "write the JSON to a file instead of stdout")
655
673
  .action((orgId, path, opts) => storeApiCmd.wooGet(orgId, path, opts));