@flowapt/flowiq-cli 0.7.0 → 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.
package/README.md CHANGED
@@ -1131,6 +1131,54 @@ flowiq insights run <organization_id> --from 2026-09-01 --to 2026-09-07 --store-
1131
1131
  schedule for that period, else standard.
1132
1132
  - `enable` / `disable` / `run` are audited (`flowiq audit --endpoint insights`).
1133
1133
 
1134
+ ### Team updates — `flowiq updates status|uncovered|draft|asset|preview|send` (v0.7.1)
1135
+
1136
+ The **"what changed in FlowIQ" email to the Flowapt team** — every super admin
1137
+ gets it. An *issue* is a JSON file composed from `platform_changelog` rows:
1138
+ headline sections (a screenshot straight from the app, what it does, and a
1139
+ **For the team** note: how we use it, what to tell clients), an *Also shipped*
1140
+ list, and a *CLI and MCP* block. The renderer, the send (from
1141
+ flowiq@flowapt.com, reply-to Matt) and the record (`team_updates`, with the exact
1142
+ HTML that went out) live server-side in the `team-update-email` edge function.
1143
+
1144
+ ```bash
1145
+ flowiq updates status # uncovered changelog rows · recent sends · recipients
1146
+ flowiq updates uncovered # the rows no issue has emailed yet
1147
+ flowiq updates draft # → ./.flowiq/updates/<date>.json, a default issue to enrich
1148
+ flowiq updates draft --since 2026-09-07T22:00:00+02:00 # compose from a period instead of the last covered point
1149
+ flowiq updates asset ./api-keys-card.png # host a screenshot, prints the https URL for image.url
1150
+ flowiq updates preview .flowiq/updates/2026-09-10.json --open # render to .html exactly as emailed
1151
+ flowiq updates send .flowiq/updates/2026-09-10.json --test # one copy to YOU; nothing marked covered
1152
+ flowiq updates send .flowiq/updates/2026-09-10.json --test --to kiah@flowapt.com
1153
+ flowiq updates send .flowiq/updates/2026-09-10.json --yes # the real send, to every super admin
1154
+ ```
1155
+
1156
+ - **Issue shape** (`issue` key in the file): `subject`, `preheader`, `title`,
1157
+ `intro`, `date_label`, `headlines[]`, `also[]`, `tools[]`, `closing`,
1158
+ `changelog_ids[]`. A headline/tool section: `type` (feature | improvement |
1159
+ fix | ui | agent | cli | infra | db | mcp — the chip is the app's own), `major`,
1160
+ `title`, `what[]` (paragraphs), `team[]` (bullets), `image{url,alt,caption}`,
1161
+ `terminal{command,output}`, `link{label,url}`, `org_name`, `version`. An
1162
+ `also[]` item: `type`, `title`, `note`, `link`. Inline markup anywhere:
1163
+ `**bold**`, `` `code` ``, `[label](https://…)`.
1164
+ - **`preview` and `send` lint the file first** (required fields, unknown types,
1165
+ non-https images, em dashes) and stop with line-level help.
1166
+ - **`send` without `--test` needs `--yes`**: it emails the whole team AND moves
1167
+ the covered-up-to mark, so those changelog rows will never be auto-digested.
1168
+ `--test` sends to you (or `--to`) and marks nothing.
1169
+ - **The safety net:** pg_cron `team-update-email-daily` (08:30 SAST) emails a
1170
+ default digest of any changelog row that has gone un-emailed for 20h+, so a
1171
+ ship is never silently missed. The curated `send` is what stops it firing —
1172
+ compose the issue the same day you ship.
1173
+ - **Who may do what:** everyone with a staff key can `status`, `uncovered`,
1174
+ `draft`, `asset`, `preview` and `send --test` (a copy to yourself, nothing
1175
+ marked covered). **The real send is Matt only** — any other key gets
1176
+ `403 Only matt@flowapt.com may post the team update` and the attempt is
1177
+ audited (`ALLOWED_SENDERS` in `api/cli/team-updates.js`).
1178
+ - Every link in the email opens the app's Changelog at that entry
1179
+ (`app.flowiq.live/?changelog=1&entry=<id>`). `send` and `asset` are audited
1180
+ (`flowiq audit --endpoint team-updates`).
1181
+
1134
1182
  ### WhatsApp templates — `flowiq templates pull|list|show|create|status` (alias `tpl`)
1135
1183
 
1136
1184
  Read an org's live templates straight from Meta (read-only), render any single
package/TEAM-GUIDE.md CHANGED
@@ -103,6 +103,9 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
103
103
  | Agent can't quote ANY price / "I can't pull the live menu" | `flowiq agent config <org_id> --tool product_lookup=true` — name-based fuzzy lookup that works without embeddings or a live OpenAI key (semantic `get_product_info` needs both) |
104
104
  | Client wants leads/requests emailed to their team instead of a human escalation | `flowiq agent config <org_id> --tool email_request_tool=true`, then set the recipient list in Agents → Tool Library → Email Request to Team (no CLI path for the addresses yet) |
105
105
  | Talk to the live agent safely (no real WhatsApp ever sent) | `flowiq test send <org_id> "hi, do you sell X?"` |
106
+ | **Tell the team what shipped** (the FlowIQ team update email) | `flowiq updates draft` → enrich the JSON (screenshots via `flowiq updates asset`, a *For the team* note per headline) → `flowiq updates preview <file> --open` → `flowiq updates send <file> --test` → `flowiq updates send <file> --yes` |
107
+ | Can I use `flowiq updates`? | Yes: `status`, `uncovered`, `draft`, `preview` and `send --test` (a copy to yourself) work for everyone. Only the real `send --yes` to the whole team is Matt's; anyone else gets a 403 there |
108
+ | Which changelog rows has nobody emailed the team about yet? | `flowiq updates status` / `flowiq updates uncovered` — anything left 20h+ goes out automatically at 08:30 SAST as a plain digest |
106
109
  | Set which contact `flowiq test` uses (use a FAKE number!) | `flowiq agent config <org_id> --test-contact-number 27000000001 --test-contact-name "QA Bot"` |
107
110
  > **Publishing the CLI (maintainers only):** publish from a clean clone, never
108
111
  > from your working tree — `npm publish` packs whatever is on disk. A
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.7.0",
3
+ "version": "0.7.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": {
@@ -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
+ }
package/src/index.js CHANGED
@@ -21,6 +21,7 @@ import * as templatesCmd from "./commands/templates.js";
21
21
  import * as orgCmd from "./commands/org.js";
22
22
  import * as orgFlagsCmd from "./commands/org-flags.js";
23
23
  import * as insightsCmd from "./commands/insights.js";
24
+ import * as teamUpdatesCmd from "./commands/team-updates.js";
24
25
  import * as agentConfigCmd from "./commands/agent-config.js";
25
26
  import * as agentsCmd from "./commands/agents.js";
26
27
  import * as agentUpdatesCmd from "./commands/agent-updates.js";
@@ -864,6 +865,39 @@ export function run(argv) {
864
865
  .option("--out <file>", "write the JSON to a file instead of stdout")
865
866
  .action((orgId, path, opts) => storeApiCmd.wooGet(orgId, path, opts));
866
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
+
867
901
  // doctor (version + auth + server contract in one read-only command)
868
902
  program.command("doctor")
869
903
  .description("Check this install: version vs npm, auth, and whether the server still supports it. Exits 1 if something needs fixing")