@flowapt/flowiq-cli 0.7.0 → 0.7.2
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 +61 -3
- package/TEAM-GUIDE.md +4 -0
- package/package.json +1 -1
- package/src/commands/team-updates.js +169 -0
- package/src/index.js +35 -1
package/README.md
CHANGED
|
@@ -188,6 +188,7 @@ flowiq ct list
|
|
|
188
188
|
- **Always `--dry-run` first** on a production agent — it prints added/removed/changed tool names before you commit.
|
|
189
189
|
- **Destructive pushes are blocked by default**: a push that removes a tool, disables one, or sends an empty `tools[]` (wiping everything) writes nothing and shows you exactly what it would strip — re-run with `--confirm` if intentional. Additive/no-op pushes are unaffected.
|
|
190
190
|
- Server-side validation is strict: tool `name` (`^[a-zA-Z0-9_-]{1,64}$`, no duplicates or built-in collisions), non-empty `description`, `http(s)` `endpoint`, method `GET/POST/PUT/PATCH/DELETE`, scalar headers, object-typed `parameters`/`injected_parameters`, valid auth/channels, timeout and response caps. Recursive schemas support strict nested objects, typed arrays, enums and numeric/string/item limits. Unknown tool/schema keys are rejected before anything writes.
|
|
191
|
+
- **`display_name` (optional, 10 Sep 2026):** the label the TEAM sees on the inbox tool-activity card (e.g. `Checked loyalty points`) — plain string, ≤80 chars, never sent to the AI. Leave it out and the card derives a label from the tool name.
|
|
191
192
|
- **Warnings (non-blocking):** an object with properties but no required fields. Unknown `{{placeholder}}` values are hard errors; injected parameters may additionally reference a declared top-level model parameter. Known runtime values include `organization_id`, `contact_id`, `agent_id`, `contact_whatsapp_id`, `contact_full_name`, `whatsapp_message_id`, `text`, `unique_message_id`, `unix_timestamp`, `operation_idempotency_key`, `supabase_anon_key`, and `openai_api_key`.
|
|
192
193
|
- **Retailer gateway credential:** `{{retailer_tools_internal_key}}` is super-admin-only and resolves only as the `x-api-key` value for `https://express.chatcart.io/retailer-tools/*` (or loopback in local tests). Validation and runtime both reject putting it in a body or sending it to any other host.
|
|
193
194
|
- `--agent` + filenames behave like `prompts`/`knowledge`; the file carries `agent_id`, so `push` targets the agent it was pulled from.
|
|
@@ -1054,7 +1055,9 @@ flowiq hours summary # every org with logged work this
|
|
|
1054
1055
|
|
|
1055
1056
|
### Client deck — `flowiq report deck status|build|narrate|generate|inputs|approve|unapprove|send|print|pull` (v0.6.5)
|
|
1056
1057
|
|
|
1057
|
-
The monthly
|
|
1058
|
+
The monthly client deck (`/reporting/deck` in the app; 10 slides, 11 for a store
|
|
1059
|
+
org — slide 8 is store health: cart abandonment, returning buyers and opt-in
|
|
1060
|
+
growth, online store only), driven from the
|
|
1058
1061
|
terminal. Everything runs server-side through the `flowiq-reporting-deck` edge
|
|
1059
1062
|
function: the metrics are built from the FROZEN month snapshot, the copy is
|
|
1060
1063
|
written by the model under the report rules and a numeric guard (every figure
|
|
@@ -1077,11 +1080,15 @@ flowiq report deck pull <org_id> 2026-08 # → ./.flowiq/repor
|
|
|
1077
1080
|
```
|
|
1078
1081
|
|
|
1079
1082
|
- Months freeze on the 1st (cron 82); a month with no snapshot cannot be built.
|
|
1080
|
-
Comparisons need the prior month's deck, which `build` creates on the fly
|
|
1083
|
+
Comparisons need the prior month's deck, which `build` creates on the fly (and rebuilds when it
|
|
1084
|
+
predates the current build logic, unless it is approved, sent or held).
|
|
1085
|
+
- Send fees are estimated at the category each template had on its send day. A send whose category
|
|
1086
|
+
cannot be known shows no fee and is left out of the broadcast return (`totals.unpriced_sends`).
|
|
1081
1087
|
- `inputs.json` shape: `{ "changes": { "<update_id>": { "hidden": false, "tag": "client_raised|flowapt_shipped", "title": "…", "description": "…" } }, "changes_reviewed": true, "action_points": { "<title>": "done|in_progress|waiting_on_you|ongoing|dropped" }, "waiting_on_you": [{ "title": "…", "unlocks": "…" }], "recipients_confirmed": true }`.
|
|
1082
1088
|
Free text (milestone, the four plan fields) is edited in the app; it is stored as overrides that survive `narrate`.
|
|
1083
1089
|
- `send --client` needs an approved deck AND `report.email.recipients` in the org's reporting config (Control center → Report delivery); it marks the deck `sent`. `--test-to` never changes status. Fees on the slides are the org's actual Meta billing when the token can read it, otherwise the rate-card estimate, and the footnote says which.
|
|
1084
1090
|
- Non-store orgs need `config.deck.outcome` (`source: handover | ticket_status | tag | keyword`, labels) — the revenue slides become outcome slides. Every verb except `status` and `pull` is audited.
|
|
1091
|
+
- Store orgs get slide 8, "Store health": cart abandonment (online checkouts started, bought, abandoned), first-time vs returning buyers and opt-in growth, each with a twelve-month strip and measured on the online store checkout only (till and other channels are named in the footer). `status --json` carries the headline figures under `store`; `config.deck.channel_labels {"<source_name>": "Label"}` renames a sales channel on the slide.
|
|
1085
1092
|
|
|
1086
1093
|
### Insights — `flowiq insights status|enable|disable|run` (v0.7.0)
|
|
1087
1094
|
|
|
@@ -1131,6 +1138,54 @@ flowiq insights run <organization_id> --from 2026-09-01 --to 2026-09-07 --store-
|
|
|
1131
1138
|
schedule for that period, else standard.
|
|
1132
1139
|
- `enable` / `disable` / `run` are audited (`flowiq audit --endpoint insights`).
|
|
1133
1140
|
|
|
1141
|
+
### Team updates — `flowiq updates status|uncovered|draft|asset|preview|send` (v0.7.1)
|
|
1142
|
+
|
|
1143
|
+
The **"what changed in FlowIQ" email to the Flowapt team** — every super admin
|
|
1144
|
+
gets it. An *issue* is a JSON file composed from `platform_changelog` rows:
|
|
1145
|
+
headline sections (a screenshot straight from the app, what it does, and a
|
|
1146
|
+
**For the team** note: how we use it, what to tell clients), an *Also shipped*
|
|
1147
|
+
list, and a *CLI and MCP* block. The renderer, the send (from
|
|
1148
|
+
flowiq@flowapt.com, reply-to Matt) and the record (`team_updates`, with the exact
|
|
1149
|
+
HTML that went out) live server-side in the `team-update-email` edge function.
|
|
1150
|
+
|
|
1151
|
+
```bash
|
|
1152
|
+
flowiq updates status # uncovered changelog rows · recent sends · recipients
|
|
1153
|
+
flowiq updates uncovered # the rows no issue has emailed yet
|
|
1154
|
+
flowiq updates draft # → ./.flowiq/updates/<date>.json, a default issue to enrich
|
|
1155
|
+
flowiq updates draft --since 2026-09-07T22:00:00+02:00 # compose from a period instead of the last covered point
|
|
1156
|
+
flowiq updates asset ./api-keys-card.png # host a screenshot, prints the https URL for image.url
|
|
1157
|
+
flowiq updates preview .flowiq/updates/2026-09-10.json --open # render to .html exactly as emailed
|
|
1158
|
+
flowiq updates send .flowiq/updates/2026-09-10.json --test # one copy to YOU; nothing marked covered
|
|
1159
|
+
flowiq updates send .flowiq/updates/2026-09-10.json --test --to kiah@flowapt.com
|
|
1160
|
+
flowiq updates send .flowiq/updates/2026-09-10.json --yes # the real send, to every super admin
|
|
1161
|
+
```
|
|
1162
|
+
|
|
1163
|
+
- **Issue shape** (`issue` key in the file): `subject`, `preheader`, `title`,
|
|
1164
|
+
`intro`, `date_label`, `headlines[]`, `also[]`, `tools[]`, `closing`,
|
|
1165
|
+
`changelog_ids[]`. A headline/tool section: `type` (feature | improvement |
|
|
1166
|
+
fix | ui | agent | cli | infra | db | mcp — the chip is the app's own), `major`,
|
|
1167
|
+
`title`, `what[]` (paragraphs), `team[]` (bullets), `image{url,alt,caption}`,
|
|
1168
|
+
`terminal{command,output}`, `link{label,url}`, `org_name`, `version`. An
|
|
1169
|
+
`also[]` item: `type`, `title`, `note`, `link`. Inline markup anywhere:
|
|
1170
|
+
`**bold**`, `` `code` ``, `[label](https://…)`.
|
|
1171
|
+
- **`preview` and `send` lint the file first** (required fields, unknown types,
|
|
1172
|
+
non-https images, em dashes) and stop with line-level help.
|
|
1173
|
+
- **`send` without `--test` needs `--yes`**: it emails the whole team AND moves
|
|
1174
|
+
the covered-up-to mark, so those changelog rows will never be auto-digested.
|
|
1175
|
+
`--test` sends to you (or `--to`) and marks nothing.
|
|
1176
|
+
- **The safety net:** pg_cron `team-update-email-daily` (08:30 SAST) emails a
|
|
1177
|
+
default digest of any changelog row that has gone un-emailed for 20h+, so a
|
|
1178
|
+
ship is never silently missed. The curated `send` is what stops it firing —
|
|
1179
|
+
compose the issue the same day you ship.
|
|
1180
|
+
- **Who may do what:** everyone with a staff key can `status`, `uncovered`,
|
|
1181
|
+
`draft`, `asset`, `preview` and `send --test` (a copy to yourself, nothing
|
|
1182
|
+
marked covered). **The real send is Matt only** — any other key gets
|
|
1183
|
+
`403 Only matt@flowapt.com may post the team update` and the attempt is
|
|
1184
|
+
audited (`ALLOWED_SENDERS` in `api/cli/team-updates.js`).
|
|
1185
|
+
- Every link in the email opens the app's Changelog at that entry
|
|
1186
|
+
(`app.flowiq.live/?changelog=1&entry=<id>`). `send` and `asset` are audited
|
|
1187
|
+
(`flowiq audit --endpoint team-updates`).
|
|
1188
|
+
|
|
1134
1189
|
### WhatsApp templates — `flowiq templates pull|list|show|create|status` (alias `tpl`)
|
|
1135
1190
|
|
|
1136
1191
|
Read an org's live templates straight from Meta (read-only), render any single
|
|
@@ -1387,7 +1442,10 @@ flowiq org flags keys --section "members"
|
|
|
1387
1442
|
response prints before → after plus the exact undo command.
|
|
1388
1443
|
- A key that is on the org but not in the registry is shown by `show` (and
|
|
1389
1444
|
`--json`) but cannot be set here — it is DB-only by design; add it to the
|
|
1390
|
-
Profile tab first.
|
|
1445
|
+
Profile tab first. **The registry is ONE shared file** (`api/_shared/feature-flag-registry.js`)
|
|
1446
|
+
that the Profile tab imports too, and every build checks the two agree — so a
|
|
1447
|
+
new Profile-tab switch is settable from the CLI the moment it deploys, with no
|
|
1448
|
+
npm publish.
|
|
1391
1449
|
|
|
1392
1450
|
### Agents — `flowiq agent list|create <org>`
|
|
1393
1451
|
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -81,6 +81,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
81
81
|
| Edit the text knowledge sources (get_more_answers playbooks) | `flowiq kn pull <org_id>` → edit `sources[]` → `flowiq kn push <slug>` |
|
|
82
82
|
| Edit the custom tools (API-call tools) | `flowiq ct pull <org_id>` → edit `tools[]` → `flowiq ct push <slug> --dry-run` → `flowiq ct push <slug>` |
|
|
83
83
|
| Turn one custom tool on/off | `flowiq ct enable\|disable <org_id> <tool_name>` |
|
|
84
|
+
| Give a custom tool a friendly name on the inbox tool-activity card | add `"display_name": "Checked loyalty points"` to that tool in `tools[]` → `flowiq ct push` (≤80 chars, never shown to the AI; empty = derived from the tool name) |
|
|
84
85
|
| Edit keyword auto-replies (incl. competition entry keywords, add/remove-tag, set-agent and delay actions) | `flowiq kw pull <org_id>` → edit → `flowiq kw push <slug> --dry-run` → `flowiq kw push <slug>` |
|
|
85
86
|
| Send a **different auto-reply depending on the contact** (e.g. "we already have your email" vs "send us your email") | add `"when": {"field":"email","op":"is_not_empty"}` to one action and `is_empty` to the other — see *Keywords* in `flowiq guide --reference`. CLI-only, no dashboard field yet |
|
|
86
87
|
| Make a follow-up button **close the customer's ticket** ("Query solved") or **re-alert the team** ("I still need help") | keyword action `{"type":"update_ticket_status","status":"resolved"}` / `{"type":"renotify_ticket"}` — see *Keywords* in `flowiq guide --reference`. CLI-only, no dashboard field yet |
|
|
@@ -103,6 +104,9 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
103
104
|
| 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
105
|
| 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
106
|
| Talk to the live agent safely (no real WhatsApp ever sent) | `flowiq test send <org_id> "hi, do you sell X?"` |
|
|
107
|
+
| **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` |
|
|
108
|
+
| 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 |
|
|
109
|
+
| 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
110
|
| 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
111
|
> **Publishing the CLI (maintainers only):** publish from a clean clone, never
|
|
108
112
|
> 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.
|
|
3
|
+
"version": "0.7.2",
|
|
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";
|
|
@@ -410,7 +411,7 @@ export function run(argv) {
|
|
|
410
411
|
.option("--model <model>", "settings.model (e.g. gpt-5.4-mini)")
|
|
411
412
|
.option("--reasoning-effort <level>", "settings.reasoning_effort — low|medium|high (\"\" clears it); pairs with reasoning models (e.g. gpt-5.6-luna + high)")
|
|
412
413
|
.option("--rename <name>", "rename the agent")
|
|
413
|
-
.option("--tool <flag=bool>", "toggle a tool flag (repeatable): woo_order_build/woo_tip_field/woo_order_note_field/view_cart_tool/restock_tool/block_tool_status/postal_code_tool_status/shopify_products_web_chat/ticket_tool_status/collapse_product_variants", agentConfigCmd.collectTool, [])
|
|
414
|
+
.option("--tool <flag=bool>", "toggle a tool flag (repeatable): woo_order_build/woo_tip_field/woo_order_note_field/view_cart_tool/restock_tool/block_tool_status/postal_code_tool_status/shopify_products_web_chat/ticket_tool_status/product_lookup/email_request_tool/collapse_product_variants", agentConfigCmd.collectTool, [])
|
|
414
415
|
.option("--discount [bool]", "agent.discount.enabled (true if bare)")
|
|
415
416
|
.option("--test-contact-number <number>", "settings.test_contact_number — the contact `flowiq test` uses (use a FAKE number; \"\" clears it → synthetic fallback)")
|
|
416
417
|
.option("--test-contact-name <name>", "settings.test_contact_name — display name for the test contact (\"\" clears it)")
|
|
@@ -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")
|