@flowapt/flowiq-cli 0.6.8 → 0.7.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.
@@ -0,0 +1,151 @@
1
+ // `flowiq org flags …` — an org's feature flags (the Settings → Profile
2
+ // switches) from the terminal.
3
+ //
4
+ // flowiq org flags show <org_id> what this org carries (secrets redacted)
5
+ // flowiq org flags list --key <path> [--all] which orgs carry ONE flag, and its value
6
+ // flowiq org flags set <org_id> <key> <value> [--yes] write a registered key (type-checked server-side)
7
+ // flowiq org flags unset <org_id> <key> --yes remove a key (revert to the platform default)
8
+ // flowiq org flags keys what is settable, by Settings section
9
+ //
10
+ // The server (api/cli/org-flags + _org-flags-registry) is the only validator:
11
+ // only keys the Profile tab manages can be set, credentials are never shown
12
+ // or written, dangerous keys need --yes.
13
+
14
+ import { http } from "../http.js";
15
+ import { parseFlagValue, fmtValue } from "../flag-values.js";
16
+
17
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
18
+ const pad = (s, n) => String(s ?? "").padEnd(n);
19
+
20
+ function requireUuid(orgId) {
21
+ if (!UUID_RE.test(orgId || "")) {
22
+ console.error(`Error: "${orgId}" is not a valid organization UUID (find it with \`flowiq org list <name>\`).`);
23
+ process.exit(1);
24
+ }
25
+ }
26
+
27
+ function fail(prefix, e) {
28
+ console.error(`${prefix}: ${e.message}`);
29
+ const b = e.body || {};
30
+ if (b.needs_confirm) {
31
+ if (b.danger) console.error(` ⚠ ${b.danger}`);
32
+ if (b.before !== undefined) console.error(` current: ${fmtValue(b.before)}`);
33
+ if (b.would_set !== undefined) console.error(` would set: ${fmtValue(b.would_set)}`);
34
+ console.error(" Re-run with --yes to confirm.");
35
+ } else if (b.settable === false) {
36
+ console.error(" `flowiq org flags keys` lists what can be set; `flowiq org flags show <org>` still READS it.");
37
+ }
38
+ process.exit(1);
39
+ }
40
+
41
+ export async function keys(opts = {}) {
42
+ let resp;
43
+ try { resp = await http.get("org-flags", { keys: "1" }); } catch (e) { fail("Lookup failed", e); }
44
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
45
+ const rows = (resp.keys || []).filter((k) => !opts.section || k.section.toLowerCase().includes(opts.section.toLowerCase()));
46
+ const bySection = new Map();
47
+ for (const k of rows) {
48
+ if (!bySection.has(k.section)) bySection.set(k.section, []);
49
+ bySection.get(k.section).push(k);
50
+ }
51
+ console.log(`Settable feature flags (${rows.length}) — the Settings → Profile switches, by section.\n`);
52
+ for (const [section, list] of bySection) {
53
+ console.log(`${section}`);
54
+ for (const k of list) {
55
+ const type = k.type === "integer" && k.min !== undefined ? `integer ${k.min}-${k.max}` : k.type;
56
+ console.log(` ${pad(k.key, 46)} ${pad(type, 14)}${k.danger ? " ⚠ needs --yes" : ""}`);
57
+ if (k.note) console.log(` ${pad("", 46)} ${k.note}`);
58
+ }
59
+ console.log("");
60
+ }
61
+ console.log("Wildcards (`x.*`) take one more segment, e.g. integrations_visible.instagram.");
62
+ console.log("Anything else on an org is readable via `flowiq org flags show` but not settable here.");
63
+ }
64
+
65
+ export async function show(orgId, opts = {}) {
66
+ requireUuid(orgId);
67
+ let resp;
68
+ try { resp = await http.get("org-flags", { organization_id: orgId }); } catch (e) { fail("Lookup failed", e); }
69
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
70
+
71
+ const o = resp.organization;
72
+ console.log(`${o.name} (${o.id})${o.inactive ? " [INACTIVE]" : ""} — ${resp.key_count} top-level flag key(s)\n`);
73
+ const known = Object.entries(resp.known || {});
74
+ if (known.length) {
75
+ const bySection = new Map();
76
+ for (const [key, v] of known) {
77
+ if (!bySection.has(v.section)) bySection.set(v.section, []);
78
+ bySection.get(v.section).push([key, v.value]);
79
+ }
80
+ for (const [section, list] of bySection) {
81
+ console.log(section);
82
+ for (const [key, value] of list) console.log(` ${pad(key, 46)} ${fmtValue(value, 70)}`);
83
+ console.log("");
84
+ }
85
+ } else {
86
+ console.log("(none of the registered flags are set on this org)\n");
87
+ }
88
+ if (resp.unknown_keys?.length) {
89
+ console.log(`Other keys on this org (readable with --json, not settable from the CLI): ${resp.unknown_keys.join(", ")}`);
90
+ }
91
+ if (resp.secret_keys?.length) {
92
+ console.log(`Credentials present (never shown): ${resp.secret_keys.join(", ")}`);
93
+ }
94
+ console.log("\nChange one: flowiq org flags set <org_id> <key> <value> (see `flowiq org flags keys`)");
95
+ }
96
+
97
+ export async function list(opts = {}) {
98
+ if (!opts.key) { console.error("Error: --key <path> is required (e.g. --key export_insights.enabled)."); process.exit(1); }
99
+ let resp;
100
+ try { resp = await http.get("org-flags", { list: "1", key: opts.key, all: opts.all ? "1" : undefined }); } catch (e) { fail("Lookup failed", e); }
101
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
102
+
103
+ let rows = resp.rows || [];
104
+ if (opts.on) rows = rows.filter((r) => r.value === true);
105
+ if (opts.off) rows = rows.filter((r) => r.value !== true);
106
+ if (opts.present) rows = rows.filter((r) => r.present);
107
+ if (opts.absent) rows = rows.filter((r) => !r.present);
108
+
109
+ const s = resp.summary;
110
+ const label = resp.registered ? ""
111
+ : resp.registered_parent ? ` (sub-key of \`${resp.registered_parent}\` — set it through the parent${resp.registered_parent === "export_insights" ? ", or \`flowiq insights enable\`" : ""})`
112
+ : " (not a registered key — read-only)";
113
+ console.log(`${resp.key}${label} across ${s.orgs} org(s)${resp.include_inactive ? " incl. inactive" : ""}: ` +
114
+ `${s.present} present · ${s.absent} absent` + (s.true || s.false ? ` · ${s.true} true · ${s.false} false` : "") + "\n");
115
+ if (!rows.length) { console.log("(no orgs match the filter)"); return; }
116
+ const nameW = Math.min(34, Math.max(4, ...rows.map((r) => (r.name || "").length)));
117
+ console.log(`${pad("NAME", nameW)} ${pad("ID", 36)} VALUE`);
118
+ for (const r of rows) {
119
+ console.log(`${pad((r.name || "").slice(0, nameW), nameW)} ${pad(r.id, 36)} ${r.present ? fmtValue(r.value, 60) : "(absent)"}${r.inactive ? " (inactive)" : ""}`);
120
+ }
121
+ console.log(`\n${rows.length} org(s) shown`);
122
+ }
123
+
124
+ export async function set(orgId, key, value, opts = {}) {
125
+ requireUuid(orgId);
126
+ let parsed;
127
+ try { parsed = parseFlagValue(value); } catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
128
+ let resp;
129
+ try {
130
+ resp = await http.post("org-flags", { organization_id: orgId, action: "set", key, value: parsed, confirm: opts.yes === true });
131
+ } catch (e) { fail("Set failed", e); }
132
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
133
+ console.log(`${resp.organization.name} — ${resp.key}`);
134
+ console.log(` ${fmtValue(resp.before, 70)} → ${fmtValue(resp.after, 70)}${resp.changed ? "" : " (no change)"}`);
135
+ if (resp.danger) console.log(` ⚠ ${resp.danger}`);
136
+ for (const w of resp.warnings || []) console.log(` ⚠ ${w}`);
137
+ console.log(` Undo: flowiq org flags ${resp.before === null ? `unset ${orgId} ${resp.key} --yes` : `set ${orgId} ${resp.key} '${JSON.stringify(resp.before)}'${resp.danger ? " --yes" : ""}`}`);
138
+ }
139
+
140
+ export async function unset(orgId, key, opts = {}) {
141
+ requireUuid(orgId);
142
+ let resp;
143
+ try {
144
+ resp = await http.post("org-flags", { organization_id: orgId, action: "unset", key, confirm: opts.yes === true });
145
+ } catch (e) { fail("Unset failed", e); }
146
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
147
+ console.log(`${resp.organization.name} — removed ${resp.key}`);
148
+ console.log(` was: ${fmtValue(resp.before, 70)}`);
149
+ for (const w of resp.warnings || []) console.log(` ⚠ ${w}`);
150
+ console.log(` Undo: flowiq org flags set ${orgId} ${resp.key} '${JSON.stringify(resp.before)}'${resp.danger ? " --yes" : ""}`);
151
+ }
@@ -0,0 +1,169 @@
1
+ // `flowiq updates <verb>` — the "what changed in FlowIQ" email to the Flowapt team.
2
+ // status uncovered changelog rows, last sends, who receives it
3
+ // uncovered list the changelog rows no issue has covered yet
4
+ // draft [--out f] write ./.flowiq/updates/<date>.json — a default issue
5
+ // composed from the uncovered rows, ready to enrich
6
+ // (screenshots, "for the team" notes, a CLI block)
7
+ // asset <file> host an image for the email; prints the public URL
8
+ // preview <file> render the issue to <file>.html (--open shows it)
9
+ // send <file> --test → one copy to you (or --to a@b) without
10
+ // marking anything covered; without --test the issue
11
+ // goes to every super admin and needs --yes
12
+ // The renderer, the send and the record all live server-side
13
+ // (/cli/team-updates → the team-update-email edge fn); every send is audited.
14
+
15
+ import fs from "node:fs/promises";
16
+ import path from "node:path";
17
+ import { spawn } from "node:child_process";
18
+ import { http } from "../http.js";
19
+
20
+ const DIR = path.resolve(process.cwd(), ".flowiq", "updates");
21
+ const TYPES = ["feature", "improvement", "fix", "ui", "agent", "cli", "infra", "db", "mcp"];
22
+
23
+ function sastStamp() {
24
+ return new Date().toLocaleDateString("en-CA", { timeZone: "Africa/Johannesburg" }); // YYYY-MM-DD
25
+ }
26
+
27
+ async function readIssue(file) {
28
+ const p = path.resolve(process.cwd(), file);
29
+ let raw;
30
+ try { raw = await fs.readFile(p, "utf8"); }
31
+ catch { console.error(`Error: cannot read ${p}`); process.exit(1); }
32
+ let doc;
33
+ try { doc = JSON.parse(raw); }
34
+ catch (e) { console.error(`Error: ${p} is not valid JSON (${e.message})`); process.exit(1); }
35
+ const issue = doc.issue && typeof doc.issue === "object" ? doc.issue : doc;
36
+ const problems = lint(issue);
37
+ if (problems.length) {
38
+ console.error(`Error: ${p} has ${problems.length} problem(s):`);
39
+ for (const m of problems) console.error(` - ${m}`);
40
+ process.exit(1);
41
+ }
42
+ return { issue, file: p };
43
+ }
44
+
45
+ /** The same checks the server applies, so a bad file fails here with line-level help. */
46
+ function lint(issue) {
47
+ const out = [];
48
+ if (!issue.subject) out.push("subject is required");
49
+ if (!issue.title) out.push("title is required");
50
+ const secs = [...(issue.headlines ?? []).map((s, i) => [`headlines[${i}]`, s]), ...(issue.tools ?? []).map((s, i) => [`tools[${i}]`, s])];
51
+ for (const [where, s] of secs) {
52
+ if (!s.title) out.push(`${where}: title is required`);
53
+ if (s.type && !TYPES.includes(s.type)) out.push(`${where}: type '${s.type}' is not one of ${TYPES.join("|")}`);
54
+ if (s.image && !/^https:\/\//.test(s.image.url || "")) out.push(`${where}: image.url must be https (host it with \`flowiq updates asset\`)`);
55
+ if (s.what && !Array.isArray(s.what)) out.push(`${where}: what must be an array of paragraphs`);
56
+ if (s.team && !Array.isArray(s.team)) out.push(`${where}: team must be an array of bullets`);
57
+ }
58
+ (issue.also ?? []).forEach((a, i) => {
59
+ if (!a.title) out.push(`also[${i}]: title is required`);
60
+ if (a.type && !TYPES.includes(a.type)) out.push(`also[${i}]: type '${a.type}' is not valid`);
61
+ });
62
+ const text = JSON.stringify(issue);
63
+ if (/—/.test(text)) out.push("contains an em dash (—) — house style is no em dashes in anything a teammate reads");
64
+ return out;
65
+ }
66
+
67
+ function fmtWhen(iso) {
68
+ return new Date(iso).toLocaleString("en-GB", { timeZone: "Africa/Johannesburg", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
69
+ }
70
+
71
+ export async function status(opts) {
72
+ let r;
73
+ try { r = await http.get("team-updates"); }
74
+ catch (e) { console.error(`Status failed: ${e.message}`); process.exit(1); }
75
+ if (opts?.json) { console.log(JSON.stringify(r, null, 2)); return; }
76
+ console.log(`Team updates`);
77
+ console.log(` uncovered: ${r.uncovered} changelog row(s) not yet emailed${r.covered_until ? ` (covered up to ${fmtWhen(r.covered_until)} SAST)` : " (nothing has ever been sent)"}`);
78
+ console.log(` recipients: ${(r.recipients ?? []).map((x) => x.email).join(", ") || "none"}`);
79
+ if (Array.isArray(r.recent) && r.recent.length) {
80
+ console.log(` recent:`);
81
+ for (const s of r.recent) console.log(` ${fmtWhen(s.created_at)} ${s.status.padEnd(6)} ${s.source.padEnd(9)} ${String(s.subject).slice(0, 70)} → ${s.recipients?.length ?? 0}`);
82
+ }
83
+ }
84
+
85
+ export async function uncovered(opts) {
86
+ let r;
87
+ try { r = await http.get("team-updates", { uncovered: 1, limit: opts?.limit ?? 60 }); }
88
+ catch (e) { console.error(`Failed: ${e.message}`); process.exit(1); }
89
+ if (opts?.json) { console.log(JSON.stringify(r, null, 2)); return; }
90
+ if (!r.rows?.length) { console.log("Nothing uncovered — every changelog row has been emailed."); return; }
91
+ console.log(`${r.rows.length} uncovered row(s)${r.since ? ` since ${fmtWhen(r.since)} SAST` : ""}:`);
92
+ for (const row of r.rows) console.log(` ${fmtWhen(row.created_at)} ${row.type.padEnd(11)} ${row.is_major ? "MAJOR " : " "} ${row.title}${row.org_name ? ` [${row.org_name}]` : ""}`);
93
+ }
94
+
95
+ export async function draft(opts) {
96
+ let r;
97
+ try { r = await http.get("team-updates", { draft: 1, limit: opts?.limit ?? 60, since: opts?.since }); }
98
+ catch (e) { console.error(`Draft failed: ${e.message}`); process.exit(1); }
99
+ if (!r.count) { console.log("Nothing to draft — every changelog row has been emailed. Use --since <iso> to re-cover a period."); return; }
100
+ await fs.mkdir(DIR, { recursive: true });
101
+ const out = path.resolve(process.cwd(), opts?.out || path.join(DIR, `${sastStamp()}.json`));
102
+ const doc = {
103
+ _help: [
104
+ "This is a default issue composed from the uncovered changelog rows. Enrich it, then `flowiq updates preview` / `send`.",
105
+ "headlines[]: type, major, title, what[] (paragraphs), team[] (bullets: how WE use it / what to tell clients), image{url,alt,caption} (host with `flowiq updates asset`), terminal{command,output}, link{label,url}.",
106
+ "also[]: short rows (title, note, type). tools[]: CLI / MCP sections, same shape as headlines.",
107
+ "Inline markup in any text: **bold**, `code`, [label](https://…). No emoji, no em dashes.",
108
+ ],
109
+ issue: r.issue,
110
+ };
111
+ await fs.writeFile(out, JSON.stringify(doc, null, 2) + "\n");
112
+ console.log(`Drafted ${r.count} row(s) → ${path.relative(process.cwd(), out)}`);
113
+ console.log(` headlines ${r.issue.headlines?.length ?? 0} · also ${r.issue.also?.length ?? 0} · cli/mcp ${r.issue.tools?.length ?? 0}`);
114
+ console.log(` next: enrich it, then \`flowiq updates preview ${path.relative(process.cwd(), out)} --open\``);
115
+ }
116
+
117
+ export async function asset(file, opts) {
118
+ const p = path.resolve(process.cwd(), file);
119
+ let buf;
120
+ try { buf = await fs.readFile(p); }
121
+ catch { console.error(`Error: cannot read ${p}`); process.exit(1); }
122
+ const ext = path.extname(p).toLowerCase();
123
+ const type = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif" }[ext];
124
+ if (!type) { console.error(`Error: ${ext || "(no extension)"} is not an image type the email can use (png, jpg, webp, gif)`); process.exit(1); }
125
+ let r;
126
+ try { r = await http.post("team-updates", { action: "asset", name: opts?.name || path.basename(p), content_type: type, data_base64: buf.toString("base64") }); }
127
+ catch (e) { console.error(`Upload failed: ${e.message}`); process.exit(1); }
128
+ console.log(r.url);
129
+ }
130
+
131
+ export async function preview(file, opts) {
132
+ const { issue, file: p } = await readIssue(file);
133
+ let r;
134
+ try { r = await http.post("team-updates", { action: "preview", issue, to_name: opts?.as || "Matt" }); }
135
+ catch (e) { console.error(`Preview failed: ${e.message}`); process.exit(1); }
136
+ const out = p.replace(/\.json$/i, "") + ".html";
137
+ await fs.writeFile(out, r.html);
138
+ console.log(`Subject: ${r.subject}`);
139
+ console.log(`Rendered → ${path.relative(process.cwd(), out)} (${(r.html.length / 1024).toFixed(1)} KB)`);
140
+ if (opts?.open) spawn(process.platform === "darwin" ? "open" : "xdg-open", [out], { stdio: "ignore", detached: true }).unref();
141
+ }
142
+
143
+ export async function send(file, opts) {
144
+ const { issue } = await readIssue(file);
145
+ const to = (opts?.to || "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
146
+ const test = !!opts?.test;
147
+ if (test && !to.length) {
148
+ let me;
149
+ try { me = await http.get("whoami"); }
150
+ catch (e) { console.error(`whoami failed: ${e.message}`); process.exit(1); }
151
+ const email = me?.user_email || me?.email || me?.userEmail;
152
+ if (!email) { console.error("Could not resolve your email for the test send; pass --to."); process.exit(1); }
153
+ to.push(email);
154
+ }
155
+ if (!test && !opts?.yes) {
156
+ console.error("Refusing: this sends to the whole Flowapt team and marks the changelog rows as covered. Re-run with --yes, or use --test first.");
157
+ process.exit(1);
158
+ }
159
+ let r;
160
+ try { r = await http.post("team-updates", { action: "send", issue, to: to.length ? to : undefined, test }); }
161
+ catch (e) { console.error(`Send failed: ${e.message}`); process.exit(1); }
162
+ const ok = (r.recipients ?? []).filter((x) => x.status === "sent");
163
+ const bad = (r.recipients ?? []).filter((x) => x.status !== "sent");
164
+ console.log(`${test ? "Test sent" : "Sent"}: "${issue.subject}"`);
165
+ for (const x of ok) console.log(` ✓ ${x.to}`);
166
+ for (const x of bad) console.log(` ✗ ${x.to} ${x.error ?? x.status}`);
167
+ if (!test) console.log(r.covers_until ? ` covered up to ${fmtWhen(r.covers_until)} SAST · record ${r.id}` : ` NOT marked as covered (every send failed)`);
168
+ if (bad.length) process.exitCode = 1;
169
+ }
@@ -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,9 @@ 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";
24
+ import * as teamUpdatesCmd from "./commands/team-updates.js";
22
25
  import * as agentConfigCmd from "./commands/agent-config.js";
23
26
  import * as agentsCmd from "./commands/agents.js";
24
27
  import * as agentUpdatesCmd from "./commands/agent-updates.js";
@@ -35,6 +38,7 @@ import * as guideCmd from "./commands/guide.js";
35
38
  import * as auditCmd from "./commands/audit.js";
36
39
  import * as storeApiCmd from "./commands/store-api.js";
37
40
  import { maybeNotifyUpdate } from "./update-check.js";
41
+ import * as doctorCmd from "./commands/doctor.js";
38
42
 
39
43
  // Read version from package.json so it stays in sync with the published npm
40
44
  // version automatically (single source of truth — bumping package.json on each
@@ -291,7 +295,7 @@ export function run(argv) {
291
295
  .action((orgId, name, opts) => templatesCmd.show(orgId, name, opts));
292
296
 
293
297
  // org (read-only org summary for the prompt-builder skill, creds stripped)
294
- const org = program.command("org").description("Org info (read) + create a new organization");
298
+ const org = program.command("org").description("Org info (read), create a new organization, read/set its feature flags");
295
299
  org.command("list [search]")
296
300
  .description("List organizations with their UUIDs — the lookup every other command needs (filter by name/slug)")
297
301
  .option("--all", "include inactive organizations")
@@ -309,6 +313,84 @@ export function run(argv) {
309
313
  .option("--json", "print the raw JSON payload")
310
314
  .action((orgId, opts) => orgCmd.info(orgId, opts));
311
315
 
316
+ // org flags — organizations.feature_flags (the Settings → Profile switches).
317
+ // Registry-allowlisted writes, credentials never read or written, dangerous
318
+ // keys need --yes. `list --key` is the cross-org census that was SQL-only
319
+ // until 10 Sep 2026.
320
+ const orgFlags = org.command("flags").description("Read + set an org's feature flags (Settings → Profile switches); `list --key` = cross-org census");
321
+ orgFlags.command("show <organization_id>")
322
+ .description("Every flag on the org, by Settings section (credentials redacted)")
323
+ .option("--json", "raw JSON")
324
+ .action((orgId, opts) => orgFlagsCmd.show(orgId, opts));
325
+ orgFlags.command("list")
326
+ .description("Cross-org census of ONE flag: which orgs carry it, and its value")
327
+ .requiredOption("--key <path>", "dotted flag path, e.g. export_insights.enabled or mcp_member_access.enabled")
328
+ .option("--all", "include inactive organizations")
329
+ .option("--on", "only orgs where the value is true")
330
+ .option("--off", "only orgs where the value is not true (false or absent)")
331
+ .option("--present", "only orgs that carry the key")
332
+ .option("--absent", "only orgs that do not carry the key")
333
+ .option("--json", "raw JSON")
334
+ .action((opts) => orgFlagsCmd.list(opts));
335
+ orgFlags.command("set <organization_id> <key> <value>")
336
+ .description("Set a registered flag (true/false, a number, JSON, or @file.json); type-checked server-side")
337
+ .option("--yes", "confirm a dangerous flag (the Profile tab's amber-warning switches)")
338
+ .option("--json", "raw JSON")
339
+ .action((orgId, key, value, opts) => orgFlagsCmd.set(orgId, key, value, opts));
340
+ orgFlags.command("unset <organization_id> <key>")
341
+ .description("Remove a flag key from the org (reverts to the platform default) — always needs --yes")
342
+ .option("--yes", "confirm")
343
+ .option("--json", "raw JSON")
344
+ .action((orgId, key, opts) => orgFlagsCmd.unset(orgId, key, opts));
345
+ orgFlags.command("keys")
346
+ .description("What is settable: every registered flag with its type, section and danger note")
347
+ .option("--section <name>", "filter by Settings section (substring)")
348
+ .option("--json", "raw JSON")
349
+ .action((opts) => orgFlagsCmd.keys(opts));
350
+
351
+ // insights — the Customer Insights Report (feature_flags.export_insights)
352
+ const collectEmail = (v, acc) => (acc || []).concat(v);
353
+ const insights = program.command("insights").description("Customer Insights Report: who has it, enable/disable per org, run one now");
354
+ insights.command("status [organization_id]")
355
+ .description("All orgs (or one): enabled, schedules as the scheduler reads them, recipients, own OpenAI key, last report, chats in 30d")
356
+ .option("--all", "include inactive organizations")
357
+ .option("--on", "only orgs with insights enabled")
358
+ .option("--off", "only orgs with insights NOT enabled")
359
+ .option("--min-chats <n>", "only orgs with at least n chatting contacts in the last 30 days")
360
+ .option("--sort <by>", "chats (default) | name | last")
361
+ .option("--json", "raw JSON")
362
+ .action((orgId, opts) => insightsCmd.status(orgId, opts));
363
+ insights.command("enable <organization_id>")
364
+ .description("Enable the report (or update its config). Cadence flags REPLACE the schedules; none given = keep existing, else weekly Monday")
365
+ .option("--daily", "add a daily schedule")
366
+ .option("--weekly [day]", "add a weekly schedule (day: monday … sunday; default monday)")
367
+ .option("--monthly [day]", "add a monthly schedule (day: 1-28 or last; default last)")
368
+ .option("--report <type>", "standard (default) | advanced — applies to the cadence flags given")
369
+ .option("--store-only", "the schedules given store the report without emailing anyone")
370
+ .option("--recipient <email>", "recipient list (repeatable; REPLACES the existing list)", collectEmail)
371
+ .option("--add-recipient <email>", "add to the existing recipients (repeatable)", collectEmail)
372
+ .option("--remove-recipient <email>", "remove from the existing recipients (repeatable)", collectEmail)
373
+ .option("--dry-run", "show what would be written, write nothing")
374
+ .option("--force", "write even if pre-flight fails (no OpenAI key / no recipients)")
375
+ .option("--json", "raw JSON")
376
+ .action((orgId, opts) => insightsCmd.enable(orgId, opts));
377
+ insights.command("disable <organization_id>")
378
+ .description("Turn the scheduled report off (schedules + recipients are kept)")
379
+ .option("--json", "raw JSON")
380
+ .action((orgId, opts) => insightsCmd.disable(orgId, opts));
381
+ insights.command("run <organization_id>")
382
+ .description("Run one report now (DRY RUN by default — add --commit). Same path as the Profile tab's Run Now")
383
+ .option("--period <p>", "daily (default: last 24h) | weekly (7d) | monthly (30d)")
384
+ .option("--from <date>", "custom window start (YYYY-MM-DD, SAST) — with --to")
385
+ .option("--to <date>", "custom window end (YYYY-MM-DD, SAST) — with --from")
386
+ .option("--report <type>", "standard | advanced (default: the org's schedule for that period, else standard)")
387
+ .option("--recipient <email>", "send to these instead of the configured recipients (repeatable)", collectEmail)
388
+ .option("--store-only", "store the report, email nobody")
389
+ .option("--commit", "actually fire it")
390
+ .option("--force", "fire even if pre-flight fails")
391
+ .option("--json", "raw JSON")
392
+ .action((orgId, opts) => insightsCmd.run(orgId, opts));
393
+
312
394
  // agent (list / create / config — config defaults to the active agent, or --agent <id>)
313
395
  const agent = program.command("agent").description("List, create, and configure an org's agents");
314
396
  agent.command("list <organization_id>")
@@ -783,6 +865,45 @@ export function run(argv) {
783
865
  .option("--out <file>", "write the JSON to a file instead of stdout")
784
866
  .action((orgId, path, opts) => storeApiCmd.wooGet(orgId, path, opts));
785
867
 
868
+ // updates — the "what changed in FlowIQ" email to the Flowapt team
869
+ const updates = program.command("updates").description("Team update email: what changed in FlowIQ, sent to every super admin (status / draft / preview / send)");
870
+ updates.command("status")
871
+ .description("uncovered changelog rows, recent sends and who receives the email")
872
+ .option("--json", "machine-readable output")
873
+ .action((opts) => teamUpdatesCmd.status(opts));
874
+ updates.command("uncovered")
875
+ .description("list the changelog rows no team update has covered yet")
876
+ .option("--limit <n>", "rows to list (default 60)")
877
+ .option("--json", "machine-readable output")
878
+ .action((opts) => teamUpdatesCmd.uncovered(opts));
879
+ updates.command("draft")
880
+ .description("write ./.flowiq/updates/<date>.json: a default issue composed from the uncovered rows, ready to enrich")
881
+ .option("--since <iso>", "compose from rows created after this time instead of the last covered point")
882
+ .option("--limit <n>", "rows to include (default 60)")
883
+ .option("--out <file>", "write the issue here instead of ./.flowiq/updates/<date>.json")
884
+ .action((opts) => teamUpdatesCmd.draft(opts));
885
+ updates.command("asset <file>")
886
+ .description("host an image (png/jpg/webp/gif, max 6 MB) for the email and print its public URL")
887
+ .option("--name <name>", "file name to store under (default: the file's own name)")
888
+ .action((file, opts) => teamUpdatesCmd.asset(file, opts));
889
+ updates.command("preview <file>")
890
+ .description("render the issue JSON to <file>.html exactly as it will be emailed")
891
+ .option("--as <first-name>", "greet this name in the preview (default Matt)")
892
+ .option("--open", "open the rendered HTML in the browser")
893
+ .action((file, opts) => teamUpdatesCmd.preview(file, opts));
894
+ updates.command("send <file>")
895
+ .description("email the issue: --test sends one copy to you (or --to) and marks nothing; without --test it goes to every super admin and needs --yes")
896
+ .option("--test", "send a single test copy only (to you unless --to is given); nothing is marked as covered")
897
+ .option("--to <emails>", "comma-separated recipients instead of the team list")
898
+ .option("--yes", "confirm the real send to the whole team")
899
+ .action((file, opts) => teamUpdatesCmd.send(file, opts));
900
+
901
+ // doctor (version + auth + server contract in one read-only command)
902
+ program.command("doctor")
903
+ .description("Check this install: version vs npm, auth, and whether the server still supports it. Exits 1 if something needs fixing")
904
+ .option("--json", "machine-readable output")
905
+ .action(async (opts) => { process.exitCode = await doctorCmd.doctor(opts); });
906
+
786
907
  // guide (bundled docs — always match the installed version)
787
908
  program.command("guide")
788
909
  .description("Read the team guide (how we use this CLI); --reference for the full command reference")