@flowapt/flowiq-cli 0.4.3 → 0.4.5
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 +158 -1
- package/TEAM-GUIDE.md +81 -0
- package/package.json +1 -1
- package/src/commands/broadcast.js +145 -0
- package/src/commands/org.js +31 -0
- package/src/commands/store-api.js +374 -0
- package/src/index.js +79 -1
package/README.md
CHANGED
|
@@ -206,8 +206,34 @@ flowiq bc send <org_id> --template … --csv … --campaign july-referrals --com
|
|
|
206
206
|
|
|
207
207
|
# 4) Interrupted? Continue only unsent rows:
|
|
208
208
|
flowiq bc resume <org_id> --campaign july-referrals --commit [--retry-failed]
|
|
209
|
+
|
|
210
|
+
# SCHEDULE a tag send for later instead of sending now (--at is SAST):
|
|
211
|
+
flowiq bc send <org_id> --tag <batch-tag> --template <name> --body param1="…" \
|
|
212
|
+
--at "2026-08-05 09:00" --commit
|
|
213
|
+
flowiq bc send … --at "2026-08-05 09:00" --needs-approval --commit # park it for approval
|
|
214
|
+
|
|
215
|
+
flowiq bc scheduled list <org_id> # open rows (pending + awaiting-approval)
|
|
216
|
+
flowiq bc scheduled list <org_id> --all # incl. fired / cancelled
|
|
217
|
+
flowiq bc scheduled approve <org_id> <queue_id> # 'request' → 'pending' (--at re-times it)
|
|
218
|
+
flowiq bc scheduled cancel <org_id> <queue_id> --confirm
|
|
209
219
|
```
|
|
210
220
|
|
|
221
|
+
- **Scheduling (v0.4.4)** — `--at "YYYY-MM-DD HH:MM"` on a `--tag` send queues it
|
|
222
|
+
instead of sending. It writes the same `api_request_queue` row the dashboard
|
|
223
|
+
writes, replayed by the `process-api-queue` cron (every 3 min, so it fires
|
|
224
|
+
0–3 min after the stated minute). Runs **every** pre-send check first
|
|
225
|
+
(APPROVED, positional, param arithmetic, header-media type) — a scheduled
|
|
226
|
+
send can't skip a guard an immediate one enforces.
|
|
227
|
+
- `--at` is always read as **SAST**, never the machine's local timezone, so
|
|
228
|
+
the same command schedules the same instant from anywhere.
|
|
229
|
+
- The **audience resolves when it FIRES**, not when you schedule — someone
|
|
230
|
+
tagged tomorrow morning still receives a send scheduled today.
|
|
231
|
+
- Defaults to fire-by-itself. `--needs-approval` parks it until
|
|
232
|
+
`bc scheduled approve`. ⚠ **A parked row NEVER fires until approved** and
|
|
233
|
+
never errors — `scheduled list` flags those `AWAITING APPROVAL — will NOT
|
|
234
|
+
fire` (two dashboard-scheduled broadcasts silently missed their sends this
|
|
235
|
+
way). Cancel is dry-run unless you pass `--confirm`.
|
|
236
|
+
|
|
211
237
|
- **Per-row values**: each template slot maps to a CSV **column**, a
|
|
212
238
|
**literal**, or a **concat transform** (e.g. `Country Code` + `Phone`,
|
|
213
239
|
leading-zero handled). The mapper auto-suggests from the template's
|
|
@@ -552,6 +578,18 @@ flowiq audit show <audit_id> --out entry.json # dump the whole entry
|
|
|
552
578
|
Filters: `--endpoint --action --status --agent --target --user --since --until
|
|
553
579
|
--limit` (default 50, max 200) `--json`.
|
|
554
580
|
|
|
581
|
+
**What gets logged.** Every command that CHANGES something writes a row —
|
|
582
|
+
including two that were missed until 4 Aug 2026 and are now covered:
|
|
583
|
+
`flowiq test` (it creates the test contact, can re-enable a contact's bot, and
|
|
584
|
+
clears the conversation) and `flowiq auth refresh` (it mints a new key and
|
|
585
|
+
revokes the old one). Reads and dry-runs deliberately write nothing.
|
|
586
|
+
|
|
587
|
+
**What is never logged:** the content itself. `messages` / `export chats` /
|
|
588
|
+
`shopify` / `woo` record *who read what*, never a copy of the conversation or
|
|
589
|
+
the store data. `flowiq test` records the prompt you sent, the tools the agent
|
|
590
|
+
called and how long its reply was — not the reply. `auth refresh` records key
|
|
591
|
+
ids and prefixes — never a key.
|
|
592
|
+
|
|
555
593
|
**What's captured, and how much:**
|
|
556
594
|
|
|
557
595
|
| Surface | Action | Content stored |
|
|
@@ -729,6 +767,109 @@ server-side (`auto_synthesized: true`; the response says
|
|
|
729
767
|
broadcast mapping suggestions — pass your own only when you want real slot
|
|
730
768
|
labels (e.g. `"body_params": {"1": "first_name", "2": "order_number"}`).
|
|
731
769
|
|
|
770
|
+
### Store API (READ-ONLY) — `flowiq shopify get|gql` / `flowiq woo get`
|
|
771
|
+
|
|
772
|
+
Write your own request against a client's **own** store API. You supply the
|
|
773
|
+
endpoint (or the GraphQL query); the server resolves that org's credentials and
|
|
774
|
+
makes the call. Credentials never reach your machine and are never printed.
|
|
775
|
+
|
|
776
|
+
**Writes are refused, structurally:**
|
|
777
|
+
|
|
778
|
+
* **REST** — the HTTP method is hardcoded `GET` and is never read from your
|
|
779
|
+
request. Every write in both APIs is POST/PUT/PATCH/DELETE, so "GET only" *is*
|
|
780
|
+
"read only". There is no endpoint allowlist to fall out of date, so any
|
|
781
|
+
endpoint — including ones Shopify or Woo ship tomorrow — works immediately.
|
|
782
|
+
* **GraphQL** — the document is lexed (strings and `#` comments stripped) and
|
|
783
|
+
any `mutation` / `subscription` operation is rejected before the call is made.
|
|
784
|
+
Shopify exposes every Admin write as a mutation, so this rejects the whole
|
|
785
|
+
write surface. The word "mutation" inside a *string* or a *comment* is fine.
|
|
786
|
+
|
|
787
|
+
```bash
|
|
788
|
+
# Shopify REST — any Admin endpoint. The ".json" suffix is optional.
|
|
789
|
+
flowiq shopify get <org_id> orders.json --q status=any --q limit=250
|
|
790
|
+
flowiq shopify get <org_id> products --q limit=5 --q fields=id,title,status
|
|
791
|
+
flowiq shopify get <org_id> inventory_levels.json --q location_ids=123 --all
|
|
792
|
+
flowiq shopify get <org_id> orders/450789469.json # a single record
|
|
793
|
+
|
|
794
|
+
# Shopify GraphQL — you write the query. Inline, from a file, or on stdin.
|
|
795
|
+
flowiq shopify gql <org_id> -q 'query { shop { name currencyCode } }'
|
|
796
|
+
flowiq shopify gql <org_id> --query-file ./q.graphql --var n=50
|
|
797
|
+
cat q.graphql | flowiq shopify gql <org_id>
|
|
798
|
+
|
|
799
|
+
# WooCommerce REST — default namespace wc/v3.
|
|
800
|
+
flowiq woo get <org_id> orders --q status=completed --q per_page=100 --all
|
|
801
|
+
flowiq woo get <org_id> products/1234
|
|
802
|
+
flowiq woo get <org_id> reports/sales --namespace wc/v3 --q period=month
|
|
803
|
+
flowiq woo get <org_id> settings/general --namespace wc/v3
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
**Output contract — stdout is the payload and nothing else**, so it pipes
|
|
807
|
+
straight into `jq`. The metadata banner (record count, pages, rate-limit
|
|
808
|
+
headroom, timing) goes to **stderr**, so you still see it on your terminal:
|
|
809
|
+
|
|
810
|
+
```bash
|
|
811
|
+
flowiq shopify get <org_id> orders.json --q status=any --all | jq -r '.orders[].name'
|
|
812
|
+
flowiq woo get <org_id> orders --all --out ./orders.json # or write to a file
|
|
813
|
+
flowiq shopify get <org_id> shop --json # full envelope incl. metadata
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
Flags:
|
|
817
|
+
|
|
818
|
+
| Flag | Meaning |
|
|
819
|
+
|---|---|
|
|
820
|
+
| `--q <k=v>` | Query parameter, repeatable. A repeated key becomes a repeated parameter (`--q ids=1 --q ids=2`). Params go here, never in the path. |
|
|
821
|
+
| `--all` | Follow pagination (Shopify's `Link: rel=next`; Woo's `page`) and return every record. |
|
|
822
|
+
| `--max-pages <n>` | Page limit for `--all` (default 10, hard cap 50). |
|
|
823
|
+
| `--namespace <ns>` | Woo REST namespace — default `wc/v3`; also `wc-analytics`, `wp/v2`. |
|
|
824
|
+
| `--api-version <v>` | Shopify Admin API version (default `2024-01`). |
|
|
825
|
+
| `-q, --query` / `--query-file` | The GraphQL document, inline or from a file (stdin also works). |
|
|
826
|
+
| `--var <k=v>` | GraphQL variable, repeatable. JSON values are parsed (`--var ids='["1"]'`). |
|
|
827
|
+
| `--json` | Print the full envelope (metadata + data) instead of just the payload. |
|
|
828
|
+
| `--compact` | Single-line JSON. |
|
|
829
|
+
| `--table` | Render the returned list as a table instead of JSON. A rendering only — same data, nothing reshaped or filtered. |
|
|
830
|
+
| `--out <file>` | Write the JSON to a file instead of stdout. |
|
|
831
|
+
|
|
832
|
+
#### Two recipes — composed of the SAME calls, and they print them
|
|
833
|
+
|
|
834
|
+
Some questions take 3-4 chained calls and a manual id-join (stock needs
|
|
835
|
+
product → `inventory_item_id` → `inventory_levels` → `locations`). These two do
|
|
836
|
+
that chaining for you — and **echo every raw call they make as a runnable
|
|
837
|
+
command**, so the API stays the interface and you can take those lines further
|
|
838
|
+
than the recipe goes. Nothing here is expressible only through the recipe.
|
|
839
|
+
|
|
840
|
+
```bash
|
|
841
|
+
flowiq shopify stock <org> "olive oil" # stock per LOCATION NAME, per variant
|
|
842
|
+
flowiq shopify order <org> '#14728' # order + fulfilments + tracking url
|
|
843
|
+
flowiq shopify stock <org> "olive oil" --raw # the underlying JSON instead
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
```
|
|
847
|
+
Looking up stock for "1 litre Oil Pourer" — the calls being made:
|
|
848
|
+
$ flowiq shopify get <org> products.json --q title="1 litre Oil Pourer" --q limit=50
|
|
849
|
+
$ flowiq shopify get <org> inventory_levels.json --q inventory_item_ids=45186940764314
|
|
850
|
+
$ flowiq shopify get <org> locations.json --q fields=id,name
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
`stock` prints **"not tracked"** where Shopify returns `available: null` — that
|
|
854
|
+
means no inventory record exists at that location, which is not the same as zero.
|
|
855
|
+
|
|
856
|
+
Notes:
|
|
857
|
+
|
|
858
|
+
* **`--all` is bounded and says so.** It stops at `--max-pages`, at a ~3.5MB
|
|
859
|
+
response size, or at a time budget, and then reports `⚠ TRUNCATED — <reason>`
|
|
860
|
+
on stderr. A partial result is never presented as a complete one.
|
|
861
|
+
* **Rate limits are surfaced, not hidden.** REST prints Shopify's
|
|
862
|
+
`api calls 3/40`; GraphQL prints the query cost and remaining points.
|
|
863
|
+
* **GraphQL errors are surfaced.** Shopify returns field errors as HTTP 200 with
|
|
864
|
+
an `errors` array; the CLI prints them rather than letting you read
|
|
865
|
+
`data: null` as "no results".
|
|
866
|
+
* **Orgs behind an IP whitelist / WAF** (`feature_flags.ip_whitelist`, e.g.
|
|
867
|
+
Hydroponic on Wordfence) reject calls from our servers. You get an explicit
|
|
868
|
+
message saying so, not an opaque 403.
|
|
869
|
+
* Every call is written to the audit log (`flowiq audit <org>`) — who, which
|
|
870
|
+
org, which endpoint or query, how many records. **The response body is never
|
|
871
|
+
stored**; the log records what was asked, not the customer data that came back.
|
|
872
|
+
|
|
732
873
|
### Org — `flowiq org create` / `flowiq org info <organization_id>`
|
|
733
874
|
|
|
734
875
|
Create a brand-new organization, or look one up (read-only; raw store/Meta
|
|
@@ -740,6 +881,10 @@ flowiq org create --name "New Client" [--slug new-client] [--owner client@email.
|
|
|
740
881
|
# person to already have an account (otherwise invite them later in the app).
|
|
741
882
|
# Provider defaults to meta. Prints the new org id + the next-step commands.
|
|
742
883
|
|
|
884
|
+
flowiq org list # every org + its UUID (the lookup everything else needs)
|
|
885
|
+
flowiq org list african # filter by name or slug
|
|
886
|
+
flowiq org list --all # include inactive orgs
|
|
887
|
+
|
|
743
888
|
flowiq org info <organization_id> # platform, storefront url, active agent
|
|
744
889
|
```
|
|
745
890
|
|
|
@@ -802,10 +947,22 @@ flowiq agent config <organization_id> --test-contact-number 27000000001 --test-c
|
|
|
802
947
|
Settable: `settings.use_settings_prompt`, `settings.model`, agent `--rename`,
|
|
803
948
|
the tool-flag columns (`woo_order_build`, `woo_tip_field`, `woo_order_note_field`,
|
|
804
949
|
`view_cart_tool`, `restock_tool`, `block_tool_status`, `postal_code_tool_status`,
|
|
805
|
-
`shopify_products_web_chat`, `ticket_tool_status
|
|
950
|
+
`shopify_products_web_chat`, `ticket_tool_status`, `collapse_product_variants`),
|
|
951
|
+
`discount.enabled`, and the `flowiq test` contact
|
|
806
952
|
(`settings.test_contact_number` / `settings.test_contact_name`). Anything else is
|
|
807
953
|
rejected; every change is reported before → after.
|
|
808
954
|
|
|
955
|
+
**`--tool collapse_product_variants=true` (added 4 Aug 2026).** Not a tool toggle —
|
|
956
|
+
it changes what `get_product_info` RETURNS. OFF (the default) the result cap counts
|
|
957
|
+
**variant rows**, so on a catalogue with several packaging/size variants per product
|
|
958
|
+
the agent sees only a handful of distinct products and will tell a customer an
|
|
959
|
+
in-stock item "isn't showing". ON, the tool fetches `max*6` rows (cap 300) and
|
|
960
|
+
collapses them to ≤`max` **distinct products**, listing each product's variants
|
|
961
|
+
inline. Measured on African Oils: a 40-row response went from **15–24 distinct
|
|
962
|
+
products to 40**, out of a 334-product catalogue. Costs ~50% more tokens per call.
|
|
963
|
+
Requires ≤12 variants per product for the inline rendering branch — check with
|
|
964
|
+
`SELECT max(c) FROM (SELECT count(*) c FROM products_all WHERE organization_id=… GROUP BY product_id) t`.
|
|
965
|
+
|
|
809
966
|
**Test contact (`--test-contact-number` / `--test-contact-name`, v0.2.9).**
|
|
810
967
|
This is the contact `flowiq test` drives the live agent through (read by
|
|
811
968
|
`api/cli/agent-test.js` as `settings.test_contact_number`). ⚠ **Use a clearly
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -80,8 +80,17 @@ 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
|
+
| 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
|
+
| 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
|
+
| Ask a client's WooCommerce store anything (read-only) | `flowiq woo get <org_id> orders --q status=completed --all` |
|
|
89
|
+
| Pull every matching record, not just the first page | add `--all` (bounded — it prints `⚠ TRUNCATED` if it stops early, so a partial answer never looks complete) |
|
|
90
|
+
| Feed store data into jq / a file | stdout is the payload only, banner goes to stderr: `flowiq shopify get <org> orders.json \| jq -r '.orders[].name'` · or `--out ./orders.json` |
|
|
83
91
|
| See an org's agents / create one | `flowiq agent list <org_id>` / `flowiq agent create <org_id> --name "…"` |
|
|
84
92
|
| Change agent model / tool flags | `flowiq agent config <org_id> --model … --tool view_cart_tool=true` |
|
|
93
|
+
| Agent says an in-stock product "isn't showing" | `flowiq agent config <org_id> --tool collapse_product_variants=true` — the search cap counts VARIANT rows until this is on |
|
|
85
94
|
| Talk to the live agent safely (no real WhatsApp ever sent) | `flowiq test send <org_id> "hi, do you sell X?"` |
|
|
86
95
|
| Set which contact `flowiq test` uses (use a FAKE number!) | `flowiq agent config <org_id> --test-contact-number 27000000001 --test-contact-name "QA Bot"` |
|
|
87
96
|
| Send a template broadcast to a CSV of people | `flowiq bc map <org_id> --template … --csv …` → `flowiq bc send … ` (dry-run) → `… --commit` |
|
|
@@ -93,6 +102,8 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
93
102
|
| Combine existing tags → a batched send list (include some tags, drop others, split into batches of N) | `flowiq seg plan <org_id> --tag-prefix clearance-bc --from-tag "loyalty-list" --exclude "recent-campaign" --batch-size 1000` → `flowiq seg apply <org_id> clearance-bc --commit` (makes `clearance-bc-batch-01/02/…`) |
|
|
94
103
|
| Split a big id-list cohort into send-safe batch tags | `flowiq seg plan <org_id> --tag-prefix … --ids-file …` → `flowiq seg apply … --commit` |
|
|
95
104
|
| 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
|
+
| 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
|
+
| 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` |
|
|
96
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>}}`) |
|
|
97
108
|
| 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. |
|
|
98
109
|
| 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.** |
|
|
@@ -103,6 +114,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
103
114
|
| **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` |
|
|
104
115
|
| **See who changed what, and when** | `flowiq audit <org_id>` — add `--endpoint prompts`, `--user <name>`, `--since 2026-07-01` to narrow |
|
|
105
116
|
| See exactly what a change looked like (before → after) | `flowiq audit show <audit_id> --content` (or `--out entry.json`) |
|
|
117
|
+
| 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. |
|
|
106
118
|
| Read a contact's chat | `flowiq m pull <contact_id>` then open the JSON |
|
|
107
119
|
| Export an org's full chat history | `flowiq export chats <org_id>` |
|
|
108
120
|
| Check / create WhatsApp templates | `flowiq tpl pull <org_id>` / `flowiq tpl create <org_id> --request-file req.json` |
|
|
@@ -192,6 +204,75 @@ flowiq tag attributes <org_id> --filter allow_broadcast_true \
|
|
|
192
204
|
`--exclude` names the same tag you're adding, so every run skips the people who
|
|
193
205
|
already have it. Safe to re-run as many times as you like.
|
|
194
206
|
|
|
207
|
+
### Scheduling a broadcast
|
|
208
|
+
|
|
209
|
+
Add `--at` to a tag send and it queues instead of sending. The time is **South
|
|
210
|
+
African time**, always — it does not matter where your laptop thinks it is.
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
flowiq bc send <org_id> --tag 5-aug-batch-01 --template my_template \
|
|
214
|
+
--body param1="Hi {{first_name}}" --at "2026-08-05 09:00" --commit
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
It fires on its own, so there is nothing more to do. Two things worth knowing:
|
|
218
|
+
|
|
219
|
+
- **The audience is worked out when it fires, not now.** Anyone who picks up
|
|
220
|
+
that tag between now and then is included.
|
|
221
|
+
- It goes out **within about 3 minutes** of the time you set, not to the second.
|
|
222
|
+
|
|
223
|
+
Check on it, change your mind, or hand it to someone else to sign off:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
flowiq bc scheduled list <org_id> # what's queued
|
|
227
|
+
flowiq bc scheduled cancel <org_id> <queue_id> --confirm
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
If you'd rather someone approved it before it goes out, add `--needs-approval`
|
|
231
|
+
when scheduling. **It then will not send until somebody runs
|
|
232
|
+
`flowiq bc scheduled approve <org_id> <queue_id>`** — `scheduled list` marks it
|
|
233
|
+
`AWAITING APPROVAL — will NOT fire` so it can't be forgotten. (Two broadcasts
|
|
234
|
+
scheduled in the dashboard were missed exactly this way.)
|
|
235
|
+
|
|
236
|
+
### Asking a client's store a question directly
|
|
237
|
+
|
|
238
|
+
When you need something the dashboard doesn't show — "how many orders are still
|
|
239
|
+
unfulfilled?", "what does this product's inventory actually look like per
|
|
240
|
+
location?", "which orders used that discount code?" — you can ask the client's
|
|
241
|
+
own store, in their own API, without a Shopify or Woo login.
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
# Shopify — any Admin REST endpoint. ".json" is optional.
|
|
245
|
+
flowiq shopify get <org_id> orders.json --q status=any --q financial_status=paid --q limit=250
|
|
246
|
+
|
|
247
|
+
# WooCommerce — same idea.
|
|
248
|
+
flowiq woo get <org_id> orders --q status=processing --all
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
**You cannot break anything with this.** Reads are the only thing it can do:
|
|
252
|
+
REST calls are locked to `GET`, and a GraphQL document containing a `mutation`
|
|
253
|
+
is refused before it is sent. There is no "are you sure?" because there is
|
|
254
|
+
nothing to be sure about.
|
|
255
|
+
|
|
256
|
+
The answer comes back as JSON on stdout and a summary line on stderr, so it
|
|
257
|
+
pipes into other tools cleanly:
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
flowiq shopify get <org_id> orders.json --q status=any --all | jq -r '.orders[].name'
|
|
261
|
+
flowiq woo get <org_id> products --all --out ./products.json
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Three things worth knowing:
|
|
265
|
+
|
|
266
|
+
- **`--all` pages through everything**, but it stops at a sane limit and tells
|
|
267
|
+
you when it did (`⚠ TRUNCATED — stopped at --max-pages 10`). If you see that,
|
|
268
|
+
narrow the query rather than assuming you have the full set.
|
|
269
|
+
- **If REST can't express your question, write GraphQL** —
|
|
270
|
+
`flowiq shopify gql <org_id> -q 'query { … }'` lets you pick exactly the
|
|
271
|
+
fields and nesting you want. Put a long query in a file and use
|
|
272
|
+
`--query-file`.
|
|
273
|
+
- **Some stores sit behind a firewall** (Hydroponic's, for one) and will refuse
|
|
274
|
+
our call. You'll get a message saying exactly that.
|
|
275
|
+
|
|
195
276
|
## Keys, rotation, logging out
|
|
196
277
|
|
|
197
278
|
- **`flowiq auth refresh`** — rotates this device's key in place (a fresh key
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowapt/flowiq-cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
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": {
|
|
@@ -864,6 +864,12 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
|
|
|
864
864
|
if (template.url_button?.present && !buttonLiteral) aborts.push("template has a dynamic URL button — pass --button param1=<code>");
|
|
865
865
|
if (aborts.length) { for (const a of aborts) console.error(`ABORT — ${a}`); process.exit(1); }
|
|
866
866
|
|
|
867
|
+
// SCHEDULE (--at): validation above has already run, so a scheduled send is
|
|
868
|
+
// gated by exactly the same checks as an immediate one (APPROVED, positional,
|
|
869
|
+
// param arithmetic, header-media type). Instead of sending we queue the send
|
|
870
|
+
// for later; python resolves the tag at FIRE time, so the audience is fresh.
|
|
871
|
+
if (opts.at) return scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage });
|
|
872
|
+
|
|
867
873
|
// ENGINE ROUTING (policy): any send of MORE THAN 10 recipients ALWAYS uses the
|
|
868
874
|
// python /meta-broadcast engine (the proven bulk sender). --python forces it at
|
|
869
875
|
// any size; only a ≤10 send stays on the resumable per-row Node engine.
|
|
@@ -1002,6 +1008,145 @@ export async function map(orgId, opts = {}) {
|
|
|
1002
1008
|
console.log(`Saved mapping: ${cfgPath(campaign)}`);
|
|
1003
1009
|
}
|
|
1004
1010
|
|
|
1011
|
+
/** Parse a `--at` value as SOUTH AFRICAN time (SAST, UTC+2) and return an ISO string.
|
|
1012
|
+
* Accepts "YYYY-MM-DD HH:MM" / "YYYY-MM-DDTHH:MM" (SAST), or a value carrying its
|
|
1013
|
+
* own offset/Z (honoured as given). A bare local string is deliberately NOT handed
|
|
1014
|
+
* to `new Date()`: that interprets it in the RUNNER's timezone, so the same command
|
|
1015
|
+
* would schedule a different moment on a laptop in another country. */
|
|
1016
|
+
export function parseSastAt(input) {
|
|
1017
|
+
const raw = String(input || "").trim();
|
|
1018
|
+
if (!raw) return { error: "empty --at value" };
|
|
1019
|
+
if (/(Z|[+-]\d{2}:?\d{2})$/.test(raw)) {
|
|
1020
|
+
const d = new Date(raw);
|
|
1021
|
+
return Number.isNaN(d.getTime()) ? { error: `not a valid timestamp: ${raw}` } : { iso: d.toISOString(), explicitOffset: true };
|
|
1022
|
+
}
|
|
1023
|
+
const m = raw.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/);
|
|
1024
|
+
if (!m) return { error: `use "YYYY-MM-DD HH:MM" (SAST), e.g. --at "2026-08-05 09:00" — got "${raw}"` };
|
|
1025
|
+
const [, y, mo, d, h, mi, s] = m;
|
|
1026
|
+
// Build the instant explicitly at +02:00 so the runner's TZ is irrelevant.
|
|
1027
|
+
const iso = new Date(`${y}-${mo}-${d}T${h}:${mi}:${s || "00"}+02:00`);
|
|
1028
|
+
if (Number.isNaN(iso.getTime())) return { error: `not a valid date/time: ${raw}` };
|
|
1029
|
+
return { iso: iso.toISOString(), explicitOffset: false };
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const fmtSast = (iso) =>
|
|
1033
|
+
new Date(iso).toLocaleString("en-ZA", { timeZone: "Africa/Johannesburg", dateStyle: "medium", timeStyle: "short" });
|
|
1034
|
+
|
|
1035
|
+
/** Queue a tag broadcast to fire later (server writes the api_request_queue row). */
|
|
1036
|
+
async function scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage }) {
|
|
1037
|
+
const when = parseSastAt(opts.at);
|
|
1038
|
+
if (when.error) { console.error(`Error: --at ${when.error}`); process.exit(1); }
|
|
1039
|
+
if (new Date(when.iso).getTime() <= Date.now()) {
|
|
1040
|
+
console.error(`ABORT — --at is in the past (${fmtSast(when.iso)} SAST). Nothing would ever fire.`);
|
|
1041
|
+
process.exit(1);
|
|
1042
|
+
}
|
|
1043
|
+
const needsApproval = !!opts.needsApproval;
|
|
1044
|
+
|
|
1045
|
+
console.log("");
|
|
1046
|
+
renderPreview(template, { rownum: 1, number: "(resolved at send time)", values: bodyLiterals, buttonValue: buttonLiteral }, headerMedia);
|
|
1047
|
+
if (Object.values(bodyLiterals).some((v) => /\{\{(first_name|full_name|email|phone_number|whatsapp_id)\}\}/.test(String(v)))) {
|
|
1048
|
+
console.log(" ({{first_name}}-style tokens are resolved PER CONTACT at send time)");
|
|
1049
|
+
}
|
|
1050
|
+
console.log("");
|
|
1051
|
+
console.log(`Schedule: ${fmtSast(when.iso)} SAST${when.explicitOffset ? "" : " (--at read as SAST)"}`);
|
|
1052
|
+
console.log(`Audience: tag "${tag}" — resolved when it FIRES, not now (so late joiners are included)`);
|
|
1053
|
+
console.log(`Engine: python /meta-broadcast`);
|
|
1054
|
+
console.log(needsApproval
|
|
1055
|
+
? `Approval: REQUIRED — parks as 'request'; run "flowiq bc scheduled approve" before it can fire`
|
|
1056
|
+
: `Approval: none — fires automatically at the scheduled time`);
|
|
1057
|
+
|
|
1058
|
+
if (!commitStage) {
|
|
1059
|
+
console.log("");
|
|
1060
|
+
console.log("DRY RUN — nothing was scheduled. Add --commit to queue it.");
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
let resp;
|
|
1065
|
+
try {
|
|
1066
|
+
resp = await http.post("broadcast", {
|
|
1067
|
+
action: "schedule",
|
|
1068
|
+
organization_id: orgId,
|
|
1069
|
+
tag,
|
|
1070
|
+
template_name: templateName,
|
|
1071
|
+
scheduled_for: when.iso,
|
|
1072
|
+
needs_approval: needsApproval,
|
|
1073
|
+
body_parameters: bodyLiterals,
|
|
1074
|
+
...(buttonLiteral ? { button_parameters: { param1: buttonLiteral } } : {}),
|
|
1075
|
+
...(headerMedia ? { header_media: headerMedia } : {}),
|
|
1076
|
+
});
|
|
1077
|
+
} catch (e) {
|
|
1078
|
+
console.error(`Schedule failed: ${e.message}`);
|
|
1079
|
+
if (e.body?.error) console.error(` ${e.body.error}`);
|
|
1080
|
+
process.exit(1);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
console.log("");
|
|
1084
|
+
console.log(`✓ Scheduled for ${fmtSast(resp.scheduled_for)} SAST — queue id ${resp.queue_id}`);
|
|
1085
|
+
console.log(` ~${resp.audience_at_schedule ?? "?"} contacts carry "${tag}" right now (re-counted at send time).`);
|
|
1086
|
+
if (resp.fires_automatically) {
|
|
1087
|
+
console.log(` It will fire on its own. Cancel with: flowiq bc scheduled cancel ${orgId} ${resp.queue_id}`);
|
|
1088
|
+
} else {
|
|
1089
|
+
console.log(` ⚠ PARKED awaiting approval — it will NOT fire until you run:`);
|
|
1090
|
+
console.log(` flowiq bc scheduled approve ${orgId} ${resp.queue_id}`);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/** List scheduled broadcasts (queue rows). Read-only. */
|
|
1095
|
+
export async function scheduledList(orgId, opts = {}) {
|
|
1096
|
+
if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
|
|
1097
|
+
let resp;
|
|
1098
|
+
try {
|
|
1099
|
+
resp = await http.post("broadcast", {
|
|
1100
|
+
action: "scheduled-list",
|
|
1101
|
+
organization_id: orgId,
|
|
1102
|
+
status_filter: opts.all ? "all" : (opts.status || "open"),
|
|
1103
|
+
limit: opts.limit,
|
|
1104
|
+
});
|
|
1105
|
+
} catch (e) { console.error(`Scheduled list failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
|
|
1106
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
1107
|
+
if (!resp.scheduled?.length) { console.log(`No ${opts.all ? "" : "open "}scheduled broadcasts on ${resp.organization_name}.`); return; }
|
|
1108
|
+
console.log(`Scheduled broadcasts on ${resp.organization_name}:`);
|
|
1109
|
+
for (const s of resp.scheduled) {
|
|
1110
|
+
const flag = s.stalled ? " ⚠ AWAITING APPROVAL — will NOT fire" : "";
|
|
1111
|
+
console.log("");
|
|
1112
|
+
console.log(` ${s.id}`);
|
|
1113
|
+
console.log(` ${fmtSast(s.scheduled_for)} SAST · ${s.status}${flag}`);
|
|
1114
|
+
console.log(` template ${s.template_name ?? "?"} → tag ${s.tag ?? "?"} · ${s.engine} · via ${s.source}${s.scheduled_by ? ` (${s.scheduled_by})` : ""}`);
|
|
1115
|
+
if (s.audience_at_schedule != null) console.log(` ~${s.audience_at_schedule} recipients at schedule time`);
|
|
1116
|
+
if (s.processed_at) console.log(` fired ${fmtSast(s.processed_at)} SAST${s.broadcast_id ? ` · broadcastId ${s.broadcast_id}` : ""}`);
|
|
1117
|
+
if (s.error_message) console.log(` error: ${s.error_message}`);
|
|
1118
|
+
}
|
|
1119
|
+
console.log("");
|
|
1120
|
+
console.log(` ${resp.count} row(s). Approve: flowiq bc scheduled approve <org> <id> · Cancel: … cancel <org> <id>`);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/** Approve (request → pending) or cancel a scheduled broadcast. */
|
|
1124
|
+
export async function scheduledUpdate(orgId, queueId, op, opts = {}) {
|
|
1125
|
+
if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
|
|
1126
|
+
if (!UUID_RE.test(queueId || "")) { console.error(`Error: "${queueId}" is not a valid queue id (get it from: flowiq bc scheduled list <org>).`); process.exit(1); }
|
|
1127
|
+
let at = null;
|
|
1128
|
+
if (opts.at) {
|
|
1129
|
+
const parsed = parseSastAt(opts.at);
|
|
1130
|
+
if (parsed.error) { console.error(`Error: --at ${parsed.error}`); process.exit(1); }
|
|
1131
|
+
at = parsed.iso;
|
|
1132
|
+
}
|
|
1133
|
+
if (op === "cancel" && !opts.confirm) {
|
|
1134
|
+
console.log(`DRY RUN — would cancel scheduled broadcast ${queueId}. Add --confirm to cancel.`);
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
let resp;
|
|
1138
|
+
try {
|
|
1139
|
+
resp = await http.post("broadcast", {
|
|
1140
|
+
action: "scheduled-update", organization_id: orgId, queue_id: queueId, op, ...(at ? { scheduled_for: at } : {}),
|
|
1141
|
+
});
|
|
1142
|
+
} catch (e) { console.error(`${op} failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
|
|
1143
|
+
if (op === "approve") {
|
|
1144
|
+
console.log(`✓ Approved — ${queueId} is now '${resp.status}' and WILL fire at ${fmtSast(resp.scheduled_for)} SAST.`);
|
|
1145
|
+
} else {
|
|
1146
|
+
console.log(`✓ Cancelled — ${queueId} is now '${resp.status}' and will not fire.`);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1005
1150
|
export async function preview(orgId, opts = {}) {
|
|
1006
1151
|
await runPipeline(orgId, opts, { commitStage: false, isResume: false });
|
|
1007
1152
|
}
|
package/src/commands/org.js
CHANGED
|
@@ -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.`);
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
// `flowiq shopify` / `flowiq woo` — READ-ONLY passthrough to a client org's own
|
|
2
|
+
// store API. You write the request; the server resolves the credentials and
|
|
3
|
+
// refuses anything that isn't a read.
|
|
4
|
+
//
|
|
5
|
+
// flowiq shopify get <org> orders.json --q status=any --q limit=250 --all
|
|
6
|
+
// flowiq shopify gql <org> -q 'query { shop { name currencyCode } }'
|
|
7
|
+
// flowiq woo get <org> orders --q status=completed --all
|
|
8
|
+
//
|
|
9
|
+
// OUTPUT CONTRACT (why this is jq-friendly by default):
|
|
10
|
+
// stdout = the API payload, and nothing else.
|
|
11
|
+
// stderr = the one-line metadata banner (pages, count, rate limit, timing).
|
|
12
|
+
// So `flowiq shopify get <org> orders.json | jq '.orders[].name'` just works,
|
|
13
|
+
// and the banner still shows up on your terminal. `--json` puts the FULL
|
|
14
|
+
// envelope (metadata + data) on stdout instead, for when you want to keep it.
|
|
15
|
+
|
|
16
|
+
import { writeFileSync } from "node:fs";
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { http } from "../http.js";
|
|
19
|
+
|
|
20
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Repeatable `--q key=value`. A repeated key becomes an array, because several
|
|
24
|
+
* store filters are legitimately multi-valued (`--q ids=1 --q ids=2`).
|
|
25
|
+
*/
|
|
26
|
+
export function collectQuery(pair, acc) {
|
|
27
|
+
const idx = pair.indexOf("=");
|
|
28
|
+
if (idx < 1) throw new Error(`Expected key=value (got "${pair}")`);
|
|
29
|
+
const key = pair.slice(0, idx);
|
|
30
|
+
const value = pair.slice(idx + 1);
|
|
31
|
+
if (key in acc) {
|
|
32
|
+
acc[key] = Array.isArray(acc[key]) ? acc[key].concat(value) : [acc[key], value];
|
|
33
|
+
} else {
|
|
34
|
+
acc[key] = value;
|
|
35
|
+
}
|
|
36
|
+
return acc;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Repeatable `--var key=value` for GraphQL variables (JSON value if it parses). */
|
|
40
|
+
export function collectVar(pair, acc) {
|
|
41
|
+
const idx = pair.indexOf("=");
|
|
42
|
+
if (idx < 1) throw new Error(`Expected key=value (got "${pair}")`);
|
|
43
|
+
const key = pair.slice(0, idx);
|
|
44
|
+
const raw = pair.slice(idx + 1);
|
|
45
|
+
try { acc[key] = JSON.parse(raw); } catch { acc[key] = raw; }
|
|
46
|
+
return acc;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function requireOrg(orgId) {
|
|
50
|
+
if (!UUID_RE.test(orgId || "")) {
|
|
51
|
+
console.error(`Error: "${orgId}" is not a valid organization UUID.`);
|
|
52
|
+
console.error("Find it with: flowiq org info <organization_id>");
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Read a query from --query-file, an inline flag, or piped stdin. */
|
|
58
|
+
function resolveQueryText(opts) {
|
|
59
|
+
if (opts.queryFile) {
|
|
60
|
+
try {
|
|
61
|
+
return readFileSync(opts.queryFile, "utf8");
|
|
62
|
+
} catch (e) {
|
|
63
|
+
console.error(`Could not read --query-file ${opts.queryFile}: ${e.message}`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (opts.query) return opts.query;
|
|
68
|
+
if (!process.stdin.isTTY) {
|
|
69
|
+
try {
|
|
70
|
+
const piped = readFileSync(0, "utf8");
|
|
71
|
+
if (piped.trim()) return piped;
|
|
72
|
+
} catch { /* nothing piped */ }
|
|
73
|
+
}
|
|
74
|
+
console.error("Error: pass a query with -q/--query, --query-file <path>, or on stdin.");
|
|
75
|
+
console.error("Example: flowiq shopify gql <org> -q 'query { shop { name } }'");
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function banner(resp) {
|
|
80
|
+
const bits = [];
|
|
81
|
+
bits.push(`${resp.organization?.name ?? "?"} · ${resp.platform}${resp.mode === "graphql" ? " graphql" : ""}`);
|
|
82
|
+
if (resp.count != null) bits.push(`${resp.count} record${resp.count === 1 ? "" : "s"}`);
|
|
83
|
+
if (resp.pages > 1) bits.push(`${resp.pages} pages`);
|
|
84
|
+
if (resp.total_pages) bits.push(`of ${resp.total_pages} available`);
|
|
85
|
+
if (resp.rate_limit) bits.push(`api calls ${resp.rate_limit}`);
|
|
86
|
+
if (resp.cost?.throttleStatus) {
|
|
87
|
+
const t = resp.cost.throttleStatus;
|
|
88
|
+
bits.push(`cost ${resp.cost.actualQueryCost ?? "?"} · ${Math.round(t.currentlyAvailable)}/${t.maximumAvailable} left`);
|
|
89
|
+
}
|
|
90
|
+
if (resp.auth_mode === "query") bits.push("auth via query string (host stripped the header)");
|
|
91
|
+
bits.push(`${resp.duration_ms}ms`);
|
|
92
|
+
console.error(`→ ${bits.join(" · ")}`);
|
|
93
|
+
|
|
94
|
+
if (resp.truncated) {
|
|
95
|
+
console.error(`⚠ TRUNCATED — ${resp.truncated_reason}. Not all records were returned.`);
|
|
96
|
+
console.error(" Narrow the query, or raise --max-pages and page through with the store's own cursor.");
|
|
97
|
+
}
|
|
98
|
+
if (resp.graphql_errors?.length) {
|
|
99
|
+
console.error(`⚠ GraphQL returned ${resp.graphql_errors.length} error(s):`);
|
|
100
|
+
for (const err of resp.graphql_errors.slice(0, 5)) {
|
|
101
|
+
console.error(` ${err.message}${err.path ? ` (at ${Array.isArray(err.path) ? err.path.join(".") : err.path})` : ""}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Print a flat array of objects as a table. Only ever a RENDERING of whatever
|
|
108
|
+
* the API returned — it never changes, filters or reshapes the data, so
|
|
109
|
+
* `--table` and the default JSON are the same answer in two skins.
|
|
110
|
+
*/
|
|
111
|
+
function renderTable(rows) {
|
|
112
|
+
if (!Array.isArray(rows) || rows.length === 0) return null;
|
|
113
|
+
const flat = rows.map((r) => {
|
|
114
|
+
if (r === null || typeof r !== "object" || Array.isArray(r)) return { value: r };
|
|
115
|
+
const o = {};
|
|
116
|
+
for (const [k, v] of Object.entries(r)) {
|
|
117
|
+
o[k] = v === null || v === undefined ? ""
|
|
118
|
+
: typeof v === "object" ? (Array.isArray(v) ? `[${v.length}]` : "{…}")
|
|
119
|
+
: String(v);
|
|
120
|
+
}
|
|
121
|
+
return o;
|
|
122
|
+
});
|
|
123
|
+
const cols = [...new Set(flat.flatMap((r) => Object.keys(r)))];
|
|
124
|
+
if (!cols.length) return null;
|
|
125
|
+
const w = {};
|
|
126
|
+
for (const c of cols) w[c] = Math.min(48, Math.max(c.length, ...flat.map((r) => (r[c] ?? "").length)));
|
|
127
|
+
const cut = (s, n) => (s.length > n ? s.slice(0, n - 1) + "…" : s.padEnd(n));
|
|
128
|
+
const lines = [cols.map((c) => cut(c.toUpperCase(), w[c])).join(" ")];
|
|
129
|
+
lines.push(cols.map((c) => "─".repeat(w[c])).join(" "));
|
|
130
|
+
for (const r of flat) lines.push(cols.map((c) => cut(r[c] ?? "", w[c])).join(" "));
|
|
131
|
+
return lines.join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The single array in a Shopify-style `{orders: [...]}` wrapper, or the array itself. */
|
|
135
|
+
function primaryArray(data) {
|
|
136
|
+
if (Array.isArray(data)) return data;
|
|
137
|
+
if (data && typeof data === "object") {
|
|
138
|
+
const keys = Object.keys(data).filter((k) => Array.isArray(data[k]));
|
|
139
|
+
if (keys.length === 1) return data[keys[0]];
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function emit(resp, opts) {
|
|
145
|
+
banner(resp);
|
|
146
|
+
const payload = opts.json ? resp : resp.data;
|
|
147
|
+
|
|
148
|
+
if (opts.table && !opts.json) {
|
|
149
|
+
const rows = primaryArray(resp.data);
|
|
150
|
+
const table = rows ? renderTable(rows) : renderTable([resp.data]);
|
|
151
|
+
if (table) {
|
|
152
|
+
if (opts.out) { writeFileSync(opts.out, table + "\n"); console.error(`✓ wrote ${opts.out}`); return; }
|
|
153
|
+
console.log(table);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
console.error("(--table: response is not a flat list; printing JSON)");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const text = JSON.stringify(payload, null, opts.compact ? 0 : 2);
|
|
160
|
+
if (opts.out) {
|
|
161
|
+
writeFileSync(opts.out, text);
|
|
162
|
+
console.error(`✓ wrote ${opts.out} (${text.length.toLocaleString()} chars)`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
console.log(text);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function send(payload, opts) {
|
|
169
|
+
let resp;
|
|
170
|
+
try {
|
|
171
|
+
resp = await http.post("store-api", payload);
|
|
172
|
+
} catch (e) {
|
|
173
|
+
console.error(`Request failed: ${e.message}`);
|
|
174
|
+
if (e.body?.upstream_status) console.error(` upstream HTTP ${e.body.upstream_status}`);
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
emit(resp, opts);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// shopify
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
export async function shopifyGet(orgId, path, opts = {}) {
|
|
185
|
+
requireOrg(orgId);
|
|
186
|
+
await send({
|
|
187
|
+
platform: "shopify",
|
|
188
|
+
organization_id: orgId,
|
|
189
|
+
mode: "rest",
|
|
190
|
+
path,
|
|
191
|
+
query: opts.q || {},
|
|
192
|
+
all: !!opts.all,
|
|
193
|
+
max_pages: opts.maxPages ? Number(opts.maxPages) : undefined,
|
|
194
|
+
api_version: opts.apiVersion,
|
|
195
|
+
}, opts);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function shopifyGql(orgId, opts = {}) {
|
|
199
|
+
requireOrg(orgId);
|
|
200
|
+
const query = resolveQueryText(opts);
|
|
201
|
+
await send({
|
|
202
|
+
platform: "shopify",
|
|
203
|
+
organization_id: orgId,
|
|
204
|
+
mode: "graphql",
|
|
205
|
+
graphql: { query, variables: opts.var && Object.keys(opts.var).length ? opts.var : undefined },
|
|
206
|
+
api_version: opts.apiVersion,
|
|
207
|
+
}, opts);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// Recipes — NOT wrappers.
|
|
212
|
+
//
|
|
213
|
+
// These answer the two questions people actually ask ("what's the stock on X",
|
|
214
|
+
// "did this order ship") which otherwise take 3-4 chained calls and a manual
|
|
215
|
+
// id-join. They are composed of THE SAME `shopify get` passthrough calls you
|
|
216
|
+
// could type yourself, and they PRINT EVERY CALL THEY MAKE as a runnable
|
|
217
|
+
// command — so the raw API stays the interface, and anyone (or any agent)
|
|
218
|
+
// reading the output can copy those lines, change them, and go further than
|
|
219
|
+
// the recipe does. Nothing here is expressible only through the recipe.
|
|
220
|
+
// `--raw` dumps the underlying JSON instead of the summary.
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
/** Run a passthrough GET, echoing the equivalent command so it can be reused. */
|
|
224
|
+
async function rawGet(orgId, path, query, { label } = {}) {
|
|
225
|
+
const qs = Object.entries(query || {})
|
|
226
|
+
.flatMap(([k, v]) => (Array.isArray(v) ? v.map((x) => [k, x]) : [[k, v]]))
|
|
227
|
+
.map(([k, v]) => `--q ${k}=${/[ "']/.test(String(v)) ? JSON.stringify(String(v)) : v}`)
|
|
228
|
+
.join(" ");
|
|
229
|
+
console.error(` $ flowiq shopify get <org> ${path}${qs ? " " + qs : ""}${label ? ` # ${label}` : ""}`);
|
|
230
|
+
const resp = await http.post("store-api", {
|
|
231
|
+
platform: "shopify", organization_id: orgId, mode: "rest", path, query: query || {},
|
|
232
|
+
});
|
|
233
|
+
return resp.data;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function shopifyStock(orgId, search, opts = {}) {
|
|
237
|
+
requireOrg(orgId);
|
|
238
|
+
if (!search || !String(search).trim()) {
|
|
239
|
+
console.error('Error: give me something to look for, e.g. flowiq shopify stock <org> "olive oil"');
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
console.error(`Looking up stock for "${search}" — the calls being made:`);
|
|
243
|
+
let products, levels, locations;
|
|
244
|
+
try {
|
|
245
|
+
// 1. name → products + variants (variants carry inventory_item_id)
|
|
246
|
+
const p = await rawGet(orgId, "products.json", { title: search, limit: 50 }, { label: "find the product" });
|
|
247
|
+
products = (p?.products || []).filter((x) =>
|
|
248
|
+
String(x.title).toLowerCase().includes(String(search).toLowerCase()));
|
|
249
|
+
if (!products.length) {
|
|
250
|
+
const all = await rawGet(orgId, "products.json", { limit: 250, fields: "id,title,variants,status" },
|
|
251
|
+
{ label: "no title match — scanning the catalogue" });
|
|
252
|
+
products = (all?.products || []).filter((x) =>
|
|
253
|
+
String(x.title).toLowerCase().includes(String(search).toLowerCase()));
|
|
254
|
+
}
|
|
255
|
+
if (!products.length) {
|
|
256
|
+
console.error(`\nNothing matched "${search}". This searches product TITLES only — ` +
|
|
257
|
+
`the passthrough can query anything else Shopify exposes.`);
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
260
|
+
const itemIds = products.flatMap((x) => (x.variants || []).map((v) => v.inventory_item_id)).filter(Boolean);
|
|
261
|
+
// 2. inventory_item_id → per-location levels 3. location_id → names
|
|
262
|
+
levels = itemIds.length
|
|
263
|
+
? (await rawGet(orgId, "inventory_levels.json",
|
|
264
|
+
{ inventory_item_ids: itemIds.slice(0, 50).join(","), limit: 250 },
|
|
265
|
+
{ label: "per-location levels" }))?.inventory_levels || []
|
|
266
|
+
: [];
|
|
267
|
+
locations = (await rawGet(orgId, "locations.json", { fields: "id,name" },
|
|
268
|
+
{ label: "turn location ids into names" }))?.locations || [];
|
|
269
|
+
} catch (e) {
|
|
270
|
+
console.error(`\nRequest failed: ${e.message}`);
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (opts.raw) { console.log(JSON.stringify({ products, inventory_levels: levels, locations }, null, 2)); return; }
|
|
275
|
+
|
|
276
|
+
const locName = new Map(locations.map((l) => [l.id, l.name]));
|
|
277
|
+
const byItem = new Map();
|
|
278
|
+
for (const l of levels) {
|
|
279
|
+
if (!byItem.has(l.inventory_item_id)) byItem.set(l.inventory_item_id, []);
|
|
280
|
+
byItem.get(l.inventory_item_id).push(l);
|
|
281
|
+
}
|
|
282
|
+
const rows = [];
|
|
283
|
+
for (const p of products) {
|
|
284
|
+
for (const v of p.variants || []) {
|
|
285
|
+
const ls = byItem.get(v.inventory_item_id) || [];
|
|
286
|
+
const per = ls.map((l) => {
|
|
287
|
+
// `available: null` is Shopify saying "this item is not TRACKED at this
|
|
288
|
+
// location" — not zero, and not an error. Say so, rather than printing
|
|
289
|
+
// null and letting the reader guess.
|
|
290
|
+
const qty = l.available === null || l.available === undefined ? "not tracked" : String(l.available);
|
|
291
|
+
return `${locName.get(l.location_id) || l.location_id}: ${qty}`;
|
|
292
|
+
});
|
|
293
|
+
rows.push({
|
|
294
|
+
product: p.title,
|
|
295
|
+
variant: v.title === "Default Title" ? "—" : v.title,
|
|
296
|
+
sku: v.sku || "",
|
|
297
|
+
price: v.price ?? "",
|
|
298
|
+
policy: v.inventory_policy === "continue" ? "oversell ok" : "stop at 0",
|
|
299
|
+
total: v.inventory_quantity ?? "",
|
|
300
|
+
per_location: per.length ? per.join(" · ") : "(no level rows — untracked)",
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
console.error("");
|
|
305
|
+
console.log(renderTable(rows) ?? "(no variants)");
|
|
306
|
+
console.error(`\n${products.length} product(s) · ${rows.length} variant(s). ` +
|
|
307
|
+
`"not tracked" means Shopify holds no inventory record at that location — it is not a zero.`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export async function shopifyOrder(orgId, ref, opts = {}) {
|
|
311
|
+
requireOrg(orgId);
|
|
312
|
+
const name = String(ref).trim();
|
|
313
|
+
console.error(`Looking up order ${name} — the calls being made:`);
|
|
314
|
+
let order;
|
|
315
|
+
try {
|
|
316
|
+
if (/^\d{10,}$/.test(name)) {
|
|
317
|
+
order = (await rawGet(orgId, `orders/${name}.json`, {}, { label: "by Shopify order id" }))?.order;
|
|
318
|
+
} else {
|
|
319
|
+
const r = await rawGet(orgId, "orders.json",
|
|
320
|
+
{ name, status: "any", limit: 5 }, { label: "by order name" });
|
|
321
|
+
order = (r?.orders || [])[0];
|
|
322
|
+
}
|
|
323
|
+
} catch (e) {
|
|
324
|
+
console.error(`\nRequest failed: ${e.message}`);
|
|
325
|
+
process.exit(1);
|
|
326
|
+
}
|
|
327
|
+
if (!order) {
|
|
328
|
+
console.error(`\nNo order matching "${name}". Note Shopify matches the ORDER NAME (#14728), not the ` +
|
|
329
|
+
`confirmation number customers are shown on the thank-you page.`);
|
|
330
|
+
process.exit(1);
|
|
331
|
+
}
|
|
332
|
+
if (opts.raw) { console.log(JSON.stringify(order, null, 2)); return; }
|
|
333
|
+
|
|
334
|
+
const f = order.fulfillments || [];
|
|
335
|
+
console.error("");
|
|
336
|
+
console.log(`${order.name} ${order.financial_status || "?"} / ${order.fulfillment_status || "unfulfilled"}`);
|
|
337
|
+
console.log(` placed ${order.created_at}`);
|
|
338
|
+
console.log(` total ${order.currency} ${order.total_price}`);
|
|
339
|
+
console.log(` customer ${[order.customer?.first_name, order.customer?.last_name].filter(Boolean).join(" ") || "—"}` +
|
|
340
|
+
`${order.email ? ` · ${order.email}` : ""}`);
|
|
341
|
+
console.log(` items ${(order.line_items || []).map((li) => `${li.quantity}× ${li.title}`).join(", ") || "—"}`);
|
|
342
|
+
if (!f.length) {
|
|
343
|
+
console.log(` shipping nothing fulfilled yet`);
|
|
344
|
+
} else {
|
|
345
|
+
for (const ff of f) {
|
|
346
|
+
console.log(` shipment ${ff.status}` +
|
|
347
|
+
` · ${ff.tracking_company || "no carrier recorded"}` +
|
|
348
|
+
` · ${ff.tracking_number || "no tracking number"}` +
|
|
349
|
+
` · courier status: ${ff.shipment_status || "none reported"}` +
|
|
350
|
+
` · ${(ff.line_items || []).length} item(s)`);
|
|
351
|
+
if (ff.tracking_url) console.log(` ${ff.tracking_url}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
console.error(`\nFull payload: add --raw (or use the passthrough directly — ` +
|
|
355
|
+
`flowiq shopify get <org> orders/${order.id}.json)`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
// woo
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
export async function wooGet(orgId, path, opts = {}) {
|
|
363
|
+
requireOrg(orgId);
|
|
364
|
+
await send({
|
|
365
|
+
platform: "woo",
|
|
366
|
+
organization_id: orgId,
|
|
367
|
+
mode: "rest",
|
|
368
|
+
path,
|
|
369
|
+
namespace: opts.namespace,
|
|
370
|
+
query: opts.q || {},
|
|
371
|
+
all: !!opts.all,
|
|
372
|
+
max_pages: opts.maxPages ? Number(opts.maxPages) : undefined,
|
|
373
|
+
}, opts);
|
|
374
|
+
}
|
package/src/index.js
CHANGED
|
@@ -30,6 +30,7 @@ import * as tagCmd from "./commands/tag.js";
|
|
|
30
30
|
import * as keywordsCmd from "./commands/keywords.js";
|
|
31
31
|
import * as guideCmd from "./commands/guide.js";
|
|
32
32
|
import * as auditCmd from "./commands/audit.js";
|
|
33
|
+
import * as storeApiCmd from "./commands/store-api.js";
|
|
33
34
|
import { maybeNotifyUpdate } from "./update-check.js";
|
|
34
35
|
|
|
35
36
|
// Read version from package.json so it stays in sync with the published npm
|
|
@@ -210,6 +211,11 @@ export function run(argv) {
|
|
|
210
211
|
|
|
211
212
|
// org (read-only org summary for the prompt-builder skill, creds stripped)
|
|
212
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));
|
|
213
219
|
org.command("create")
|
|
214
220
|
.description("Create a NEW organization (org row + admin membership; owner defaults to you)")
|
|
215
221
|
.requiredOption("--name <name>", "organization name")
|
|
@@ -241,7 +247,7 @@ export function run(argv) {
|
|
|
241
247
|
.option("--use-settings-prompt [bool]", "settings.use_settings_prompt (true if bare)")
|
|
242
248
|
.option("--model <model>", "settings.model (e.g. gpt-5.4-mini)")
|
|
243
249
|
.option("--rename <name>", "rename the agent")
|
|
244
|
-
.option("--tool <flag=bool>", "toggle a tool flag (repeatable): woo_order_build/woo_tip_field/woo_order_note_field/view_cart_tool/restock_tool/block_tool_status/postal_code_tool_status/shopify_products_web_chat", agentConfigCmd.collectTool, [])
|
|
250
|
+
.option("--tool <flag=bool>", "toggle a tool flag (repeatable): woo_order_build/woo_tip_field/woo_order_note_field/view_cart_tool/restock_tool/block_tool_status/postal_code_tool_status/shopify_products_web_chat/ticket_tool_status/collapse_product_variants", agentConfigCmd.collectTool, [])
|
|
245
251
|
.option("--discount [bool]", "agent.discount.enabled (true if bare)")
|
|
246
252
|
.option("--test-contact-number <number>", "settings.test_contact_number — the contact `flowiq test` uses (use a FAKE number; \"\" clears it → synthetic fallback)")
|
|
247
253
|
.option("--test-contact-name <name>", "settings.test_contact_name — display name for the test contact (\"\" clears it)")
|
|
@@ -426,6 +432,8 @@ export function run(argv) {
|
|
|
426
432
|
.option("--campaign <name>", "campaign id / config file slug (default: CSV filename / tag)")
|
|
427
433
|
.option("--header-media <url>", "header image/video/doc URL for a media-header template (default: the template's own stored image)")
|
|
428
434
|
.option("--python", "with --tag: force the python /meta-broadcast engine (same as the dashboard's Python toggle). NOTE: any tag send >10 recipients ALWAYS uses python automatically")
|
|
435
|
+
.option("--at <when>", "with --tag: SCHEDULE instead of sending now — \"YYYY-MM-DD HH:MM\" in SAST (e.g. --at \"2026-08-05 09:00\"). Fires automatically; audience is resolved at send time")
|
|
436
|
+
.option("--needs-approval", "with --at: park it awaiting approval (flowiq bc scheduled approve) instead of firing automatically")
|
|
429
437
|
.option("--commit", "actually send (omit to dry-run)")
|
|
430
438
|
.option("--yes", "skip the type-the-campaign-name confirm gate (CI)")
|
|
431
439
|
.option("--force-remap", "ignore the saved mapping and rebuild interactively")
|
|
@@ -445,6 +453,24 @@ export function run(argv) {
|
|
|
445
453
|
.option("--rate <n>", "max messages per second (hard cap 10)", "8")
|
|
446
454
|
.option("--retry-failed", "also re-attempt rows previously marked failed (confirmed failures only)")
|
|
447
455
|
.action((orgId, opts) => broadcastCmd.resume(orgId, opts));
|
|
456
|
+
const scheduled = broadcast.command("scheduled")
|
|
457
|
+
.description("Scheduled broadcasts (queue rows): list / approve / cancel");
|
|
458
|
+
scheduled.command("list <organization_id>")
|
|
459
|
+
.description("List scheduled broadcasts. Default: only OPEN rows (pending + awaiting-approval)")
|
|
460
|
+
.option("--all", "include fired / cancelled / failed rows too")
|
|
461
|
+
.option("--status <s>", "filter to one status (pending|request|completed|cancelled|failed)")
|
|
462
|
+
.option("--limit <n>", "max rows (default 25, max 200)")
|
|
463
|
+
.option("--json", "raw JSON")
|
|
464
|
+
.action((orgId, opts) => broadcastCmd.scheduledList(orgId, opts));
|
|
465
|
+
scheduled.command("approve <organization_id> <queue_id>")
|
|
466
|
+
.description("Approve a parked ('request') scheduled broadcast so it will fire")
|
|
467
|
+
.option("--at <when>", "also re-time it — \"YYYY-MM-DD HH:MM\" SAST")
|
|
468
|
+
.action((orgId, queueId, opts) => broadcastCmd.scheduledUpdate(orgId, queueId, "approve", opts));
|
|
469
|
+
scheduled.command("cancel <organization_id> <queue_id>")
|
|
470
|
+
.description("Cancel a scheduled broadcast so it never fires (dry-run unless --confirm)")
|
|
471
|
+
.option("--confirm", "actually cancel")
|
|
472
|
+
.action((orgId, queueId, opts) => broadcastCmd.scheduledUpdate(orgId, queueId, "cancel", opts));
|
|
473
|
+
|
|
448
474
|
broadcast.command("list-remote <organization_id>")
|
|
449
475
|
.description("List the org's broadcasts newest-first (full broadcastId + template + status + recipients) — the discovery step for `bc status` / `bc retry`")
|
|
450
476
|
.option("--limit <n>", "how many to show (default 25, max 200)")
|
|
@@ -594,6 +620,58 @@ export function run(argv) {
|
|
|
594
620
|
.description("List local keyword snapshots")
|
|
595
621
|
.action(() => keywordsCmd.list());
|
|
596
622
|
|
|
623
|
+
// shopify / woo — READ-ONLY passthrough to the client's own store API.
|
|
624
|
+
// You write the request; creds are resolved server-side and never displayed.
|
|
625
|
+
// stdout is the payload (pipe it to jq); the metadata banner goes to stderr.
|
|
626
|
+
const shopify = program.command("shopify")
|
|
627
|
+
.description("Read-only passthrough to an org's Shopify Admin API (REST + GraphQL). Writes are refused.");
|
|
628
|
+
shopify.command("get <organization_id> <path>")
|
|
629
|
+
.description('GET any Shopify Admin REST endpoint, e.g. `get <org> orders.json --q status=any` (".json" optional)')
|
|
630
|
+
.option("--q <k=v>", "query parameter (repeatable), e.g. --q status=any --q limit=250", storeApiCmd.collectQuery, {})
|
|
631
|
+
.option("--all", "follow Link rel=next and return every page (bounded by --max-pages and a size/time cap)")
|
|
632
|
+
.option("--max-pages <n>", "page limit when using --all (default 10, cap 50)")
|
|
633
|
+
.option("--api-version <ver>", "Shopify Admin API version (default 2024-01)")
|
|
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)")
|
|
636
|
+
.option("--compact", "single-line JSON")
|
|
637
|
+
.option("--out <file>", "write the JSON to a file instead of stdout")
|
|
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));
|
|
649
|
+
shopify.command("gql <organization_id>")
|
|
650
|
+
.description("Run a Shopify Admin GraphQL QUERY (you write it). Any document containing a mutation is refused.")
|
|
651
|
+
.option("-q, --query <graphql>", "the query document, inline")
|
|
652
|
+
.option("--query-file <path>", "read the query document from a file")
|
|
653
|
+
.option("--var <k=v>", "GraphQL variable (repeatable; JSON values parsed)", storeApiCmd.collectVar, {})
|
|
654
|
+
.option("--api-version <ver>", "Shopify Admin API version (default 2024-01)")
|
|
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)")
|
|
657
|
+
.option("--compact", "single-line JSON")
|
|
658
|
+
.option("--out <file>", "write the JSON to a file instead of stdout")
|
|
659
|
+
.action((orgId, opts) => storeApiCmd.shopifyGql(orgId, opts));
|
|
660
|
+
|
|
661
|
+
const woo = program.command("woo")
|
|
662
|
+
.description("Read-only passthrough to an org's WooCommerce REST API. Writes are refused.");
|
|
663
|
+
woo.command("get <organization_id> <path>")
|
|
664
|
+
.description("GET any WooCommerce REST endpoint, e.g. `get <org> orders --q status=completed`")
|
|
665
|
+
.option("--q <k=v>", "query parameter (repeatable), e.g. --q status=completed --q per_page=100", storeApiCmd.collectQuery, {})
|
|
666
|
+
.option("--namespace <ns>", "REST namespace (default wc/v3; also wc-analytics, wp/v2)")
|
|
667
|
+
.option("--all", "page through every result (bounded by --max-pages and a size/time cap)")
|
|
668
|
+
.option("--max-pages <n>", "page limit when using --all (default 10, cap 50)")
|
|
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)")
|
|
671
|
+
.option("--compact", "single-line JSON")
|
|
672
|
+
.option("--out <file>", "write the JSON to a file instead of stdout")
|
|
673
|
+
.action((orgId, path, opts) => storeApiCmd.wooGet(orgId, path, opts));
|
|
674
|
+
|
|
597
675
|
// guide (bundled docs — always match the installed version)
|
|
598
676
|
program.command("guide")
|
|
599
677
|
.description("Read the team guide (how we use this CLI); --reference for the full command reference")
|