@flowapt/flowiq-cli 0.6.7 → 0.7.0

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.
@@ -154,3 +154,204 @@ export async function list() {
154
154
  }
155
155
  }
156
156
  }
157
+
158
+ // ── show ────────────────────────────────────────────────────────────────────
159
+ // Render ONE template row — including a DRAFT, which nothing else in the CLI
160
+ // can see. `templates pull` reads Meta and a draft never reaches Meta;
161
+ // api/cli/_introspect.js also starts from Meta by name, so it is blind too.
162
+ // Until this existed, reviewing a draft meant the dialog or raw SQL.
163
+ const MONTH_RE = /\{\{(\d+)\}\}/g;
164
+
165
+ function substitute(text, examples) {
166
+ return String(text ?? "").replace(MONTH_RE, (m, n) => {
167
+ const v = examples?.[n];
168
+ return v ? String(v) : m;
169
+ });
170
+ }
171
+
172
+ function indent(text, pad = " ") {
173
+ return String(text ?? "").split("\n").map((l) => pad + l).join("\n");
174
+ }
175
+
176
+ /** Cheap structural checks — the ones that are free while rendering. */
177
+ function lintDraft(d) {
178
+ const warn = [];
179
+ const body = (d.components || []).find((c) => c.type === "BODY");
180
+
181
+ if (body?.text) {
182
+ const nums = [...new Set([...body.text.matchAll(/\{\{(\d+)\}\}/g)].map((m) => Number(m[1])))].sort((a, b) => a - b);
183
+ if (nums.some((n, i) => n !== i + 1)) {
184
+ warn.push(`BODY variables are not sequential from {{1}} — found ${nums.map((n) => `{{${n}}}`).join(", ")}. Meta rejects this as "BODY is missing expected field(s) (example)".`);
185
+ }
186
+ for (const n of nums) {
187
+ if (!d.parameterExamples?.[n]) {
188
+ warn.push(`BODY {{${n}}} has no example — Meta's reviewers will see the placeholder text instead of a real value.`);
189
+ }
190
+ }
191
+ // WhatsApp formatting: a marker pair only renders when neither marker sits
192
+ // against whitespace on the inside. `👇_ Swipe …below._` shipped in this very
193
+ // template and renders as literal underscores. Deliberately only checked on
194
+ // lines that look like a PAIR (>=2 of the same marker), so a lone `*` used
195
+ // as a bullet or a `~` in a URL is not flagged.
196
+ for (const [mark, name] of [["_", "an italic"], ["*", "a bold"], ["~", "a strikethrough"]]) {
197
+ for (const line of body.text.split("\n")) {
198
+ // Iterate CODE POINTS, not UTF-16 units: `👇` is a surrogate pair, so
199
+ // mixing [...line] positions with line[i] lookups reads the wrong char
200
+ // and the check silently never fires (which is how 👇_ shipped).
201
+ const chars = [...line];
202
+ const idxs = chars.reduce((a, ch, i) => (ch === mark ? (a.push(i), a) : a), []);
203
+ if (idxs.length < 2) continue;
204
+ const opensBad = /\s/.test(chars[idxs[0] + 1] || "");
205
+ const closesBad = /\s/.test(chars[idxs[idxs.length - 1] - 1] || "");
206
+ if (opensBad || closesBad) {
207
+ warn.push(`BODY line has ${name} marker "${mark}" against a space (${opensBad ? "after the opening" : "before the closing"} ${mark}) — WhatsApp will not render it and the customer sees the literal ${mark}: ${JSON.stringify(line.trim().slice(0, 70))}`);
208
+ break;
209
+ }
210
+ }
211
+ }
212
+ if (/\s$/.test(body.text)) warn.push("BODY ends with trailing whitespace/newline.");
213
+ if (body.text.length > 1024) warn.push(`BODY is ${body.text.length} chars (Meta limit 1024).`);
214
+ }
215
+
216
+ const cards = Array.isArray(d.carouselCards) ? d.carouselCards : [];
217
+ if (d.templateMode === "carousel") {
218
+ if (cards.length < 2 || cards.length > 10) warn.push(`Carousel has ${cards.length} card(s) — Meta requires 2-10.`);
219
+ const urlVars = new Set();
220
+ cards.forEach((c, i) => {
221
+ if (!c.media?.media_url && !c.media?.asset_handle) warn.push(`Card ${i + 1} has no media.`);
222
+ if ((c.body || "").length > 160) warn.push(`Card ${i + 1} body is ${c.body.length} chars (Meta limit 160).`);
223
+ for (const b of c.buttons || []) {
224
+ if (b.type === "URL" && b.url) {
225
+ const vars = [...b.url.matchAll(/\{\{(\d+)\}\}/g)].map((m) => m[1]);
226
+ vars.forEach((v) => urlVars.add(v));
227
+ if (vars.length > 1) warn.push(`Card ${i + 1} button "${b.text}" has ${vars.length} URL variables — Meta supports exactly 1.`);
228
+ if (vars.length === 1 && vars[0] !== "1") {
229
+ warn.push(`Card ${i + 1} button "${b.text}" uses {{${vars[0]}}} — each card's URL variable must be {{1}} (Meta numbers card params per card). This card's link will break at send.`);
230
+ }
231
+ }
232
+ }
233
+ });
234
+ const shapes = new Set(cards.map((c) => `${c.headerFormat}|${(c.buttons || []).map((b) => b.type).join(",")}`));
235
+ if (shapes.size > 1) warn.push(`Cards do not share one shape (${[...shapes].join(" vs ")}) — Meta requires every card to have the same header format and button set.`);
236
+ }
237
+ return warn;
238
+ }
239
+
240
+ function renderDraft(d, opts) {
241
+ console.log(` mode: ${d.templateMode || "standard"}`);
242
+ console.log(` language: ${d.language || "(unset)"} parameters: ${d.parameterFormat || "POSITIONAL"}`);
243
+ const ex = d.parameterExamples || {};
244
+ const exKeys = Object.keys(ex);
245
+ if (exKeys.length) {
246
+ console.log(` examples: ${exKeys.map((k) => `{{${k}}} = ${ex[k] === "" ? "(EMPTY)" : JSON.stringify(ex[k])}`).join(" ")}`);
247
+ }
248
+
249
+ for (const c of d.components || []) {
250
+ console.log(`\n ${c.type}${c.format ? ` (${c.format})` : ""}`);
251
+ if (c.text) {
252
+ console.log(indent(substitute(c.text, ex)));
253
+ if (opts.raw) { console.log("\n raw:"); console.log(indent(JSON.stringify(c.text))); }
254
+ }
255
+ for (const b of c.buttons || []) {
256
+ console.log(` [${b.type}] ${b.text}${b.url ? ` → ${b.url}` : ""}${b.phone_number ? ` → ${b.phone_number}` : ""}`);
257
+ }
258
+ }
259
+
260
+ const cards = Array.isArray(d.carouselCards) ? d.carouselCards : [];
261
+ if (cards.length) {
262
+ console.log(`\n CAROUSEL — ${cards.length} card(s)`);
263
+ cards.forEach((c, i) => {
264
+ console.log(`\n ── card ${i + 1} ──`);
265
+ console.log(` header: ${c.headerFormat || "?"}`);
266
+ if (c.media) {
267
+ console.log(` media: ${c.media.media_url || "(none)"}`);
268
+ if (c.media.original_filename) console.log(` file: ${c.media.original_filename}${c.media.file_type ? ` (${c.media.file_type})` : ""}`);
269
+ console.log(` asset handle: ${c.media.asset_handle ? "present" : "MISSING"}`);
270
+ }
271
+ if (c.body) console.log(` body: ${substitute(c.body, ex)}`);
272
+ for (const b of c.buttons || []) {
273
+ console.log(` [${b.type}] ${b.text}${b.url ? ` → ${b.url}` : ""}${b.example ? ` example: ${b.example}` : ""}`);
274
+ }
275
+ });
276
+ }
277
+ }
278
+
279
+ export async function show(orgId, name, opts = {}) {
280
+ if (!UUID_RE.test(orgId)) {
281
+ console.error(`Error: "${orgId}" is not a valid organization UUID. Find it with: flowiq org list <name>`);
282
+ process.exit(1);
283
+ }
284
+ if (!name) {
285
+ console.error("Error: pass the template name, e.g. flowiq templates show <org> heritage_day_v2");
286
+ process.exit(1);
287
+ }
288
+
289
+ let resp;
290
+ try {
291
+ resp = await http.get("meta-templates", { organization_id: orgId, name, full: "1" });
292
+ } catch (e) {
293
+ console.error(`Show failed: ${e.message}`);
294
+ process.exit(1);
295
+ }
296
+
297
+ const rows = resp.templates || [];
298
+ if (!rows.length) {
299
+ console.error(`No template on this org matching "${name}". List them with: flowiq templates status ${orgId}`);
300
+ process.exit(1);
301
+ }
302
+ // Prefer an exact name match; otherwise if the substring is ambiguous, say so.
303
+ let row = rows.find((r) => r.template_name === name);
304
+ if (!row) {
305
+ if (rows.length > 1) {
306
+ console.error(`"${name}" matches ${rows.length} templates — name one exactly:`);
307
+ for (const r of rows) console.error(` ${r.template_name} [${r.status || "no status"}]`);
308
+ process.exit(1);
309
+ }
310
+ row = rows[0];
311
+ }
312
+
313
+ if (opts.json) { console.log(JSON.stringify(row, null, 2)); return; }
314
+
315
+ // Version skew: `template_data` only comes back when the server understands
316
+ // ?full=1. An older api/cli deploy answers the row without it, and rendering
317
+ // that silently produces a confident, EMPTY draft — so refuse instead.
318
+ if (!("template_data" in row)) {
319
+ console.error(`The API did not return this template's content.`);
320
+ console.error(`api/cli/meta-templates.js on the server is older than this CLI (it needs the ?full=1 branch, shipped with CLI v0.6.7).`);
321
+ console.error(`Everything else still works — retry once the Vercel deploy of origin/main has landed.`);
322
+ process.exit(1);
323
+ }
324
+ const td = row.template_data || {};
325
+ const isDraft = td.is_draft === true || row.status === "DRAFT";
326
+ console.log(`${row.template_name} [${row.status || "no status"}]${row.category ? ` ${row.category}` : ""}`);
327
+ console.log(` org: ${resp.organization_id}`);
328
+ console.log(` created: ${row.created_at || "?"}${row.updated_at && row.updated_at !== row.created_at ? ` updated: ${row.updated_at}` : ""}`);
329
+
330
+ if (isDraft) {
331
+ const d = td.draft || {};
332
+ console.log(` source: DRAFT (never submitted to Meta — this content exists only here)`);
333
+ renderDraft(d, opts);
334
+ const warn = lintDraft(d);
335
+ console.log(`\n Checks`);
336
+ if (!warn.length) {
337
+ console.log(` ✓ nothing obvious — these are structural checks only; Meta's review is still the authority.`);
338
+ } else {
339
+ for (const w of warn) console.log(` ⚠ ${w}`);
340
+ }
341
+ console.log(`\n This is a draft: edit it in Broadcasts → Templates, then submit with`);
342
+ console.log(` flowiq templates create ${orgId} --request-file <file> (submitting is irreversible — it burns the name).`);
343
+ return;
344
+ }
345
+
346
+ // A LIVE row's template_data is only the send-time MAPPING; the structure
347
+ // (body text, buttons, cards) lives at Meta, so point at the tool that reads it.
348
+ console.log(` source: live row — template_data here is the send-time mapping, not the message structure`);
349
+ if (td.meta_template_id) console.log(` meta id: ${td.meta_template_id}`);
350
+ if (td.meta_status) console.log(` meta: ${td.meta_status}${td.meta_category ? ` / ${td.meta_category}` : ""}`);
351
+ if (td.parameter_format) console.log(` params: ${td.parameter_format}`);
352
+ if (td.header) console.log(` header: ${JSON.stringify(td.header)}`);
353
+ if (td.body_params && Object.keys(td.body_params).length) console.log(` body: ${JSON.stringify(td.body_params)}`);
354
+ if (td.button_params && Object.keys(td.button_params).length) console.log(` buttons: ${JSON.stringify(td.button_params)}`);
355
+ if (td.carousel?.cards) console.log(` carousel: ${td.carousel.cards.length} card(s) with stored header media`);
356
+ console.log(`\n For the message structure as Meta holds it: flowiq templates pull ${orgId}`);
357
+ }
@@ -0,0 +1,42 @@
1
+ // Parse the VALUE argument of `flowiq org flags set <org> <key> <value>`.
2
+ //
3
+ // The SERVER is the validator (api/cli/_org-flags-registry.js type-checks
4
+ // against the registry), so this only has to turn what a human typed into the
5
+ // most obvious JSON: `true`/`false` → booleans, a bare integer → number,
6
+ // `[…]`/`{…}` → parsed JSON, `@file.json` → that file's JSON, `null` → null,
7
+ // everything else → the string as typed (the server splits "a,b" for list
8
+ // keys and coerces "true" for booleans, so a plain string is never wrong).
9
+
10
+ import { readFileSync } from "node:fs";
11
+
12
+ export function parseFlagValue(raw) {
13
+ if (raw === undefined || raw === null) throw new Error("a value is required");
14
+ const s = String(raw).trim();
15
+ if (s === "") throw new Error("value must not be empty (use `unset` to remove a key)");
16
+
17
+ if (s.startsWith("@")) {
18
+ const file = s.slice(1);
19
+ let text;
20
+ try { text = readFileSync(file, "utf8"); } catch (e) { throw new Error(`cannot read ${file}: ${e.message}`); }
21
+ try { return JSON.parse(text); } catch (e) { throw new Error(`${file} is not valid JSON: ${e.message}`); }
22
+ }
23
+ const lower = s.toLowerCase();
24
+ if (["true", "yes", "on"].includes(lower)) return true;
25
+ if (["false", "no", "off"].includes(lower)) return false;
26
+ if (lower === "null") return null;
27
+ if (/^-?\d+$/.test(s)) return Number(s);
28
+ if (s.startsWith("[") || s.startsWith("{")) {
29
+ try { return JSON.parse(s); } catch (e) { throw new Error(`value looks like JSON but does not parse: ${e.message}`); }
30
+ }
31
+ return s;
32
+ }
33
+
34
+ /** Render a flag value for a table cell: short, one line, no surprises. */
35
+ export function fmtValue(v, width = 60) {
36
+ if (v === undefined) return "(absent)";
37
+ if (v === null) return "null";
38
+ if (typeof v === "string") return v.length > width ? `${v.slice(0, width - 1)}…` : v;
39
+ if (typeof v !== "object") return String(v);
40
+ const s = JSON.stringify(v);
41
+ return s.length > width ? `${s.slice(0, width - 1)}…` : s;
42
+ }
@@ -0,0 +1,51 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { writeFileSync, mkdtempSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import { parseFlagValue, fmtValue } from "./flag-values.js";
7
+
8
+ test("booleans in every spelling a human types", () => {
9
+ for (const t of ["true", "TRUE", "yes", "on"]) assert.equal(parseFlagValue(t), true);
10
+ for (const f of ["false", "no", "off", "OFF"]) assert.equal(parseFlagValue(f), false);
11
+ });
12
+
13
+ test("integers become numbers, decimals stay strings (the server decides)", () => {
14
+ assert.equal(parseFlagValue("5"), 5);
15
+ assert.equal(parseFlagValue("-1"), -1);
16
+ assert.equal(parseFlagValue("1.5"), "1.5");
17
+ });
18
+
19
+ test("JSON arrays and objects parse; a broken one is an error, not a string", () => {
20
+ assert.deepEqual(parseFlagValue('["a","b"]'), ["a", "b"]);
21
+ assert.deepEqual(parseFlagValue('{"enabled":true}'), { enabled: true });
22
+ assert.throws(() => parseFlagValue("[a,b"), /does not parse/);
23
+ });
24
+
25
+ test("comma lists stay strings — the server splits them for string[] keys", () => {
26
+ assert.equal(parseFlagValue("summer,winter"), "summer,winter");
27
+ });
28
+
29
+ test("@file reads JSON from disk", () => {
30
+ const dir = mkdtempSync(path.join(tmpdir(), "flowiq-flags-"));
31
+ const f = path.join(dir, "v.json");
32
+ writeFileSync(f, JSON.stringify({ enabled: true, schedules: [] }));
33
+ assert.deepEqual(parseFlagValue(`@${f}`), { enabled: true, schedules: [] });
34
+ assert.throws(() => parseFlagValue("@/nonexistent/x.json"), /cannot read/);
35
+ });
36
+
37
+ test("empty is refused (unset is a separate verb)", () => {
38
+ assert.throws(() => parseFlagValue(""), /unset/);
39
+ assert.throws(() => parseFlagValue(" "), /unset/);
40
+ });
41
+
42
+ test("null is explicit null", () => {
43
+ assert.equal(parseFlagValue("null"), null);
44
+ });
45
+
46
+ test("fmtValue keeps table cells on one line and marks absence", () => {
47
+ assert.equal(fmtValue(undefined), "(absent)");
48
+ assert.equal(fmtValue(true), "true");
49
+ assert.equal(fmtValue({ a: 1 }), '{"a":1}');
50
+ assert.equal(fmtValue("x".repeat(80), 10).length, 10);
51
+ });
package/src/http.js CHANGED
@@ -7,6 +7,7 @@ import { readFileSync } from "node:fs";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import path from "node:path";
9
9
  import { loadConfig } from "./config.js";
10
+ import { cmpVersion } from "./update-check.js";
10
11
 
11
12
  // Sent on every request as X-Flowiq-Cli-Version so the server-side audit log
12
13
  // records WHICH CLI version made a change (a stale install is a real source of
@@ -17,6 +18,42 @@ try {
17
18
  CLI_VERSION = JSON.parse(readFileSync(path.join(dir, "..", "package.json"), "utf8")).version || "unknown";
18
19
  } catch { /* version is a nice-to-have; never block a request on it */ }
19
20
 
21
+ export const cliVersion = () => CLI_VERSION;
22
+
23
+ // ── Server version contract ─────────────────────────────────────────────────
24
+ // Every api/cli/* response carries X-Flowiq-Min-Version (the oldest client the
25
+ // current server contract supports) and X-Flowiq-Api-Build. We record them on
26
+ // every call, and warn ONCE per process when this install is below the minimum.
27
+ //
28
+ // This is the half npm cannot tell you: `npm i -g` compares against the
29
+ // registry, which says nothing about whether the SERVER still speaks your
30
+ // dialect. It is also instant and offline-safe — it rides on a request you were
31
+ // making anyway, with no registry round-trip and no 24h cache to go stale.
32
+ export const serverContract = { minVersion: null, apiBuild: null };
33
+ let warnedBelowMinimum = false;
34
+
35
+ function noteServerContract(resp) {
36
+ const min = resp.headers.get("x-flowiq-min-version");
37
+ const build = resp.headers.get("x-flowiq-api-build");
38
+ if (min) serverContract.minVersion = min;
39
+ if (build) serverContract.apiBuild = build;
40
+
41
+ if (min && CLI_VERSION !== "unknown" && cmpVersion(min, CLI_VERSION) > 0 && !warnedBelowMinimum) {
42
+ warnedBelowMinimum = true;
43
+ process.stderr.write(
44
+ `\n ⬆ FLOWIQ CLI TOO OLD FOR THE SERVER — installed ${CLI_VERSION}, server requires ${min}\n` +
45
+ ` Run this before continuing: npm i -g @flowapt/flowiq-cli@latest\n` +
46
+ ` (results from this run may be wrong or incomplete)\n\n`
47
+ );
48
+ }
49
+ }
50
+
51
+ /** True when this install is older than the server's declared minimum. */
52
+ export function isBelowServerMinimum() {
53
+ const min = serverContract.minVersion;
54
+ return !!(min && CLI_VERSION !== "unknown" && cmpVersion(min, CLI_VERSION) > 0);
55
+ }
56
+
20
57
  /**
21
58
  * Turn an error body into a STRING a human can act on.
22
59
  *
@@ -97,6 +134,7 @@ async function call(method, endpoint, { query, body } = {}) {
97
134
  }
98
135
 
99
136
  const resp = await fetch(url, init);
137
+ noteServerContract(resp);
100
138
  const text = await resp.text();
101
139
  let parsed, isJson = true;
102
140
  try { parsed = text ? JSON.parse(text) : {}; } catch { parsed = { _raw: text }; isJson = false; }
package/src/index.js CHANGED
@@ -19,6 +19,8 @@ import * as hoursCmd from "./commands/hours.js";
19
19
  import * as reportCmd from "./commands/report.js";
20
20
  import * as templatesCmd from "./commands/templates.js";
21
21
  import * as orgCmd from "./commands/org.js";
22
+ import * as orgFlagsCmd from "./commands/org-flags.js";
23
+ import * as insightsCmd from "./commands/insights.js";
22
24
  import * as agentConfigCmd from "./commands/agent-config.js";
23
25
  import * as agentsCmd from "./commands/agents.js";
24
26
  import * as agentUpdatesCmd from "./commands/agent-updates.js";
@@ -35,6 +37,7 @@ import * as guideCmd from "./commands/guide.js";
35
37
  import * as auditCmd from "./commands/audit.js";
36
38
  import * as storeApiCmd from "./commands/store-api.js";
37
39
  import { maybeNotifyUpdate } from "./update-check.js";
40
+ import * as doctorCmd from "./commands/doctor.js";
38
41
 
39
42
  // Read version from package.json so it stays in sync with the published npm
40
43
  // version automatically (single source of truth — bumping package.json on each
@@ -269,7 +272,7 @@ export function run(argv) {
269
272
  // templates (read WhatsApp templates from Meta; create via /cli/meta-templates)
270
273
  const templates = program.command("templates")
271
274
  .alias("tpl")
272
- .description("Read an org's WhatsApp templates from Meta (pull/list); create + submit to Meta (create/status)");
275
+ .description("Read an org's WhatsApp templates (pull/list/show — show also reads DRAFTS); create + submit to Meta (create/status)");
273
276
  templates.command("pull <organization_id>")
274
277
  .description("Fetch every live WhatsApp template from Meta into a local JSON snapshot")
275
278
  .action((orgId) => templatesCmd.pull(orgId));
@@ -284,9 +287,14 @@ export function run(argv) {
284
287
  .description("List the org's template rows (name + status) to poll Meta approval")
285
288
  .option("--name <substr>", "filter by template name substring")
286
289
  .action((orgId, opts) => templatesCmd.status(orgId, opts));
290
+ templates.command("show <organization_id> <name>")
291
+ .description("Render ONE template row in full — including a DRAFT, which `pull` cannot see (drafts never reach Meta)")
292
+ .option("--raw", "also print the raw body text (escaped), so invisible whitespace is visible")
293
+ .option("--json", "raw JSON row output")
294
+ .action((orgId, name, opts) => templatesCmd.show(orgId, name, opts));
287
295
 
288
296
  // org (read-only org summary for the prompt-builder skill, creds stripped)
289
- const org = program.command("org").description("Org info (read) + create a new organization");
297
+ const org = program.command("org").description("Org info (read), create a new organization, read/set its feature flags");
290
298
  org.command("list [search]")
291
299
  .description("List organizations with their UUIDs — the lookup every other command needs (filter by name/slug)")
292
300
  .option("--all", "include inactive organizations")
@@ -304,6 +312,84 @@ export function run(argv) {
304
312
  .option("--json", "print the raw JSON payload")
305
313
  .action((orgId, opts) => orgCmd.info(orgId, opts));
306
314
 
315
+ // org flags — organizations.feature_flags (the Settings → Profile switches).
316
+ // Registry-allowlisted writes, credentials never read or written, dangerous
317
+ // keys need --yes. `list --key` is the cross-org census that was SQL-only
318
+ // until 10 Sep 2026.
319
+ const orgFlags = org.command("flags").description("Read + set an org's feature flags (Settings → Profile switches); `list --key` = cross-org census");
320
+ orgFlags.command("show <organization_id>")
321
+ .description("Every flag on the org, by Settings section (credentials redacted)")
322
+ .option("--json", "raw JSON")
323
+ .action((orgId, opts) => orgFlagsCmd.show(orgId, opts));
324
+ orgFlags.command("list")
325
+ .description("Cross-org census of ONE flag: which orgs carry it, and its value")
326
+ .requiredOption("--key <path>", "dotted flag path, e.g. export_insights.enabled or mcp_member_access.enabled")
327
+ .option("--all", "include inactive organizations")
328
+ .option("--on", "only orgs where the value is true")
329
+ .option("--off", "only orgs where the value is not true (false or absent)")
330
+ .option("--present", "only orgs that carry the key")
331
+ .option("--absent", "only orgs that do not carry the key")
332
+ .option("--json", "raw JSON")
333
+ .action((opts) => orgFlagsCmd.list(opts));
334
+ orgFlags.command("set <organization_id> <key> <value>")
335
+ .description("Set a registered flag (true/false, a number, JSON, or @file.json); type-checked server-side")
336
+ .option("--yes", "confirm a dangerous flag (the Profile tab's amber-warning switches)")
337
+ .option("--json", "raw JSON")
338
+ .action((orgId, key, value, opts) => orgFlagsCmd.set(orgId, key, value, opts));
339
+ orgFlags.command("unset <organization_id> <key>")
340
+ .description("Remove a flag key from the org (reverts to the platform default) — always needs --yes")
341
+ .option("--yes", "confirm")
342
+ .option("--json", "raw JSON")
343
+ .action((orgId, key, opts) => orgFlagsCmd.unset(orgId, key, opts));
344
+ orgFlags.command("keys")
345
+ .description("What is settable: every registered flag with its type, section and danger note")
346
+ .option("--section <name>", "filter by Settings section (substring)")
347
+ .option("--json", "raw JSON")
348
+ .action((opts) => orgFlagsCmd.keys(opts));
349
+
350
+ // insights — the Customer Insights Report (feature_flags.export_insights)
351
+ const collectEmail = (v, acc) => (acc || []).concat(v);
352
+ const insights = program.command("insights").description("Customer Insights Report: who has it, enable/disable per org, run one now");
353
+ insights.command("status [organization_id]")
354
+ .description("All orgs (or one): enabled, schedules as the scheduler reads them, recipients, own OpenAI key, last report, chats in 30d")
355
+ .option("--all", "include inactive organizations")
356
+ .option("--on", "only orgs with insights enabled")
357
+ .option("--off", "only orgs with insights NOT enabled")
358
+ .option("--min-chats <n>", "only orgs with at least n chatting contacts in the last 30 days")
359
+ .option("--sort <by>", "chats (default) | name | last")
360
+ .option("--json", "raw JSON")
361
+ .action((orgId, opts) => insightsCmd.status(orgId, opts));
362
+ insights.command("enable <organization_id>")
363
+ .description("Enable the report (or update its config). Cadence flags REPLACE the schedules; none given = keep existing, else weekly Monday")
364
+ .option("--daily", "add a daily schedule")
365
+ .option("--weekly [day]", "add a weekly schedule (day: monday … sunday; default monday)")
366
+ .option("--monthly [day]", "add a monthly schedule (day: 1-28 or last; default last)")
367
+ .option("--report <type>", "standard (default) | advanced — applies to the cadence flags given")
368
+ .option("--store-only", "the schedules given store the report without emailing anyone")
369
+ .option("--recipient <email>", "recipient list (repeatable; REPLACES the existing list)", collectEmail)
370
+ .option("--add-recipient <email>", "add to the existing recipients (repeatable)", collectEmail)
371
+ .option("--remove-recipient <email>", "remove from the existing recipients (repeatable)", collectEmail)
372
+ .option("--dry-run", "show what would be written, write nothing")
373
+ .option("--force", "write even if pre-flight fails (no OpenAI key / no recipients)")
374
+ .option("--json", "raw JSON")
375
+ .action((orgId, opts) => insightsCmd.enable(orgId, opts));
376
+ insights.command("disable <organization_id>")
377
+ .description("Turn the scheduled report off (schedules + recipients are kept)")
378
+ .option("--json", "raw JSON")
379
+ .action((orgId, opts) => insightsCmd.disable(orgId, opts));
380
+ insights.command("run <organization_id>")
381
+ .description("Run one report now (DRY RUN by default — add --commit). Same path as the Profile tab's Run Now")
382
+ .option("--period <p>", "daily (default: last 24h) | weekly (7d) | monthly (30d)")
383
+ .option("--from <date>", "custom window start (YYYY-MM-DD, SAST) — with --to")
384
+ .option("--to <date>", "custom window end (YYYY-MM-DD, SAST) — with --from")
385
+ .option("--report <type>", "standard | advanced (default: the org's schedule for that period, else standard)")
386
+ .option("--recipient <email>", "send to these instead of the configured recipients (repeatable)", collectEmail)
387
+ .option("--store-only", "store the report, email nobody")
388
+ .option("--commit", "actually fire it")
389
+ .option("--force", "fire even if pre-flight fails")
390
+ .option("--json", "raw JSON")
391
+ .action((orgId, opts) => insightsCmd.run(orgId, opts));
392
+
307
393
  // agent (list / create / config — config defaults to the active agent, or --agent <id>)
308
394
  const agent = program.command("agent").description("List, create, and configure an org's agents");
309
395
  agent.command("list <organization_id>")
@@ -625,8 +711,10 @@ export function run(argv) {
625
711
  .description("Shorten one or more URLs, tagging each with utm_campaign/utm_content")
626
712
  .option("--url <url>", "URL to shorten (repeatable)", (v, acc) => (acc || []).concat([v]), [])
627
713
  .option("--file <path>", "shorten every URL found in this text file")
628
- .option("--campaign <value>", "utm_campaign (e.g. 13Aug_Seeds) — must be paired with --content")
629
- .option("--content <value>", "utm_content — must be paired with --campaign")
714
+ .option("--campaign <value>", "campaign title — normalised to the house Date_Campaign convention (\"Spring Promotion\" → 9Sep_SpringPromotion)")
715
+ .option("--content <value>", "utm_content, the per-link tag (e.g. ViewMore); defaults to the campaign")
716
+ .option("--date <value>", "send date for the campaign tag when it is not today: 9Sep | 2026-09-24 | 24/9/2026")
717
+ .option("--raw-campaign", "use --campaign exactly as typed, bypassing the Date_Campaign normalisation")
630
718
  .option("--domain <host>", "short domain: chatcart.io (default) | linklnk.io | yapi.store")
631
719
  .option("--commit", "actually mint the links (omit = dry-run preview)")
632
720
  .option("--json", "raw JSON output")
@@ -776,6 +864,12 @@ export function run(argv) {
776
864
  .option("--out <file>", "write the JSON to a file instead of stdout")
777
865
  .action((orgId, path, opts) => storeApiCmd.wooGet(orgId, path, opts));
778
866
 
867
+ // doctor (version + auth + server contract in one read-only command)
868
+ program.command("doctor")
869
+ .description("Check this install: version vs npm, auth, and whether the server still supports it. Exits 1 if something needs fixing")
870
+ .option("--json", "machine-readable output")
871
+ .action(async (opts) => { process.exitCode = await doctorCmd.doctor(opts); });
872
+
779
873
  // guide (bundled docs — always match the installed version)
780
874
  program.command("guide")
781
875
  .description("Read the team guide (how we use this CLI); --reference for the full command reference")
@@ -0,0 +1,69 @@
1
+ // Pure helpers for `flowiq insights` — turning flags a human types
2
+ // (`--weekly monday`, `--monthly last`) into the `schedules[]` entries the
3
+ // export-insights scheduler reads, and describing a config the way the
4
+ // scheduler will actually run it. No I/O; unit-tested in insights-config.test.mjs.
5
+
6
+ export const DOW = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
7
+ export const DOW_LABEL = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
8
+
9
+ /** "monday" | "mon" | "1" → 1. Bare `--weekly` (true) → Monday, the scheduler's default. */
10
+ export function parseWeekday(v) {
11
+ if (v === true || v === undefined || v === null || v === "") return 1;
12
+ const s = String(v).trim().toLowerCase();
13
+ if (/^[0-6]$/.test(s)) return Number(s);
14
+ const idx = DOW.findIndex((d) => d === s || d.slice(0, 3) === s);
15
+ if (idx >= 0) return idx;
16
+ throw new Error(`--weekly expects a day (monday … sunday, or 0-6), got "${v}"`);
17
+ }
18
+
19
+ /** "last" | "15" → "last" | 15. Bare `--monthly` (true) → "last", the scheduler's default. */
20
+ export function parseMonthlyDay(v) {
21
+ if (v === true || v === undefined || v === null || v === "") return "last";
22
+ const s = String(v).trim().toLowerCase();
23
+ if (s === "last") return "last";
24
+ if (/^\d{1,2}$/.test(s)) {
25
+ const n = Number(s);
26
+ if (n >= 1 && n <= 28) return n;
27
+ }
28
+ throw new Error(`--monthly expects a day of the month (1-28) or "last", got "${v}"`);
29
+ }
30
+
31
+ /**
32
+ * Build schedules[] from the enable flags. Returns null when no cadence flag
33
+ * was given (the server then keeps what exists, or applies its default).
34
+ * opts: { daily?, weekly?, monthly?, report?, storeOnly? }
35
+ */
36
+ export function schedulesFromOpts(opts = {}) {
37
+ const report = opts.report ?? "standard";
38
+ if (!["standard", "advanced"].includes(report)) throw new Error(`--report must be standard or advanced, got "${report}"`);
39
+ const storeOnly = opts.storeOnly === true;
40
+ const out = [];
41
+ if (opts.daily) out.push({ frequency: "daily", report_type: report, store_only: storeOnly });
42
+ if (opts.weekly !== undefined) out.push({ frequency: "weekly", weekly_day: parseWeekday(opts.weekly), report_type: report, store_only: storeOnly });
43
+ if (opts.monthly !== undefined) out.push({ frequency: "monthly", monthly_day: parseMonthlyDay(opts.monthly), report_type: report, store_only: storeOnly });
44
+ return out.length ? out : null;
45
+ }
46
+
47
+ /** One line per schedule, in the scheduler's own vocabulary. */
48
+ export function describeSchedule(s) {
49
+ const tag = `${s.report_type ?? "standard"}${s.store_only ? "/store-only" : ""}`;
50
+ if (s.frequency === "daily") return `daily [${tag}]`;
51
+ if (s.frequency === "weekly") return `weekly ${DOW_LABEL[typeof s.weekly_day === "number" ? s.weekly_day : 1]} [${tag}]`;
52
+ if (s.frequency === "monthly") return `monthly day=${s.monthly_day ?? "last"} [${tag}]`;
53
+ return `${s.frequency} [${tag}]`;
54
+ }
55
+
56
+ /**
57
+ * A YYYY-MM-DD typed by a South African becomes the SAST day boundary, not
58
+ * UTC's — otherwise `--from 2026-09-01` silently starts at 02:00 SAST.
59
+ * `end` covers the whole day. Full ISO strings pass through untouched.
60
+ */
61
+ export function sastRange(from, to) {
62
+ const day = /^\d{4}-\d{2}-\d{2}$/;
63
+ const start = day.test(from) ? new Date(`${from}T00:00:00+02:00`) : new Date(from);
64
+ const end = day.test(to) ? new Date(`${to}T23:59:59.999+02:00`) : new Date(to);
65
+ if (Number.isNaN(start.getTime())) throw new Error(`--from "${from}" is not a date`);
66
+ if (Number.isNaN(end.getTime())) throw new Error(`--to "${to}" is not a date`);
67
+ if (start >= end) throw new Error("--from must be before --to");
68
+ return { start: start.toISOString(), end: end.toISOString() };
69
+ }
@@ -0,0 +1,51 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseWeekday, parseMonthlyDay, schedulesFromOpts, describeSchedule, sastRange } from "./insights-config.js";
4
+
5
+ test("weekday parsing: names, abbreviations, digits, and the bare default", () => {
6
+ assert.equal(parseWeekday("monday"), 1);
7
+ assert.equal(parseWeekday("Fri"), 5);
8
+ assert.equal(parseWeekday("0"), 0);
9
+ assert.equal(parseWeekday(true), 1);
10
+ assert.throws(() => parseWeekday("someday"), /--weekly expects/);
11
+ });
12
+
13
+ test("monthly day: last, 1-28, bare default; 29+ refused (the scheduler never fires them)", () => {
14
+ assert.equal(parseMonthlyDay("last"), "last");
15
+ assert.equal(parseMonthlyDay("15"), 15);
16
+ assert.equal(parseMonthlyDay(true), "last");
17
+ assert.throws(() => parseMonthlyDay("31"), /1-28/);
18
+ });
19
+
20
+ test("no cadence flag → null (server keeps existing / applies default)", () => {
21
+ assert.equal(schedulesFromOpts({}), null);
22
+ assert.equal(schedulesFromOpts({ report: "advanced" }), null);
23
+ });
24
+
25
+ test("cadence flags build scheduler-shaped entries, store-only applies to all", () => {
26
+ const s = schedulesFromOpts({ daily: true, weekly: "monday", monthly: "last", report: "advanced", storeOnly: true });
27
+ assert.deepEqual(s, [
28
+ { frequency: "daily", report_type: "advanced", store_only: true },
29
+ { frequency: "weekly", weekly_day: 1, report_type: "advanced", store_only: true },
30
+ { frequency: "monthly", monthly_day: "last", report_type: "advanced", store_only: true },
31
+ ]);
32
+ });
33
+
34
+ test("bad report type is refused before anything reaches the server", () => {
35
+ assert.throws(() => schedulesFromOpts({ daily: true, report: "fancy" }), /--report must be/);
36
+ });
37
+
38
+ test("describeSchedule speaks the scheduler's vocabulary", () => {
39
+ assert.equal(describeSchedule({ frequency: "daily", report_type: "standard", store_only: true }), "daily [standard/store-only]");
40
+ assert.equal(describeSchedule({ frequency: "weekly", weekly_day: 3 }), "weekly Wednesday [standard]");
41
+ assert.equal(describeSchedule({ frequency: "weekly" }), "weekly Monday [standard]");
42
+ assert.equal(describeSchedule({ frequency: "monthly", report_type: "advanced" }), "monthly day=last [advanced]");
43
+ });
44
+
45
+ test("date-only --from/--to are SAST day boundaries, not UTC", () => {
46
+ const r = sastRange("2026-09-01", "2026-09-07");
47
+ assert.equal(r.start, "2026-08-31T22:00:00.000Z");
48
+ assert.equal(r.end, "2026-09-07T21:59:59.999Z");
49
+ assert.throws(() => sastRange("2026-09-07", "2026-09-01"), /before/);
50
+ assert.throws(() => sastRange("yesterday", "2026-09-01"), /not a date/);
51
+ });