@flowapt/flowiq-cli 0.4.3 → 0.4.4
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 +117 -1
- package/TEAM-GUIDE.md +77 -0
- package/package.json +1 -1
- package/src/commands/broadcast.js +145 -0
- package/src/commands/store-api.js +176 -0
- package/src/index.js +61 -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
|
|
@@ -729,6 +755,84 @@ server-side (`auto_synthesized: true`; the response says
|
|
|
729
755
|
broadcast mapping suggestions — pass your own only when you want real slot
|
|
730
756
|
labels (e.g. `"body_params": {"1": "first_name", "2": "order_number"}`).
|
|
731
757
|
|
|
758
|
+
### Store API (READ-ONLY) — `flowiq shopify get|gql` / `flowiq woo get`
|
|
759
|
+
|
|
760
|
+
Write your own request against a client's **own** store API. You supply the
|
|
761
|
+
endpoint (or the GraphQL query); the server resolves that org's credentials and
|
|
762
|
+
makes the call. Credentials never reach your machine and are never printed.
|
|
763
|
+
|
|
764
|
+
**Writes are refused, structurally:**
|
|
765
|
+
|
|
766
|
+
* **REST** — the HTTP method is hardcoded `GET` and is never read from your
|
|
767
|
+
request. Every write in both APIs is POST/PUT/PATCH/DELETE, so "GET only" *is*
|
|
768
|
+
"read only". There is no endpoint allowlist to fall out of date, so any
|
|
769
|
+
endpoint — including ones Shopify or Woo ship tomorrow — works immediately.
|
|
770
|
+
* **GraphQL** — the document is lexed (strings and `#` comments stripped) and
|
|
771
|
+
any `mutation` / `subscription` operation is rejected before the call is made.
|
|
772
|
+
Shopify exposes every Admin write as a mutation, so this rejects the whole
|
|
773
|
+
write surface. The word "mutation" inside a *string* or a *comment* is fine.
|
|
774
|
+
|
|
775
|
+
```bash
|
|
776
|
+
# Shopify REST — any Admin endpoint. The ".json" suffix is optional.
|
|
777
|
+
flowiq shopify get <org_id> orders.json --q status=any --q limit=250
|
|
778
|
+
flowiq shopify get <org_id> products --q limit=5 --q fields=id,title,status
|
|
779
|
+
flowiq shopify get <org_id> inventory_levels.json --q location_ids=123 --all
|
|
780
|
+
flowiq shopify get <org_id> orders/450789469.json # a single record
|
|
781
|
+
|
|
782
|
+
# Shopify GraphQL — you write the query. Inline, from a file, or on stdin.
|
|
783
|
+
flowiq shopify gql <org_id> -q 'query { shop { name currencyCode } }'
|
|
784
|
+
flowiq shopify gql <org_id> --query-file ./q.graphql --var n=50
|
|
785
|
+
cat q.graphql | flowiq shopify gql <org_id>
|
|
786
|
+
|
|
787
|
+
# WooCommerce REST — default namespace wc/v3.
|
|
788
|
+
flowiq woo get <org_id> orders --q status=completed --q per_page=100 --all
|
|
789
|
+
flowiq woo get <org_id> products/1234
|
|
790
|
+
flowiq woo get <org_id> reports/sales --namespace wc/v3 --q period=month
|
|
791
|
+
flowiq woo get <org_id> settings/general --namespace wc/v3
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
**Output contract — stdout is the payload and nothing else**, so it pipes
|
|
795
|
+
straight into `jq`. The metadata banner (record count, pages, rate-limit
|
|
796
|
+
headroom, timing) goes to **stderr**, so you still see it on your terminal:
|
|
797
|
+
|
|
798
|
+
```bash
|
|
799
|
+
flowiq shopify get <org_id> orders.json --q status=any --all | jq -r '.orders[].name'
|
|
800
|
+
flowiq woo get <org_id> orders --all --out ./orders.json # or write to a file
|
|
801
|
+
flowiq shopify get <org_id> shop --json # full envelope incl. metadata
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
Flags:
|
|
805
|
+
|
|
806
|
+
| Flag | Meaning |
|
|
807
|
+
|---|---|
|
|
808
|
+
| `--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. |
|
|
809
|
+
| `--all` | Follow pagination (Shopify's `Link: rel=next`; Woo's `page`) and return every record. |
|
|
810
|
+
| `--max-pages <n>` | Page limit for `--all` (default 10, hard cap 50). |
|
|
811
|
+
| `--namespace <ns>` | Woo REST namespace — default `wc/v3`; also `wc-analytics`, `wp/v2`. |
|
|
812
|
+
| `--api-version <v>` | Shopify Admin API version (default `2024-01`). |
|
|
813
|
+
| `-q, --query` / `--query-file` | The GraphQL document, inline or from a file (stdin also works). |
|
|
814
|
+
| `--var <k=v>` | GraphQL variable, repeatable. JSON values are parsed (`--var ids='["1"]'`). |
|
|
815
|
+
| `--json` | Print the full envelope (metadata + data) instead of just the payload. |
|
|
816
|
+
| `--compact` | Single-line JSON. |
|
|
817
|
+
| `--out <file>` | Write the JSON to a file instead of stdout. |
|
|
818
|
+
|
|
819
|
+
Notes:
|
|
820
|
+
|
|
821
|
+
* **`--all` is bounded and says so.** It stops at `--max-pages`, at a ~3.5MB
|
|
822
|
+
response size, or at a time budget, and then reports `⚠ TRUNCATED — <reason>`
|
|
823
|
+
on stderr. A partial result is never presented as a complete one.
|
|
824
|
+
* **Rate limits are surfaced, not hidden.** REST prints Shopify's
|
|
825
|
+
`api calls 3/40`; GraphQL prints the query cost and remaining points.
|
|
826
|
+
* **GraphQL errors are surfaced.** Shopify returns field errors as HTTP 200 with
|
|
827
|
+
an `errors` array; the CLI prints them rather than letting you read
|
|
828
|
+
`data: null` as "no results".
|
|
829
|
+
* **Orgs behind an IP whitelist / WAF** (`feature_flags.ip_whitelist`, e.g.
|
|
830
|
+
Hydroponic on Wordfence) reject calls from our servers. You get an explicit
|
|
831
|
+
message saying so, not an opaque 403.
|
|
832
|
+
* Every call is written to the audit log (`flowiq audit <org>`) — who, which
|
|
833
|
+
org, which endpoint or query, how many records. **The response body is never
|
|
834
|
+
stored**; the log records what was asked, not the customer data that came back.
|
|
835
|
+
|
|
732
836
|
### Org — `flowiq org create` / `flowiq org info <organization_id>`
|
|
733
837
|
|
|
734
838
|
Create a brand-new organization, or look one up (read-only; raw store/Meta
|
|
@@ -802,10 +906,22 @@ flowiq agent config <organization_id> --test-contact-number 27000000001 --test-c
|
|
|
802
906
|
Settable: `settings.use_settings_prompt`, `settings.model`, agent `--rename`,
|
|
803
907
|
the tool-flag columns (`woo_order_build`, `woo_tip_field`, `woo_order_note_field`,
|
|
804
908
|
`view_cart_tool`, `restock_tool`, `block_tool_status`, `postal_code_tool_status`,
|
|
805
|
-
`shopify_products_web_chat`, `ticket_tool_status
|
|
909
|
+
`shopify_products_web_chat`, `ticket_tool_status`, `collapse_product_variants`),
|
|
910
|
+
`discount.enabled`, and the `flowiq test` contact
|
|
806
911
|
(`settings.test_contact_number` / `settings.test_contact_name`). Anything else is
|
|
807
912
|
rejected; every change is reported before → after.
|
|
808
913
|
|
|
914
|
+
**`--tool collapse_product_variants=true` (added 4 Aug 2026).** Not a tool toggle —
|
|
915
|
+
it changes what `get_product_info` RETURNS. OFF (the default) the result cap counts
|
|
916
|
+
**variant rows**, so on a catalogue with several packaging/size variants per product
|
|
917
|
+
the agent sees only a handful of distinct products and will tell a customer an
|
|
918
|
+
in-stock item "isn't showing". ON, the tool fetches `max*6` rows (cap 300) and
|
|
919
|
+
collapses them to ≤`max` **distinct products**, listing each product's variants
|
|
920
|
+
inline. Measured on African Oils: a 40-row response went from **15–24 distinct
|
|
921
|
+
products to 40**, out of a 334-product catalogue. Costs ~50% more tokens per call.
|
|
922
|
+
Requires ≤12 variants per product for the inline rendering branch — check with
|
|
923
|
+
`SELECT max(c) FROM (SELECT count(*) c FROM products_all WHERE organization_id=… GROUP BY product_id) t`.
|
|
924
|
+
|
|
809
925
|
**Test contact (`--test-contact-number` / `--test-contact-name`, v0.2.9).**
|
|
810
926
|
This is the contact `flowiq test` drives the live agent through (read by
|
|
811
927
|
`api/cli/agent-test.js` as `settings.test_contact_number`). ⚠ **Use a clearly
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -80,8 +80,14 @@ 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
|
+
| 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
|
+
| 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
|
+
| Ask a client's WooCommerce store anything (read-only) | `flowiq woo get <org_id> orders --q status=completed --all` |
|
|
86
|
+
| 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) |
|
|
87
|
+
| 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
88
|
| See an org's agents / create one | `flowiq agent list <org_id>` / `flowiq agent create <org_id> --name "…"` |
|
|
84
89
|
| Change agent model / tool flags | `flowiq agent config <org_id> --model … --tool view_cart_tool=true` |
|
|
90
|
+
| 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
91
|
| Talk to the live agent safely (no real WhatsApp ever sent) | `flowiq test send <org_id> "hi, do you sell X?"` |
|
|
86
92
|
| 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
93
|
| Send a template broadcast to a CSV of people | `flowiq bc map <org_id> --template … --csv …` → `flowiq bc send … ` (dry-run) → `… --commit` |
|
|
@@ -93,6 +99,8 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
93
99
|
| 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
100
|
| 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
101
|
| 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
|
+
| 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
|
+
| 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
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>}}`) |
|
|
97
105
|
| 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
106
|
| 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.** |
|
|
@@ -192,6 +200,75 @@ flowiq tag attributes <org_id> --filter allow_broadcast_true \
|
|
|
192
200
|
`--exclude` names the same tag you're adding, so every run skips the people who
|
|
193
201
|
already have it. Safe to re-run as many times as you like.
|
|
194
202
|
|
|
203
|
+
### Scheduling a broadcast
|
|
204
|
+
|
|
205
|
+
Add `--at` to a tag send and it queues instead of sending. The time is **South
|
|
206
|
+
African time**, always — it does not matter where your laptop thinks it is.
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
flowiq bc send <org_id> --tag 5-aug-batch-01 --template my_template \
|
|
210
|
+
--body param1="Hi {{first_name}}" --at "2026-08-05 09:00" --commit
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
It fires on its own, so there is nothing more to do. Two things worth knowing:
|
|
214
|
+
|
|
215
|
+
- **The audience is worked out when it fires, not now.** Anyone who picks up
|
|
216
|
+
that tag between now and then is included.
|
|
217
|
+
- It goes out **within about 3 minutes** of the time you set, not to the second.
|
|
218
|
+
|
|
219
|
+
Check on it, change your mind, or hand it to someone else to sign off:
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
flowiq bc scheduled list <org_id> # what's queued
|
|
223
|
+
flowiq bc scheduled cancel <org_id> <queue_id> --confirm
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
If you'd rather someone approved it before it goes out, add `--needs-approval`
|
|
227
|
+
when scheduling. **It then will not send until somebody runs
|
|
228
|
+
`flowiq bc scheduled approve <org_id> <queue_id>`** — `scheduled list` marks it
|
|
229
|
+
`AWAITING APPROVAL — will NOT fire` so it can't be forgotten. (Two broadcasts
|
|
230
|
+
scheduled in the dashboard were missed exactly this way.)
|
|
231
|
+
|
|
232
|
+
### Asking a client's store a question directly
|
|
233
|
+
|
|
234
|
+
When you need something the dashboard doesn't show — "how many orders are still
|
|
235
|
+
unfulfilled?", "what does this product's inventory actually look like per
|
|
236
|
+
location?", "which orders used that discount code?" — you can ask the client's
|
|
237
|
+
own store, in their own API, without a Shopify or Woo login.
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
# Shopify — any Admin REST endpoint. ".json" is optional.
|
|
241
|
+
flowiq shopify get <org_id> orders.json --q status=any --q financial_status=paid --q limit=250
|
|
242
|
+
|
|
243
|
+
# WooCommerce — same idea.
|
|
244
|
+
flowiq woo get <org_id> orders --q status=processing --all
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
**You cannot break anything with this.** Reads are the only thing it can do:
|
|
248
|
+
REST calls are locked to `GET`, and a GraphQL document containing a `mutation`
|
|
249
|
+
is refused before it is sent. There is no "are you sure?" because there is
|
|
250
|
+
nothing to be sure about.
|
|
251
|
+
|
|
252
|
+
The answer comes back as JSON on stdout and a summary line on stderr, so it
|
|
253
|
+
pipes into other tools cleanly:
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
flowiq shopify get <org_id> orders.json --q status=any --all | jq -r '.orders[].name'
|
|
257
|
+
flowiq woo get <org_id> products --all --out ./products.json
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Three things worth knowing:
|
|
261
|
+
|
|
262
|
+
- **`--all` pages through everything**, but it stops at a sane limit and tells
|
|
263
|
+
you when it did (`⚠ TRUNCATED — stopped at --max-pages 10`). If you see that,
|
|
264
|
+
narrow the query rather than assuming you have the full set.
|
|
265
|
+
- **If REST can't express your question, write GraphQL** —
|
|
266
|
+
`flowiq shopify gql <org_id> -q 'query { … }'` lets you pick exactly the
|
|
267
|
+
fields and nesting you want. Put a long query in a file and use
|
|
268
|
+
`--query-file`.
|
|
269
|
+
- **Some stores sit behind a firewall** (Hydroponic's, for one) and will refuse
|
|
270
|
+
our call. You'll get a message saying exactly that.
|
|
271
|
+
|
|
195
272
|
## Keys, rotation, logging out
|
|
196
273
|
|
|
197
274
|
- **`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.4",
|
|
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
|
}
|
|
@@ -0,0 +1,176 @@
|
|
|
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
|
+
function emit(resp, opts) {
|
|
107
|
+
banner(resp);
|
|
108
|
+
const payload = opts.json ? resp : resp.data;
|
|
109
|
+
const text = JSON.stringify(payload, null, opts.compact ? 0 : 2);
|
|
110
|
+
if (opts.out) {
|
|
111
|
+
writeFileSync(opts.out, text);
|
|
112
|
+
console.error(`✓ wrote ${opts.out} (${text.length.toLocaleString()} chars)`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
console.log(text);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function send(payload, opts) {
|
|
119
|
+
let resp;
|
|
120
|
+
try {
|
|
121
|
+
resp = await http.post("store-api", payload);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
console.error(`Request failed: ${e.message}`);
|
|
124
|
+
if (e.body?.upstream_status) console.error(` upstream HTTP ${e.body.upstream_status}`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
emit(resp, opts);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// shopify
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
export async function shopifyGet(orgId, path, opts = {}) {
|
|
135
|
+
requireOrg(orgId);
|
|
136
|
+
await send({
|
|
137
|
+
platform: "shopify",
|
|
138
|
+
organization_id: orgId,
|
|
139
|
+
mode: "rest",
|
|
140
|
+
path,
|
|
141
|
+
query: opts.q || {},
|
|
142
|
+
all: !!opts.all,
|
|
143
|
+
max_pages: opts.maxPages ? Number(opts.maxPages) : undefined,
|
|
144
|
+
api_version: opts.apiVersion,
|
|
145
|
+
}, opts);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function shopifyGql(orgId, opts = {}) {
|
|
149
|
+
requireOrg(orgId);
|
|
150
|
+
const query = resolveQueryText(opts);
|
|
151
|
+
await send({
|
|
152
|
+
platform: "shopify",
|
|
153
|
+
organization_id: orgId,
|
|
154
|
+
mode: "graphql",
|
|
155
|
+
graphql: { query, variables: opts.var && Object.keys(opts.var).length ? opts.var : undefined },
|
|
156
|
+
api_version: opts.apiVersion,
|
|
157
|
+
}, opts);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// woo
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
export async function wooGet(orgId, path, opts = {}) {
|
|
165
|
+
requireOrg(orgId);
|
|
166
|
+
await send({
|
|
167
|
+
platform: "woo",
|
|
168
|
+
organization_id: orgId,
|
|
169
|
+
mode: "rest",
|
|
170
|
+
path,
|
|
171
|
+
namespace: opts.namespace,
|
|
172
|
+
query: opts.q || {},
|
|
173
|
+
all: !!opts.all,
|
|
174
|
+
max_pages: opts.maxPages ? Number(opts.maxPages) : undefined,
|
|
175
|
+
}, opts);
|
|
176
|
+
}
|
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
|
|
@@ -241,7 +242,7 @@ export function run(argv) {
|
|
|
241
242
|
.option("--use-settings-prompt [bool]", "settings.use_settings_prompt (true if bare)")
|
|
242
243
|
.option("--model <model>", "settings.model (e.g. gpt-5.4-mini)")
|
|
243
244
|
.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, [])
|
|
245
|
+
.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
246
|
.option("--discount [bool]", "agent.discount.enabled (true if bare)")
|
|
246
247
|
.option("--test-contact-number <number>", "settings.test_contact_number — the contact `flowiq test` uses (use a FAKE number; \"\" clears it → synthetic fallback)")
|
|
247
248
|
.option("--test-contact-name <name>", "settings.test_contact_name — display name for the test contact (\"\" clears it)")
|
|
@@ -426,6 +427,8 @@ export function run(argv) {
|
|
|
426
427
|
.option("--campaign <name>", "campaign id / config file slug (default: CSV filename / tag)")
|
|
427
428
|
.option("--header-media <url>", "header image/video/doc URL for a media-header template (default: the template's own stored image)")
|
|
428
429
|
.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")
|
|
430
|
+
.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")
|
|
431
|
+
.option("--needs-approval", "with --at: park it awaiting approval (flowiq bc scheduled approve) instead of firing automatically")
|
|
429
432
|
.option("--commit", "actually send (omit to dry-run)")
|
|
430
433
|
.option("--yes", "skip the type-the-campaign-name confirm gate (CI)")
|
|
431
434
|
.option("--force-remap", "ignore the saved mapping and rebuild interactively")
|
|
@@ -445,6 +448,24 @@ export function run(argv) {
|
|
|
445
448
|
.option("--rate <n>", "max messages per second (hard cap 10)", "8")
|
|
446
449
|
.option("--retry-failed", "also re-attempt rows previously marked failed (confirmed failures only)")
|
|
447
450
|
.action((orgId, opts) => broadcastCmd.resume(orgId, opts));
|
|
451
|
+
const scheduled = broadcast.command("scheduled")
|
|
452
|
+
.description("Scheduled broadcasts (queue rows): list / approve / cancel");
|
|
453
|
+
scheduled.command("list <organization_id>")
|
|
454
|
+
.description("List scheduled broadcasts. Default: only OPEN rows (pending + awaiting-approval)")
|
|
455
|
+
.option("--all", "include fired / cancelled / failed rows too")
|
|
456
|
+
.option("--status <s>", "filter to one status (pending|request|completed|cancelled|failed)")
|
|
457
|
+
.option("--limit <n>", "max rows (default 25, max 200)")
|
|
458
|
+
.option("--json", "raw JSON")
|
|
459
|
+
.action((orgId, opts) => broadcastCmd.scheduledList(orgId, opts));
|
|
460
|
+
scheduled.command("approve <organization_id> <queue_id>")
|
|
461
|
+
.description("Approve a parked ('request') scheduled broadcast so it will fire")
|
|
462
|
+
.option("--at <when>", "also re-time it — \"YYYY-MM-DD HH:MM\" SAST")
|
|
463
|
+
.action((orgId, queueId, opts) => broadcastCmd.scheduledUpdate(orgId, queueId, "approve", opts));
|
|
464
|
+
scheduled.command("cancel <organization_id> <queue_id>")
|
|
465
|
+
.description("Cancel a scheduled broadcast so it never fires (dry-run unless --confirm)")
|
|
466
|
+
.option("--confirm", "actually cancel")
|
|
467
|
+
.action((orgId, queueId, opts) => broadcastCmd.scheduledUpdate(orgId, queueId, "cancel", opts));
|
|
468
|
+
|
|
448
469
|
broadcast.command("list-remote <organization_id>")
|
|
449
470
|
.description("List the org's broadcasts newest-first (full broadcastId + template + status + recipients) — the discovery step for `bc status` / `bc retry`")
|
|
450
471
|
.option("--limit <n>", "how many to show (default 25, max 200)")
|
|
@@ -594,6 +615,45 @@ export function run(argv) {
|
|
|
594
615
|
.description("List local keyword snapshots")
|
|
595
616
|
.action(() => keywordsCmd.list());
|
|
596
617
|
|
|
618
|
+
// shopify / woo — READ-ONLY passthrough to the client's own store API.
|
|
619
|
+
// You write the request; creds are resolved server-side and never displayed.
|
|
620
|
+
// stdout is the payload (pipe it to jq); the metadata banner goes to stderr.
|
|
621
|
+
const shopify = program.command("shopify")
|
|
622
|
+
.description("Read-only passthrough to an org's Shopify Admin API (REST + GraphQL). Writes are refused.");
|
|
623
|
+
shopify.command("get <organization_id> <path>")
|
|
624
|
+
.description('GET any Shopify Admin REST endpoint, e.g. `get <org> orders.json --q status=any` (".json" optional)')
|
|
625
|
+
.option("--q <k=v>", "query parameter (repeatable), e.g. --q status=any --q limit=250", storeApiCmd.collectQuery, {})
|
|
626
|
+
.option("--all", "follow Link rel=next and return every page (bounded by --max-pages and a size/time cap)")
|
|
627
|
+
.option("--max-pages <n>", "page limit when using --all (default 10, cap 50)")
|
|
628
|
+
.option("--api-version <ver>", "Shopify Admin API version (default 2024-01)")
|
|
629
|
+
.option("--json", "print the full envelope (metadata + data) instead of just the payload")
|
|
630
|
+
.option("--compact", "single-line JSON")
|
|
631
|
+
.option("--out <file>", "write the JSON to a file instead of stdout")
|
|
632
|
+
.action((orgId, path, opts) => storeApiCmd.shopifyGet(orgId, path, opts));
|
|
633
|
+
shopify.command("gql <organization_id>")
|
|
634
|
+
.description("Run a Shopify Admin GraphQL QUERY (you write it). Any document containing a mutation is refused.")
|
|
635
|
+
.option("-q, --query <graphql>", "the query document, inline")
|
|
636
|
+
.option("--query-file <path>", "read the query document from a file")
|
|
637
|
+
.option("--var <k=v>", "GraphQL variable (repeatable; JSON values parsed)", storeApiCmd.collectVar, {})
|
|
638
|
+
.option("--api-version <ver>", "Shopify Admin API version (default 2024-01)")
|
|
639
|
+
.option("--json", "print the full envelope (metadata + data) instead of just the payload")
|
|
640
|
+
.option("--compact", "single-line JSON")
|
|
641
|
+
.option("--out <file>", "write the JSON to a file instead of stdout")
|
|
642
|
+
.action((orgId, opts) => storeApiCmd.shopifyGql(orgId, opts));
|
|
643
|
+
|
|
644
|
+
const woo = program.command("woo")
|
|
645
|
+
.description("Read-only passthrough to an org's WooCommerce REST API. Writes are refused.");
|
|
646
|
+
woo.command("get <organization_id> <path>")
|
|
647
|
+
.description("GET any WooCommerce REST endpoint, e.g. `get <org> orders --q status=completed`")
|
|
648
|
+
.option("--q <k=v>", "query parameter (repeatable), e.g. --q status=completed --q per_page=100", storeApiCmd.collectQuery, {})
|
|
649
|
+
.option("--namespace <ns>", "REST namespace (default wc/v3; also wc-analytics, wp/v2)")
|
|
650
|
+
.option("--all", "page through every result (bounded by --max-pages and a size/time cap)")
|
|
651
|
+
.option("--max-pages <n>", "page limit when using --all (default 10, cap 50)")
|
|
652
|
+
.option("--json", "print the full envelope (metadata + data) instead of just the payload")
|
|
653
|
+
.option("--compact", "single-line JSON")
|
|
654
|
+
.option("--out <file>", "write the JSON to a file instead of stdout")
|
|
655
|
+
.action((orgId, path, opts) => storeApiCmd.wooGet(orgId, path, opts));
|
|
656
|
+
|
|
597
657
|
// guide (bundled docs — always match the installed version)
|
|
598
658
|
program.command("guide")
|
|
599
659
|
.description("Read the team guide (how we use this CLI); --reference for the full command reference")
|