@flowapt/flowiq-cli 0.2.9 → 0.3.1
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 +34 -5
- package/TEAM-GUIDE.md +7 -0
- package/package.json +1 -1
- package/src/commands/segments.js +100 -22
- package/src/config.js +1 -1
- package/src/index.js +9 -1
- package/src/update-check.js +132 -0
package/README.md
CHANGED
|
@@ -230,6 +230,14 @@ throttle *and* your brake. Tags only; the send is `broadcast send --tag` (one
|
|
|
230
230
|
batch at a time) or the dashboard broadcaster.
|
|
231
231
|
|
|
232
232
|
```bash
|
|
233
|
+
# Broadcast list → N even TAGGED COHORTS in ONE command (v0.3.1 — no id file, no DB access):
|
|
234
|
+
flowiq seg plan <org_id> --tag-prefix bcast --from-attribute allow_broadcast_true --split 3 --seed 42
|
|
235
|
+
# every broadcast-opted-in contact (minus archived/blocked), shuffled into 3
|
|
236
|
+
# BALANCED cohorts → bcast-batch-01/02/03. --from-attribute also takes
|
|
237
|
+
# all_contacts | allow_broadcast_false | no_broadcast_permission (same selector
|
|
238
|
+
# as `tag attributes --filter`). --split N (2..100) replaces --batch-size;
|
|
239
|
+
# --seed makes the shuffle reproducible, --ordered keeps server order (no shuffle).
|
|
240
|
+
|
|
233
241
|
# Cohort by ORDER-COUNT CRITERIA (v0.2.6 — server-resolved, no id file needed):
|
|
234
242
|
flowiq seg plan <org_id> --tag-prefix repeat-60d --min-orders 2 --window 60d
|
|
235
243
|
# "everyone with 2+ orders in the last 60 days" — counted from the captured
|
|
@@ -252,11 +260,19 @@ flowiq seg list <org_id> --prefix repeat-60d # VERIFY: tag → count
|
|
|
252
260
|
flowiq seg untag <org_id> repeat-60d --commit --confirm # ROLLBACK (its own tags only)
|
|
253
261
|
```
|
|
254
262
|
|
|
255
|
-
- **
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
263
|
+
- **Cohort sources:** `--from-attribute <filter>` (a broadcast-permission
|
|
264
|
+
audience — `all_contacts` / `allow_broadcast_true` / `allow_broadcast_false`
|
|
265
|
+
/ `no_broadcast_permission`, resolved server-side past the 1000-row cap);
|
|
266
|
+
`--min-orders [--window]` (the windowed order count the app's Advanced Tagging
|
|
267
|
+
UI cannot express — its Min Orders filter is lifetime-only); `--bought`
|
|
268
|
+
(product line-items); or explicit **contact UUIDs** (a snapshot JSON's
|
|
269
|
+
`contact_ids[]` or a plain file). Exactly one per plan.
|
|
270
|
+
- **`--split N` = exactly N even cohorts** (2..100), an alternative to
|
|
271
|
+
`--batch-size` fixed chunks. It **shuffles by default** so the cohorts are
|
|
272
|
+
balanced (not skewed by contact age / signup order); `--seed S` makes the
|
|
273
|
+
shuffle reproducible, `--ordered` keeps server order. `--from-attribute
|
|
274
|
+
allow_broadcast_true --split 3` is the "whole broadcast list → 3 balanced
|
|
275
|
+
tagged cohorts" one-liner — no id file, no database access.
|
|
260
276
|
- Apply is **append-only** — it never touches a contact's other tags, names,
|
|
261
277
|
or anything else, and never double-adds.
|
|
262
278
|
- Point-in-time warning: the plan snapshots the cohort at plan time; re-run
|
|
@@ -645,6 +661,7 @@ version you have installed.
|
|
|
645
661
|
|---|---|---|
|
|
646
662
|
| `FLOWIQ_API_URL` | `https://api.flowiq.live` | Override the API host (local dev, staging). |
|
|
647
663
|
| `FLOWIQ_TOKEN` | (saved in `~/.config/flowiq/auth.json`) | Override the auth token, useful for CI. |
|
|
664
|
+
| `FLOWIQ_NO_UPDATE_CHECK` | (unset) | Set to `1` to silence the "you're behind" update hint (also honours `NO_UPDATE_NOTIFIER=1` and any `CI` env). |
|
|
648
665
|
| `FLOWMOD_EVO_DB_URL` | (none) | Full `postgres://` URL for `groups` (Evolution DB). Required for `groups`. |
|
|
649
666
|
| `FLOWMOD_DB_HOST/PORT/USER/PASS/NAME` | (none) | Alternative to `FLOWMOD_EVO_DB_URL` — assembled into a connection string. |
|
|
650
667
|
|
|
@@ -660,6 +677,18 @@ flowiq --version
|
|
|
660
677
|
Your saved token in `~/.config/flowiq/auth.json` is preserved across
|
|
661
678
|
upgrades.
|
|
662
679
|
|
|
680
|
+
**You'll be told when you're behind.** The CLI checks npm about once a day (in a
|
|
681
|
+
detached background process — it never slows a command) and prints a one-line
|
|
682
|
+
hint to stderr on your next run when a newer version exists:
|
|
683
|
+
|
|
684
|
+
```
|
|
685
|
+
⬆ flowiq 0.3.1 is available (you're on 0.3.0)
|
|
686
|
+
update: npm i -g @flowapt/flowiq-cli
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
It's stderr-only (never corrupts piped or `--json` output) and shows only in an
|
|
690
|
+
interactive terminal. Silence it with `FLOWIQ_NO_UPDATE_CHECK=1`.
|
|
691
|
+
|
|
663
692
|
## License
|
|
664
693
|
|
|
665
694
|
UNLICENSED. Internal staff tool — install requires a valid `fiq_staff_…`
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -38,6 +38,12 @@ This guide travels with the CLI — read it any time with `flowiq guide`
|
|
|
38
38
|
(`--reference` for the full command reference). It always matches the version
|
|
39
39
|
you have installed.
|
|
40
40
|
|
|
41
|
+
**Keep it current.** The CLI checks npm about once a day and, on your next run,
|
|
42
|
+
prints a one-line hint when you're behind (e.g. `⬆ flowiq 0.3.1 is available…`).
|
|
43
|
+
When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also means
|
|
44
|
+
`flowiq guide` shows you stale instructions. (Silence it with
|
|
45
|
+
`FLOWIQ_NO_UPDATE_CHECK=1` if you must.)
|
|
46
|
+
|
|
41
47
|
> **Approving someone else's login:** when a teammate runs `auth login`, they
|
|
42
48
|
> read you their code (or you open the link they send). On `/cli-auth`, check
|
|
43
49
|
> the code AND the device name match what they told you, then Approve.
|
|
@@ -81,6 +87,7 @@ you have installed.
|
|
|
81
87
|
| Tag everyone who bought a product (accurate, windowable) | `flowiq seg plan <org_id> --tag-prefix whey --bought "Whey" --window 90d` → `flowiq seg apply … --commit` |
|
|
82
88
|
| Advanced Tagging in the terminal (one named tag on a matched set) | `flowiq tag field\|cohort\|segment\|attributes\|messages <org_id> …` (dry-run) → add `--tag <name> --commit` |
|
|
83
89
|
| List / remove tags | `flowiq tag list <org_id>` · `flowiq tag remove <org_id> <tag> --confirm` |
|
|
90
|
+
| Split your whole broadcast list into N even cohorts (e.g. 3 for A/B/C or waves) | `flowiq seg plan <org_id> --tag-prefix bcast --from-attribute allow_broadcast_true --split 3` → `flowiq seg apply <org_id> bcast --commit` (makes `bcast-batch-01/02/03`, ~even, shuffled) |
|
|
84
91
|
| Split a big id-list cohort into send-safe batch tags | `flowiq seg plan <org_id> --tag-prefix … --ids-file …` → `flowiq seg apply … --commit` |
|
|
85
92
|
| Send to one batch tag | `flowiq bc send <org_id> --tag <batch-tag> --template … --body param1="Hi {{first_name}}" --commit` |
|
|
86
93
|
| Read a contact's chat | `flowiq m pull <contact_id>` then open the JSON |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowapt/flowiq-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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": {
|
package/src/commands/segments.js
CHANGED
|
@@ -39,6 +39,27 @@ function slugify(name, fallback) {
|
|
|
39
39
|
return s || fallback;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** Deterministic PRNG (mulberry32) so `--seed` gives a reproducible shuffle. */
|
|
43
|
+
function mulberry32(seed) {
|
|
44
|
+
let a = seed >>> 0;
|
|
45
|
+
return function () {
|
|
46
|
+
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
|
47
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
48
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
49
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Fisher–Yates shuffle in place. Pass a numeric seed for reproducibility, else Math.random. */
|
|
54
|
+
function shuffle(arr, seed) {
|
|
55
|
+
const rng = seed !== undefined && seed !== null && String(seed).length ? mulberry32(Number(seed) >>> 0) : Math.random;
|
|
56
|
+
for (let i = arr.length - 1; i > 0; i--) {
|
|
57
|
+
const j = Math.floor(rng() * (i + 1));
|
|
58
|
+
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
59
|
+
}
|
|
60
|
+
return arr;
|
|
61
|
+
}
|
|
62
|
+
|
|
42
63
|
async function fileExists(p) {
|
|
43
64
|
try { await fs.access(p); return true; } catch { return false; }
|
|
44
65
|
}
|
|
@@ -98,26 +119,45 @@ export async function plan(orgId, opts = {}) {
|
|
|
98
119
|
console.error(`Error: --tag-prefix must be slug-safe lowercase ([a-z0-9-], got "${prefix}").`);
|
|
99
120
|
process.exit(1);
|
|
100
121
|
}
|
|
122
|
+
const splitMode = opts.split !== undefined;
|
|
101
123
|
const batchSize = Number(opts.batchSize ?? 75);
|
|
102
|
-
if (!
|
|
103
|
-
|
|
124
|
+
if (!splitMode) {
|
|
125
|
+
if (!Number.isInteger(batchSize) || batchSize < 1) { console.error("Error: --batch-size must be ≥ 1."); process.exit(1); }
|
|
126
|
+
if (batchSize > 500) console.log(`⚠ batch size ${batchSize} is large — Meta tier risk; the runbook default is 75.`);
|
|
127
|
+
}
|
|
128
|
+
let splitN = 0;
|
|
129
|
+
if (splitMode) {
|
|
130
|
+
splitN = Number(opts.split);
|
|
131
|
+
if (!Number.isInteger(splitN) || splitN < 2 || splitN > 100) { console.error("Error: --split must be an integer 2..100."); process.exit(1); }
|
|
132
|
+
if (opts.seed !== undefined && !Number.isFinite(Number(opts.seed))) { console.error("Error: --seed must be a number."); process.exit(1); }
|
|
133
|
+
}
|
|
104
134
|
|
|
105
|
-
// Cohort source: a server-resolved criterion (--
|
|
106
|
-
//
|
|
107
|
-
// --ids-file).
|
|
135
|
+
// Cohort source: a server-resolved criterion (--from-attribute / --min-orders
|
|
136
|
+
// / --bought) OR an explicit id list (--from-segment / --ids-file). Exactly one.
|
|
108
137
|
const orderMode = opts.minOrders !== undefined;
|
|
109
138
|
const productMode = opts.bought !== undefined;
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
139
|
+
const attributeMode = opts.fromAttribute !== undefined;
|
|
140
|
+
const criteriaMode = orderMode || productMode || attributeMode;
|
|
141
|
+
const sourceCount = [orderMode, productMode, attributeMode, !!opts.fromSegment, !!opts.idsFile].filter(Boolean).length;
|
|
142
|
+
if (sourceCount === 0) {
|
|
143
|
+
console.error("Error: a cohort source is required — one of --from-attribute / --min-orders / --bought / --from-segment / --ids-file."); process.exit(1);
|
|
113
144
|
}
|
|
114
|
-
if (
|
|
115
|
-
console.error("Error: --min-orders / --bought
|
|
145
|
+
if (sourceCount > 1) {
|
|
146
|
+
console.error("Error: pick exactly ONE cohort source (--from-attribute / --min-orders / --bought / --from-segment / --ids-file)."); process.exit(1);
|
|
116
147
|
}
|
|
117
148
|
|
|
118
149
|
let cohort;
|
|
119
150
|
let cohortSpec = null;
|
|
120
151
|
if (criteriaMode) {
|
|
152
|
+
if (attributeMode) {
|
|
153
|
+
const ATTR = ["all_contacts", "allow_broadcast_true", "allow_broadcast_false", "no_broadcast_permission"];
|
|
154
|
+
const attribute = String(opts.fromAttribute).trim();
|
|
155
|
+
if (!ATTR.includes(attribute)) {
|
|
156
|
+
console.error(`Error: --from-attribute must be one of: ${ATTR.join(", ")}`); process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
cohortSpec = { attribute };
|
|
159
|
+
cohort = { ids: [], badUuids: [], sourceMeta: { type: "attribute_cohort", attribute } };
|
|
160
|
+
} else {
|
|
121
161
|
let windowDays = null;
|
|
122
162
|
if (opts.window) {
|
|
123
163
|
try { windowDays = parseWindowDays(opts.window); }
|
|
@@ -140,6 +180,7 @@ export async function plan(orgId, opts = {}) {
|
|
|
140
180
|
cohortSpec = { min_orders: minOrders, window_days: windowDays, platform: opts.platform || "auto" };
|
|
141
181
|
cohort = { ids: [], badUuids: [], sourceMeta: { type: "order_cohort", ...cohortSpec } };
|
|
142
182
|
}
|
|
183
|
+
}
|
|
143
184
|
} else {
|
|
144
185
|
try { cohort = await readCohort(opts, orgId); }
|
|
145
186
|
catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
|
|
@@ -160,23 +201,54 @@ export async function plan(orgId, opts = {}) {
|
|
|
160
201
|
process.exit(1);
|
|
161
202
|
}
|
|
162
203
|
if (resp.cohort) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
204
|
+
if (resp.cohort.kind === "attribute") {
|
|
205
|
+
const label = {
|
|
206
|
+
all_contacts: "all contacts",
|
|
207
|
+
allow_broadcast_true: "broadcast-opted-in contacts",
|
|
208
|
+
allow_broadcast_false: "broadcast-opted-OUT contacts",
|
|
209
|
+
no_broadcast_permission: "contacts with no broadcast permission set",
|
|
210
|
+
}[resp.cohort.attribute] || resp.cohort.attribute;
|
|
211
|
+
console.log(`Cohort: ${label} → ${resp.cohort.resolved_count} contact(s) (server-resolved).`);
|
|
168
212
|
} else {
|
|
169
|
-
|
|
213
|
+
const w = resp.cohort.window_days ? `in the last ${resp.cohort.window_days} days` : "across all captured history";
|
|
214
|
+
if (resp.cohort.kind === "product") {
|
|
215
|
+
const verb = resp.cohort.min_purchases > 1 ? `bought ${resp.cohort.min_purchases}+ times` : "bought";
|
|
216
|
+
const join = resp.cohort.match === "all" ? "ALL of" : "any of";
|
|
217
|
+
console.log(`Cohort: ${verb} ${join} [${resp.cohort.product_terms.join(", ")}] ${w} → ${resp.cohort.resolved_count} contact(s) (server-resolved from order line-items).`);
|
|
218
|
+
} else {
|
|
219
|
+
console.log(`Cohort: ${resp.cohort.min_orders}+ orders ${w} → ${resp.cohort.resolved_count} contact(s) (server-resolved from captured orders).`);
|
|
220
|
+
}
|
|
170
221
|
}
|
|
171
222
|
cohort.sourceMeta.resolved_count = resp.cohort.resolved_count;
|
|
172
223
|
}
|
|
173
224
|
if (!resp.safe_count) { console.error("Nothing to tag — 0 safe contacts after exclusions (V-13)."); process.exit(1); }
|
|
225
|
+
if (splitMode && splitN > resp.safe_count) {
|
|
226
|
+
console.error(`Error: --split ${splitN} but only ${resp.safe_count} safe contact(s) — can't make that many cohorts.`); process.exit(1);
|
|
227
|
+
}
|
|
174
228
|
|
|
175
|
-
//
|
|
229
|
+
// --split makes exactly N EVEN cohorts and shuffles by default so the groups
|
|
230
|
+
// are balanced (not skewed by contact age / id order); --ordered keeps server
|
|
231
|
+
// order; --seed makes the shuffle reproducible. Without --split, slice into
|
|
232
|
+
// contiguous --batch-size chunks (the original behaviour).
|
|
176
233
|
const batches = [];
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
234
|
+
if (splitMode) {
|
|
235
|
+
const ids = opts.ordered ? resp.safe_ids.slice() : shuffle(resp.safe_ids.slice(), opts.seed);
|
|
236
|
+
const total = ids.length;
|
|
237
|
+
const base = Math.floor(total / splitN);
|
|
238
|
+
const rem = total % splitN;
|
|
239
|
+
let idx = 0;
|
|
240
|
+
for (let g = 0; g < splitN; g++) {
|
|
241
|
+
const size = base + (g < rem ? 1 : 0);
|
|
242
|
+
const nn = String(g + 1).padStart(2, "0");
|
|
243
|
+
batches.push({ tag: `${prefix}-batch-${nn}`, count: size, contact_ids: ids.slice(idx, idx + size) });
|
|
244
|
+
idx += size;
|
|
245
|
+
}
|
|
246
|
+
if (batches[0].count > 500) console.log(`⚠ each cohort is ~${batches[0].count} contacts — a single-cohort send is large (Meta tier risk); pace the sends.`);
|
|
247
|
+
} else {
|
|
248
|
+
for (let i = 0; i < resp.safe_ids.length; i += batchSize) {
|
|
249
|
+
const nn = String(batches.length + 1).padStart(2, "0");
|
|
250
|
+
batches.push({ tag: `${prefix}-batch-${nn}`, count: Math.min(batchSize, resp.safe_ids.length - i), contact_ids: resp.safe_ids.slice(i, i + batchSize) });
|
|
251
|
+
}
|
|
180
252
|
}
|
|
181
253
|
|
|
182
254
|
const slug = slugify(opts.segment || prefix, prefix);
|
|
@@ -186,7 +258,8 @@ export async function plan(orgId, opts = {}) {
|
|
|
186
258
|
organization_id: resp.organization_id,
|
|
187
259
|
organization_slug: slugify(resp.organization_name, resp.organization_id.slice(0, 8)),
|
|
188
260
|
tag_prefix: prefix,
|
|
189
|
-
batch_size: batchSize,
|
|
261
|
+
batch_size: splitMode ? null : batchSize,
|
|
262
|
+
split: splitMode ? { cohorts: splitN, shuffled: !opts.ordered, seed: opts.seed ?? null } : null,
|
|
190
263
|
created_at: new Date().toISOString(),
|
|
191
264
|
source: cohort.sourceMeta,
|
|
192
265
|
exclusions: {
|
|
@@ -206,7 +279,12 @@ export async function plan(orgId, opts = {}) {
|
|
|
206
279
|
console.log(`Wrote ${planPath(slug)}`);
|
|
207
280
|
console.log(` org: ${resp.organization_name}`);
|
|
208
281
|
console.log(` input: ${resp.input_count} · safe: ${resp.safe_count} · excluded: ${ex.not_allow_broadcast + ex.archived + ex.blocked} (allow_broadcast ${ex.not_allow_broadcast}, archived ${ex.archived}, blocked ${ex.blocked}) · not_found ${ex.not_found_in_org} · dupes ${ex.duplicate_in_input}`);
|
|
209
|
-
|
|
282
|
+
if (splitMode) {
|
|
283
|
+
const how = opts.ordered ? "ordered" : (opts.seed != null ? `shuffled, seed ${opts.seed}` : "shuffled");
|
|
284
|
+
console.log(` cohorts: ${batches.length} even (${how}) — sizes ${batches.map((b) => b.count).join(" / ")} (${batches[0].tag} … ${batches[batches.length - 1].tag})`);
|
|
285
|
+
} else {
|
|
286
|
+
console.log(` batches: ${batches.length} × ≤${batchSize} (${batches[0].tag} … ${batches[batches.length - 1].tag})`);
|
|
287
|
+
}
|
|
210
288
|
if (cohort.sourceMeta.generated_at) {
|
|
211
289
|
console.log(` ⚠ tagging point-in-time ids from ${cohort.sourceMeta.generated_at} — re-derive the cohort SQL if freshness matters.`);
|
|
212
290
|
}
|
package/src/config.js
CHANGED
|
@@ -13,7 +13,7 @@ import os from "node:os";
|
|
|
13
13
|
|
|
14
14
|
const DEFAULT_API_URL = "https://api.flowiq.live";
|
|
15
15
|
|
|
16
|
-
function configDir() {
|
|
16
|
+
export function configDir() {
|
|
17
17
|
// Honour XDG_CONFIG_HOME if set; else ~/.config (Linux/Mac default).
|
|
18
18
|
const xdg = process.env.XDG_CONFIG_HOME;
|
|
19
19
|
const base = xdg && xdg.trim() ? xdg : path.join(os.homedir(), ".config");
|
package/src/index.js
CHANGED
|
@@ -29,6 +29,7 @@ import * as segmentsCmd from "./commands/segments.js";
|
|
|
29
29
|
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
|
+
import { maybeNotifyUpdate } from "./update-check.js";
|
|
32
33
|
|
|
33
34
|
// Read version from package.json so it stays in sync with the published npm
|
|
34
35
|
// version automatically (single source of truth — bumping package.json on each
|
|
@@ -37,6 +38,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
37
38
|
const pkg = JSON.parse(readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
|
|
38
39
|
|
|
39
40
|
export function run(argv) {
|
|
41
|
+
// Non-blocking "you're behind" hint (stderr; cached; detached refresh).
|
|
42
|
+
maybeNotifyUpdate(pkg.version);
|
|
43
|
+
|
|
40
44
|
const program = new Command();
|
|
41
45
|
program
|
|
42
46
|
.name("flowiq")
|
|
@@ -412,10 +416,11 @@ export function run(argv) {
|
|
|
412
416
|
.alias("seg")
|
|
413
417
|
.description("Slice a contact cohort into fixed-size batch tags and bulk-apply them (broadcast batching)");
|
|
414
418
|
segments.command("plan <organization_id>")
|
|
415
|
-
.description("Resolve a cohort (id list, order-count,
|
|
419
|
+
.description("Resolve a cohort (id list, order-count, product-purchase, or broadcast-attribute), apply broadcast-safety exclusions, slice into fixed-size OR N even batch tags, write the plan (no DB write)")
|
|
416
420
|
.requiredOption("--tag-prefix <name>", "campaign tag stem; batches become <prefix>-batch-NN")
|
|
417
421
|
.option("--from-segment <path>", "a segments snapshot JSON (uses its contact_ids[])")
|
|
418
422
|
.option("--ids-file <path>", "a plain newline/CSV file of contact UUIDs")
|
|
423
|
+
.option("--from-attribute <filter>", "SERVER-RESOLVED cohort: every contact matching a broadcast-permission filter (all_contacts | allow_broadcast_true | allow_broadcast_false | no_broadcast_permission)")
|
|
419
424
|
.option("--min-orders <n>", "SERVER-RESOLVED cohort: contacts with ≥ n captured orders (instead of an id file)")
|
|
420
425
|
.option("--bought <terms>", "SERVER-RESOLVED cohort: contacts who bought these product(s) (comma-separated name substrings) from real order line-items")
|
|
421
426
|
.option("--match <mode>", "with --bought: any (bought any listed product) | all (bought every one)", "any")
|
|
@@ -423,6 +428,9 @@ export function run(argv) {
|
|
|
423
428
|
.option("--window <span>", "with --min-orders/--bought: only count orders in this window, e.g. 60d / 8w / 3m (omit = all captured history)")
|
|
424
429
|
.option("--platform <p>", "with --min-orders/--bought: auto | shopify | woo", "auto")
|
|
425
430
|
.option("--batch-size <n>", "contacts per batch tag", "75")
|
|
431
|
+
.option("--split <n>", "split the safe cohort into exactly N even cohorts (alternative to --batch-size; shuffles by default for balanced groups)")
|
|
432
|
+
.option("--seed <n>", "with --split: reproducible shuffle seed (omit = random each run)")
|
|
433
|
+
.option("--ordered", "with --split: keep server order instead of shuffling (deterministic, but skews groups by contact age)")
|
|
426
434
|
.option("--segment <name>", "plan file slug (default: the tag prefix)")
|
|
427
435
|
.option("--include-unsafe", "do NOT exclude non-broadcast-safe contacts (rare; default excludes)")
|
|
428
436
|
.action((orgId, opts) => segmentsCmd.plan(orgId, opts));
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Best-effort "you're behind" nudge for the flowiq CLI.
|
|
2
|
+
//
|
|
3
|
+
// On startup we print a one-line update hint to STDERR (never stdout — so it
|
|
4
|
+
// can never corrupt piped / --json output) when the installed version is older
|
|
5
|
+
// than the latest published on npm.
|
|
6
|
+
//
|
|
7
|
+
// The nudge is driven by a CACHED value read SYNCHRONOUSLY — zero network on the
|
|
8
|
+
// hot path, so no command is ever slowed. The cache is refreshed at most once a
|
|
9
|
+
// day by a DETACHED, unref'd child process, so even `flowiq --version` returns
|
|
10
|
+
// instantly (the parent never waits on the registry). The hint therefore
|
|
11
|
+
// appears on the NEXT run after a new release — exactly how npm / update-notifier
|
|
12
|
+
// behave.
|
|
13
|
+
//
|
|
14
|
+
// Fully fail-silent: offline, registry down, malformed cache → no output, no
|
|
15
|
+
// crash, never blocks. Opt out with FLOWIQ_NO_UPDATE_CHECK=1 (also honours
|
|
16
|
+
// NO_UPDATE_NOTIFIER=1 and any CI env).
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import fsp from "node:fs/promises";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { spawn } from "node:child_process";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { configDir } from "./config.js";
|
|
24
|
+
|
|
25
|
+
const PKG = "@flowapt/flowiq-cli";
|
|
26
|
+
const REGISTRY = "https://registry.npmjs.org/@flowapt%2Fflowiq-cli/latest";
|
|
27
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // refresh the cache at most once/day
|
|
28
|
+
const FETCH_TIMEOUT_MS = 2000;
|
|
29
|
+
|
|
30
|
+
function cacheFile() {
|
|
31
|
+
return path.join(configDir(), "update-check.json");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function disabled() {
|
|
35
|
+
return (
|
|
36
|
+
process.env.FLOWIQ_NO_UPDATE_CHECK === "1" ||
|
|
37
|
+
process.env.NO_UPDATE_NOTIFIER === "1" ||
|
|
38
|
+
!!process.env.CI
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Compare dotted numeric versions (ignoring any -prerelease). a>b → 1, a<b → -1, = → 0.
|
|
43
|
+
export function cmpVersion(a, b) {
|
|
44
|
+
const parse = (v) => String(v).split("-")[0].split(".").map((n) => parseInt(n, 10) || 0);
|
|
45
|
+
const pa = parse(a);
|
|
46
|
+
const pb = parse(b);
|
|
47
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
48
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
49
|
+
if (d !== 0) return d > 0 ? 1 : -1;
|
|
50
|
+
}
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readCacheSync() {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(fs.readFileSync(cacheFile(), "utf8"));
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function writeCache(obj) {
|
|
63
|
+
try {
|
|
64
|
+
await fsp.mkdir(configDir(), { recursive: true, mode: 0o700 });
|
|
65
|
+
await fsp.writeFile(cacheFile(), JSON.stringify(obj) + "\n", "utf8");
|
|
66
|
+
} catch {
|
|
67
|
+
/* best-effort — a failed cache write just means we re-check next run */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Hit the npm registry for dist-tags.latest and cache it. Runs ONLY in the
|
|
72
|
+
// detached child (never on the hot path). Backs off for the day even on failure
|
|
73
|
+
// so an offline machine doesn't spawn a refresher on every invocation.
|
|
74
|
+
async function refreshLatest(prevLatest) {
|
|
75
|
+
const ctrl = new AbortController();
|
|
76
|
+
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(REGISTRY, { signal: ctrl.signal, headers: { Accept: "application/json" } });
|
|
79
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
80
|
+
const body = await res.json();
|
|
81
|
+
await writeCache({ checked_at: Date.now(), latest: body?.version || prevLatest || null });
|
|
82
|
+
} catch {
|
|
83
|
+
await writeCache({ checked_at: Date.now(), latest: prevLatest || null });
|
|
84
|
+
} finally {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Print the nudge (from cache, synchronously) and, if the cache is stale, spawn
|
|
90
|
+
// a detached child to refresh it for the NEXT run. Never throws, never blocks.
|
|
91
|
+
export function maybeNotifyUpdate(currentVersion) {
|
|
92
|
+
try {
|
|
93
|
+
if (disabled()) return;
|
|
94
|
+
const cache = readCacheSync();
|
|
95
|
+
const latest = cache?.latest;
|
|
96
|
+
|
|
97
|
+
if (latest && process.stderr.isTTY && cmpVersion(latest, currentVersion) > 0) {
|
|
98
|
+
process.stderr.write(
|
|
99
|
+
`\n ⬆ flowiq ${latest} is available (you're on ${currentVersion})\n` +
|
|
100
|
+
` update: npm i -g ${PKG}\n\n`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const stale = !cache || (Date.now() - (cache.checked_at || 0)) > CHECK_INTERVAL_MS;
|
|
105
|
+
if (stale) {
|
|
106
|
+
try {
|
|
107
|
+
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), "--refresh"], {
|
|
108
|
+
detached: true,
|
|
109
|
+
stdio: "ignore",
|
|
110
|
+
windowsHide: true,
|
|
111
|
+
});
|
|
112
|
+
child.unref();
|
|
113
|
+
} catch {
|
|
114
|
+
/* if we can't spawn, we simply try again next run */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
/* fully silent — an update hint must never be the reason a command fails */
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// When this module is executed directly as `node update-check.js --refresh`
|
|
123
|
+
// (the detached child spawned above), do the network refresh and exit. Guarded
|
|
124
|
+
// so importing the module for maybeNotifyUpdate never triggers it.
|
|
125
|
+
const invokedDirectly =
|
|
126
|
+
process.argv[1] &&
|
|
127
|
+
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) &&
|
|
128
|
+
process.argv.includes("--refresh");
|
|
129
|
+
if (invokedDirectly) {
|
|
130
|
+
const cache = readCacheSync();
|
|
131
|
+
refreshLatest(cache?.latest).finally(() => process.exit(0));
|
|
132
|
+
}
|