@flowapt/flowiq-cli 0.6.8 → 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.
- package/README.md +131 -1
- package/TEAM-GUIDE.md +25 -0
- package/package.json +1 -1
- package/src/commands/doctor.js +150 -0
- package/src/commands/insights.js +201 -0
- package/src/commands/org-flags.js +151 -0
- package/src/flag-values.js +42 -0
- package/src/flag-values.test.mjs +51 -0
- package/src/http.js +38 -0
- package/src/index.js +88 -1
- package/src/insights-config.js +69 -0
- package/src/insights-config.test.mjs +51 -0
- package/src/update-check.js +37 -3
package/README.md
CHANGED
|
@@ -555,6 +555,48 @@ flowiq tag remove <org_id> vip,old-promo --confirm # remove tag(s) from ALL con
|
|
|
555
555
|
matches — re-run until `matched 0`. Idempotent and safe to re-run.
|
|
556
556
|
- **Undo** any tag with `flowiq tag remove <org> <tag> --confirm`.
|
|
557
557
|
|
|
558
|
+
### Staying current — `flowiq doctor` (v0.6.9)
|
|
559
|
+
|
|
560
|
+
```bash
|
|
561
|
+
flowiq doctor # version vs npm, auth, and whether the server still supports this install
|
|
562
|
+
flowiq doctor --json # same, machine-readable; exits 1 when something needs fixing
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
```
|
|
566
|
+
✓ version 0.6.9 (latest)
|
|
567
|
+
✓ api https://api.flowiq.live
|
|
568
|
+
✓ auth you@flowapt.com · super admin
|
|
569
|
+
✓ server contract 0.6.8 or newer · build 1f0fd48c
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
**Three ways this install tells you it is stale**, so nobody — person or AI
|
|
573
|
+
assistant — has to remember to check:
|
|
574
|
+
|
|
575
|
+
1. **npm nudge.** Any command prints, on stderr, when a newer version is
|
|
576
|
+
published:
|
|
577
|
+
`⬆ FLOWIQ CLI OUT OF DATE — installed X, latest Y / Run this before continuing: npm i -g @flowapt/flowiq-cli@latest`.
|
|
578
|
+
The registry is polled at most once a day by a detached child, so the hint
|
|
579
|
+
costs nothing on the hot path and appears on the next run after a release.
|
|
580
|
+
2. **Server contract.** Every `api/cli/*` response carries
|
|
581
|
+
`X-Flowiq-Min-Version` — the oldest client the deployed server still
|
|
582
|
+
supports — and the CLI warns once per process when this install is below it.
|
|
583
|
+
This is the half npm cannot tell you: the registry knows what is *published*,
|
|
584
|
+
not whether the **server** still speaks your dialect. It is instant and
|
|
585
|
+
offline-safe, riding on a request you were making anyway.
|
|
586
|
+
3. **`flowiq doctor`**, when you want to ask deliberately.
|
|
587
|
+
|
|
588
|
+
⚠️ Until 9 Sep 2026 the npm nudge was also gated on `process.stderr.isTTY`, so
|
|
589
|
+
it was invisible to exactly the audience that most needs it — an assistant
|
|
590
|
+
running `flowiq` through a tool call gets a pipe, not a TTY. Teammates' AI
|
|
591
|
+
sessions could run a months-old CLI forever without ever being told. Writing to
|
|
592
|
+
stderr was already the whole safety property; the TTY gate bought nothing.
|
|
593
|
+
|
|
594
|
+
**Bumping `MIN_CLI_VERSION`** (`api/cli/_auth.js`): only when a server change
|
|
595
|
+
would MISBEHAVE on an older client — a removed or renamed response field, a
|
|
596
|
+
changed request shape, a validation rule an old client cannot satisfy. Never for
|
|
597
|
+
additive work; a new endpoint or an opt-in query param leaves old clients
|
|
598
|
+
correct, and crying wolf trains people to ignore the warning.
|
|
599
|
+
|
|
558
600
|
### Publishing (maintainers) — publish from a CLEAN checkout
|
|
559
601
|
|
|
560
602
|
`npm publish` packs the **working tree**, not your branch. This repo's local
|
|
@@ -1041,6 +1083,54 @@ flowiq report deck pull <org_id> 2026-08 # → ./.flowiq/repor
|
|
|
1041
1083
|
- `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.
|
|
1042
1084
|
- 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.
|
|
1043
1085
|
|
|
1086
|
+
### Insights — `flowiq insights status|enable|disable|run` (v0.7.0)
|
|
1087
|
+
|
|
1088
|
+
The **Customer Insights Report** (the scheduled AI email: exec summary, top
|
|
1089
|
+
questions / complaints / topics, sentiment, product mentions — cron 72, 09:00
|
|
1090
|
+
SAST) per org. Reads and writes `feature_flags.export_insights` exactly the
|
|
1091
|
+
way the Insights Studio does, and fires a report the way the Profile tab's
|
|
1092
|
+
Run Now does.
|
|
1093
|
+
|
|
1094
|
+
```bash
|
|
1095
|
+
flowiq insights status # every org: STATE · CHATS30D · KEY · LAST REPORT · RCPT · SCHEDULES
|
|
1096
|
+
flowiq insights status --off --min-chats 20 # who is missing it and has real chat volume
|
|
1097
|
+
flowiq insights status <organization_id> # one org + its last 10 reports
|
|
1098
|
+
|
|
1099
|
+
flowiq insights enable <organization_id> --weekly monday --recipient client@example.com --recipient matt@flowapt.com
|
|
1100
|
+
flowiq insights enable <organization_id> --daily --store-only --monthly last # two schedules; dailies stored, not emailed
|
|
1101
|
+
flowiq insights enable <organization_id> --monthly 1 --report advanced # advanced engine (PDF + Studio config)
|
|
1102
|
+
flowiq insights enable <organization_id> --add-recipient kiah@flowapt.com # edit recipients, keep the cadence
|
|
1103
|
+
flowiq insights enable <organization_id> --weekly friday --dry-run # show what would be written
|
|
1104
|
+
|
|
1105
|
+
flowiq insights disable <organization_id> # enabled=false; schedules + recipients kept
|
|
1106
|
+
|
|
1107
|
+
flowiq insights run <organization_id> --period weekly --recipient matt@flowapt.com # DRY RUN: engine, window, recipients, message count
|
|
1108
|
+
flowiq insights run <organization_id> --period weekly --recipient matt@flowapt.com --commit
|
|
1109
|
+
flowiq insights run <organization_id> --from 2026-09-01 --to 2026-09-07 --store-only --commit
|
|
1110
|
+
```
|
|
1111
|
+
|
|
1112
|
+
- **`enable` composes the whole config** (`enabled` + `recipient_emails` +
|
|
1113
|
+
`schedules[]`). Cadence flags (`--daily` / `--weekly [day]` / `--monthly [day]`)
|
|
1114
|
+
REPLACE the schedules; none given keeps what exists, or applies the default
|
|
1115
|
+
(weekly Monday, standard, emailed). `--report` and `--store-only` apply to the
|
|
1116
|
+
cadence flags you pass. `--recipient` replaces the list; `--add-recipient` /
|
|
1117
|
+
`--remove-recipient` edit it.
|
|
1118
|
+
- **It refuses the three ways an enabled org silently produces nothing**
|
|
1119
|
+
(`--force` overrides): the org has **no OpenAI key** (both engines hard-require
|
|
1120
|
+
`organizations.openai_api_key`); an emailing schedule with **no recipients**
|
|
1121
|
+
(the engines would email a hardcoded fallback address); an **inactive** org.
|
|
1122
|
+
- **`status` shows schedules as the scheduler will actually run them.** An
|
|
1123
|
+
enabled org with no schedules stored runs *daily standard* by default — it is
|
|
1124
|
+
marked `(implicit)`; `enable` replaces that with an explicit schedule.
|
|
1125
|
+
- **`run` is a dry run by default** (it costs OpenAI tokens and can email a
|
|
1126
|
+
client): it prints the engine, the window, who would get it and how many
|
|
1127
|
+
customer messages are in the window, and refuses an empty window. `--commit`
|
|
1128
|
+
fires it through python-render (fire-and-forget) and prints the job id; the
|
|
1129
|
+
report shows under *Recent reports* in `status <org>` a few minutes later.
|
|
1130
|
+
- `--from` / `--to` are SAST calendar days. `--report` defaults to the org's
|
|
1131
|
+
schedule for that period, else standard.
|
|
1132
|
+
- `enable` / `disable` / `run` are audited (`flowiq audit --endpoint insights`).
|
|
1133
|
+
|
|
1044
1134
|
### WhatsApp templates — `flowiq templates pull|list|show|create|status` (alias `tpl`)
|
|
1045
1135
|
|
|
1046
1136
|
Read an org's live templates straight from Meta (read-only), render any single
|
|
@@ -1232,7 +1322,7 @@ Notes:
|
|
|
1232
1322
|
org, which endpoint or query, how many records. **The response body is never
|
|
1233
1323
|
stored**; the log records what was asked, not the customer data that came back.
|
|
1234
1324
|
|
|
1235
|
-
### Org — `flowiq org create` / `flowiq org info <organization_id>`
|
|
1325
|
+
### Org — `flowiq org create` / `flowiq org info <organization_id>` / `flowiq org flags …`
|
|
1236
1326
|
|
|
1237
1327
|
Create a brand-new organization, or look one up (read-only; raw store/Meta
|
|
1238
1328
|
credentials are stripped server-side).
|
|
@@ -1259,6 +1349,46 @@ flowiq prompts pull <new_org_id> # edit → push
|
|
|
1259
1349
|
(No `agent config` step any more — since 29 Aug 2026 a new agent is born on the
|
|
1260
1350
|
house default: `gpt-5.6-luna`, `reasoning_effort high`, `use_settings_prompt` ON.)
|
|
1261
1351
|
|
|
1352
|
+
#### Feature flags — `flowiq org flags show|list|set|unset|keys` (v0.7.0)
|
|
1353
|
+
|
|
1354
|
+
The Settings → Profile switches (`organizations.feature_flags`) from the
|
|
1355
|
+
terminal. `show` reads one org, `list --key` is the cross-org census, `set` /
|
|
1356
|
+
`unset` write. Only keys the Profile tab manages can be set (server-side
|
|
1357
|
+
allowlist + type check); anything that looks like a credential is redacted on
|
|
1358
|
+
read and refused on write.
|
|
1359
|
+
|
|
1360
|
+
```bash
|
|
1361
|
+
flowiq org flags show <organization_id> # every flag, by Settings section
|
|
1362
|
+
flowiq org flags list --key export_insights.enabled --off # which orgs do NOT have it (--on / --present / --absent / --all)
|
|
1363
|
+
flowiq org flags list --key mcp_member_access.enabled --on
|
|
1364
|
+
|
|
1365
|
+
flowiq org flags set <organization_id> mcp_member_access.enabled true
|
|
1366
|
+
flowiq org flags set <organization_id> wait_for_more_messages 5 # integer, 0-60
|
|
1367
|
+
flowiq org flags set <organization_id> UTM.excluded_campaign_keywords "test,internal" # string[] (comma list or JSON)
|
|
1368
|
+
flowiq org flags set <organization_id> integrations_visible.instagram true # wildcard keys take one more segment
|
|
1369
|
+
flowiq org flags set <organization_id> vert.active true --yes # dangerous keys need --yes
|
|
1370
|
+
flowiq org flags set <organization_id> export_insights @insights.json # JSON from a file (prefer `flowiq insights enable`)
|
|
1371
|
+
|
|
1372
|
+
flowiq org flags unset <organization_id> wait_for_more_messages --yes # remove the key (= platform default); always --yes
|
|
1373
|
+
flowiq org flags keys # what is settable, with type + danger notes
|
|
1374
|
+
flowiq org flags keys --section "members"
|
|
1375
|
+
```
|
|
1376
|
+
|
|
1377
|
+
- **Values:** `true`/`false`, a whole number, JSON (`'["a","b"]'` / `'{"enabled":true}'`),
|
|
1378
|
+
`@file.json`, or a comma list for list keys. The server validates against the
|
|
1379
|
+
registry — a boolean given `12` is a 400, not a corrupted flag.
|
|
1380
|
+
- **Dangerous keys** (the Profile tab's amber-warning switches: `vert.active`,
|
|
1381
|
+
`ip_whitelist`, `follow_up.enabled`, `subscriptions_test_mode`,
|
|
1382
|
+
`api_key_member_access.enabled`, `export_insights`, `wait_for_more_messages`, …)
|
|
1383
|
+
are refused without `--yes`, with the reason printed.
|
|
1384
|
+
- **Writes are atomic per path** (the same `_jsonb_deep_set` the Profile tab
|
|
1385
|
+
uses), so `set a.b` never clobbers `a.c` that someone else just changed.
|
|
1386
|
+
- **Every set/unset is audited** (`flowiq audit --endpoint org-flags`) and the
|
|
1387
|
+
response prints before → after plus the exact undo command.
|
|
1388
|
+
- A key that is on the org but not in the registry is shown by `show` (and
|
|
1389
|
+
`--json`) but cannot be set here — it is DB-only by design; add it to the
|
|
1390
|
+
Profile tab first.
|
|
1391
|
+
|
|
1262
1392
|
### Agents — `flowiq agent list|create <org>`
|
|
1263
1393
|
|
|
1264
1394
|
List an org's agents (to discover ids) and create a new one.
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -108,6 +108,23 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
108
108
|
> from your working tree — `npm publish` packs whatever is on disk. A
|
|
109
109
|
> `prepublishOnly` check now stops you if `cli/` differs from `origin/main`.
|
|
110
110
|
|
|
111
|
+
### Keeping the CLI current
|
|
112
|
+
|
|
113
|
+
Run **`flowiq doctor`** any time you are unsure — it checks your version against
|
|
114
|
+
npm, your login, and whether the server still supports your install, and tells
|
|
115
|
+
you the exact command to fix anything wrong.
|
|
116
|
+
|
|
117
|
+
You will usually not need to: the CLI now tells you itself. If a newer version
|
|
118
|
+
is out, or if your install is older than the server supports, every command
|
|
119
|
+
prints a line starting `⬆ FLOWIQ CLI …` with the fix:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
npm i -g @flowapt/flowiq-cli@latest
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
That warning is printed for AI assistants too, not just people — so if you work
|
|
126
|
+
with Claude in this repo, it will see it and can update for you.
|
|
127
|
+
|
|
111
128
|
### Campaign naming — `Date_Campaign`
|
|
112
129
|
|
|
113
130
|
Every campaign tag is **the date the broadcast goes out, then the campaign
|
|
@@ -163,6 +180,14 @@ several.
|
|
|
163
180
|
| See a client's pending change requests | `flowiq au pull <org_id>` |
|
|
164
181
|
| Close a client's change request (after verifying the fix!) | `flowiq au resolve <update_id> --note "what changed"` — the client reads the note |
|
|
165
182
|
| Check an org's platform + active agent | `flowiq org info <org_id>` |
|
|
183
|
+
| **See every Settings → Profile switch on an org** | `flowiq org flags show <org_id>` (credentials are never shown) |
|
|
184
|
+
| **Which orgs have a flag on / off** (insights, MCP member access, a debounce…) | `flowiq org flags list --key export_insights.enabled --off` · `--key mcp_member_access.enabled --on` |
|
|
185
|
+
| Flip a Settings → Profile switch from the terminal | `flowiq org flags set <org_id> mcp_member_access.enabled true` — `flowiq org flags keys` lists what you can set; the risky ones ask for `--yes`; the reply prints the undo |
|
|
186
|
+
| Set the agent's message debounce ("customer sends 3 fragments, agent replies 3 times") | `flowiq org flags set <org_id> wait_for_more_messages 5` (seconds, 0-60) |
|
|
187
|
+
| **Who is missing the Customer Insights report?** | `flowiq insights status --off --min-chats 20` — KEY `NO` means the org has no OpenAI key and the report cannot run |
|
|
188
|
+
| Switch the insights report on for a client | `flowiq insights enable <org_id> --weekly monday --recipient client@example.com --recipient you@flowapt.com` — it refuses if there is no OpenAI key or nobody to email |
|
|
189
|
+
| Store insights daily without emailing anyone (build history first) | `flowiq insights enable <org_id> --daily --store-only` |
|
|
190
|
+
| Send one insights report right now | `flowiq insights run <org_id> --period weekly --recipient you@flowapt.com` (dry run) → add `--commit` |
|
|
166
191
|
| Work a Pin Board task | `flowiq pin list-remote open` → `pull` → edit → `push` |
|
|
167
192
|
| Log hours you worked for a client (every package = 10h Flowapt work/month) | `flowiq hours log <org_id> --hours 1.5 --desc "what you did"` — plain language, the client sees it on their Client Console |
|
|
168
193
|
| Check a client's package hours / the month across all clients | `flowiq hours list <org_id>` / `flowiq hours summary` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowapt/flowiq-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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,150 @@
|
|
|
1
|
+
// `flowiq doctor` — one command that answers "is my CLI healthy and current?"
|
|
2
|
+
//
|
|
3
|
+
// Written because the honest answer used to need three commands and some
|
|
4
|
+
// knowledge: `flowiq --version` (installed), `npm view` (latest), and nothing
|
|
5
|
+
// at all for "does the SERVER still speak my dialect?". A teammate — or a
|
|
6
|
+
// teammate's AI assistant — needs one place that says what is wrong and the
|
|
7
|
+
// exact command to fix it.
|
|
8
|
+
//
|
|
9
|
+
// Read-only, no org needed, safe to run any time. Exit code 1 when something
|
|
10
|
+
// actionable is wrong, so it can gate a script.
|
|
11
|
+
|
|
12
|
+
import { http, serverContract, cliVersion } from "../http.js";
|
|
13
|
+
import { cachedLatest, fetchLatestNow, cmpVersion } from "../update-check.js";
|
|
14
|
+
import { loadConfig, configDir } from "../config.js";
|
|
15
|
+
|
|
16
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
17
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
18
|
+
const amber = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
19
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
20
|
+
|
|
21
|
+
const OK = green("✓");
|
|
22
|
+
const WARN = amber("⚠");
|
|
23
|
+
const BAD = red("✗");
|
|
24
|
+
|
|
25
|
+
function ageLabel(ts) {
|
|
26
|
+
if (!ts) return "never";
|
|
27
|
+
const h = (Date.now() - ts) / 3600000;
|
|
28
|
+
if (h < 1) return `${Math.round(h * 60)} min ago`;
|
|
29
|
+
if (h < 48) return `${h.toFixed(1)} h ago`;
|
|
30
|
+
return `${Math.round(h / 24)} days ago`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function doctor(opts = {}) {
|
|
34
|
+
const installed = cliVersion();
|
|
35
|
+
const problems = [];
|
|
36
|
+
const out = {
|
|
37
|
+
cli_version: installed,
|
|
38
|
+
npm_latest: null,
|
|
39
|
+
server_min_version: null,
|
|
40
|
+
api_build: null,
|
|
41
|
+
api_url: null,
|
|
42
|
+
authenticated: false,
|
|
43
|
+
user: null,
|
|
44
|
+
problems: [],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const lines = [];
|
|
48
|
+
lines.push(`flowiq doctor`);
|
|
49
|
+
lines.push("");
|
|
50
|
+
|
|
51
|
+
// ── 1. installed vs npm latest ────────────────────────────────────────────
|
|
52
|
+
// Ask the registry LIVE here (with a short timeout). The background cache is
|
|
53
|
+
// refreshed at most once a day, so on the day of a release it is stale — which
|
|
54
|
+
// is precisely when someone runs doctor.
|
|
55
|
+
const live = await fetchLatestNow(3000);
|
|
56
|
+
const cached = cachedLatest();
|
|
57
|
+
const latest = live || cached.latest;
|
|
58
|
+
out.npm_latest = latest;
|
|
59
|
+
|
|
60
|
+
if (!latest) {
|
|
61
|
+
lines.push(` ${WARN} version installed ${installed} · could not reach npm ${dim("(offline?)")}`);
|
|
62
|
+
} else if (cmpVersion(latest, installed) > 0) {
|
|
63
|
+
lines.push(` ${BAD} version installed ${installed} · latest ${latest}`);
|
|
64
|
+
problems.push({
|
|
65
|
+
what: `CLI is out of date (${installed} → ${latest})`,
|
|
66
|
+
fix: "npm i -g @flowapt/flowiq-cli@latest",
|
|
67
|
+
});
|
|
68
|
+
} else if (cmpVersion(installed, latest) > 0) {
|
|
69
|
+
// A dev machine, a worktree build, or a version bumped but not yet
|
|
70
|
+
// published. Saying "(latest)" here would be a lie, and it is exactly the
|
|
71
|
+
// state in which someone wonders why teammates do not have their feature.
|
|
72
|
+
lines.push(` ${WARN} version installed ${installed} is AHEAD of npm (${latest}) ${dim("— unpublished build")}`);
|
|
73
|
+
} else {
|
|
74
|
+
lines.push(` ${OK} version ${installed} ${dim(live ? "(latest)" : `(latest, cached ${ageLabel(cached.checkedAt)})`)}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── 2. config + auth ──────────────────────────────────────────────────────
|
|
78
|
+
let cfg = {};
|
|
79
|
+
try { cfg = await loadConfig(); } catch { /* handled below */ }
|
|
80
|
+
out.api_url = cfg.api_url || null;
|
|
81
|
+
lines.push(` ${cfg.api_url ? OK : BAD} api ${cfg.api_url || "not configured"} ${dim(configDir())}`);
|
|
82
|
+
if (!cfg.api_url) problems.push({ what: "no api_url configured", fix: "flowiq auth login" });
|
|
83
|
+
|
|
84
|
+
if (!cfg.token) {
|
|
85
|
+
lines.push(` ${BAD} auth not authenticated`);
|
|
86
|
+
problems.push({ what: "not authenticated", fix: "flowiq auth login" });
|
|
87
|
+
} else {
|
|
88
|
+
// whoami doubles as the live server probe — it is how the response headers
|
|
89
|
+
// carrying the version contract reach us at all.
|
|
90
|
+
try {
|
|
91
|
+
// `whoami` is also the live server probe: it is how the response headers
|
|
92
|
+
// carrying the version contract reach us at all.
|
|
93
|
+
const who = await http.get("whoami");
|
|
94
|
+
out.authenticated = true;
|
|
95
|
+
out.user = who?.user_email || null;
|
|
96
|
+
out.super_admin = who?.is_super_admin === true;
|
|
97
|
+
const role = who?.is_super_admin ? "super admin" : red("NOT a super admin");
|
|
98
|
+
lines.push(` ${OK} auth ${out.user || "authenticated"} ${dim(`· ${role}`)}`);
|
|
99
|
+
if (who && who.is_super_admin !== true) {
|
|
100
|
+
problems.push({ what: "this key is not a super-admin key — most commands will 403", fix: "ask Matt to re-issue the staff key" });
|
|
101
|
+
}
|
|
102
|
+
} catch (e) {
|
|
103
|
+
lines.push(` ${BAD} auth ${e.message.split("\n")[0]}`);
|
|
104
|
+
problems.push({
|
|
105
|
+
what: "the API rejected this key",
|
|
106
|
+
fix: e.status === 401 || e.status === 403 ? "flowiq auth login" : "check api_url / network, then retry",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── 3. the server's version contract ──────────────────────────────────────
|
|
112
|
+
out.server_min_version = serverContract.minVersion;
|
|
113
|
+
out.api_build = serverContract.apiBuild;
|
|
114
|
+
|
|
115
|
+
if (!serverContract.minVersion) {
|
|
116
|
+
lines.push(` ${WARN} server did not declare a minimum CLI version ${dim("(deploy predates the version contract)")}`);
|
|
117
|
+
} else if (cmpVersion(serverContract.minVersion, installed) > 0) {
|
|
118
|
+
lines.push(` ${BAD} server requires ${serverContract.minVersion}, you have ${installed}`);
|
|
119
|
+
problems.push({
|
|
120
|
+
what: `this CLI is older than the server's contract (needs ${serverContract.minVersion})`,
|
|
121
|
+
fix: "npm i -g @flowapt/flowiq-cli@latest",
|
|
122
|
+
});
|
|
123
|
+
} else {
|
|
124
|
+
lines.push(` ${OK} server contract ${serverContract.minVersion} or newer ${dim(`· build ${serverContract.apiBuild || "?"}`)}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The REVERSE skew: a CLI newer than the deployment it is talking to. Not an
|
|
128
|
+
// error — it is the normal state for the minutes between a push and the
|
|
129
|
+
// Vercel deploy — but it explains "the command exists and still doesn't work".
|
|
130
|
+
if (latest && serverContract.minVersion && cmpVersion(installed, latest) === 0 && cmpVersion(installed, serverContract.minVersion) > 0) {
|
|
131
|
+
lines.push(` ${dim("i server min is " + serverContract.minVersion + " while you run " + installed + " — fine; newest features need the matching deploy")}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
out.problems = problems;
|
|
135
|
+
if (opts.json) { console.log(JSON.stringify(out, null, 2)); return problems.length ? 1 : 0; }
|
|
136
|
+
|
|
137
|
+
console.log(lines.join("\n"));
|
|
138
|
+
console.log("");
|
|
139
|
+
if (!problems.length) {
|
|
140
|
+
console.log(green(" Everything checks out."));
|
|
141
|
+
} else {
|
|
142
|
+
console.log(red(` ${problems.length} thing${problems.length === 1 ? "" : "s"} to fix:`));
|
|
143
|
+
for (const p of problems) {
|
|
144
|
+
console.log(` • ${p.what}`);
|
|
145
|
+
console.log(` ${p.fix}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
console.log("");
|
|
149
|
+
return problems.length ? 1 : 0;
|
|
150
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// `flowiq insights …` — the Customer Insights Report from the terminal.
|
|
2
|
+
//
|
|
3
|
+
// flowiq insights status [org_id] [--off|--on] [--min-chats N] [--all] who has it, who does not, last report, chat volume
|
|
4
|
+
// flowiq insights enable <org_id> [--daily] [--weekly [day]] [--monthly [day]] [--report standard|advanced]
|
|
5
|
+
// [--store-only] [--recipient a@b.com …] [--dry-run] [--force]
|
|
6
|
+
// flowiq insights disable <org_id>
|
|
7
|
+
// flowiq insights run <org_id> [--period daily|weekly|monthly | --from D --to D] [--report …]
|
|
8
|
+
// [--recipient …] [--store-only] --commit
|
|
9
|
+
//
|
|
10
|
+
// `run` is DRY-RUN by default (it prints the exact engine, window and
|
|
11
|
+
// recipients) and fires only with --commit — it costs OpenAI tokens and can
|
|
12
|
+
// email a client. The server refuses the three ways an enabled org silently
|
|
13
|
+
// produces nothing (no OpenAI key, emailing with no recipients, an empty
|
|
14
|
+
// window); --force overrides all but an inactive org.
|
|
15
|
+
|
|
16
|
+
import { http } from "../http.js";
|
|
17
|
+
import { schedulesFromOpts, describeSchedule, sastRange } from "../insights-config.js";
|
|
18
|
+
|
|
19
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
20
|
+
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
21
|
+
const padL = (s, n) => String(s ?? "").padStart(n);
|
|
22
|
+
|
|
23
|
+
function requireUuid(orgId) {
|
|
24
|
+
if (!UUID_RE.test(orgId || "")) {
|
|
25
|
+
console.error(`Error: "${orgId}" is not a valid organization UUID (find it with \`flowiq org list <name>\`).`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function fmtWhen(iso) {
|
|
31
|
+
if (!iso) return "never";
|
|
32
|
+
const d = new Date(iso);
|
|
33
|
+
if (Number.isNaN(d.getTime())) return String(iso);
|
|
34
|
+
// SAST, minute precision
|
|
35
|
+
const s = new Date(d.getTime() + 2 * 3600 * 1000).toISOString();
|
|
36
|
+
return `${s.slice(0, 10)} ${s.slice(11, 16)}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function daysAgo(iso) {
|
|
40
|
+
if (!iso) return null;
|
|
41
|
+
return Math.floor((Date.now() - new Date(iso).getTime()) / 86400000);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function printProblems(body) {
|
|
45
|
+
for (const e of body.errors || []) console.error(` ✗ ${e}`);
|
|
46
|
+
for (const w of body.warnings || []) console.error(` ⚠ ${w}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fail(prefix, e) {
|
|
50
|
+
console.error(`${prefix}: ${e.message}`);
|
|
51
|
+
const b = e.body || {};
|
|
52
|
+
if (b.errors?.length || b.warnings?.length) printProblems(b);
|
|
53
|
+
if (b.needs_force) console.error(" Fix the cause, or re-run with --force to write anyway.");
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── status ───────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
export async function status(orgId, opts = {}) {
|
|
60
|
+
if (orgId) return statusOne(orgId, opts);
|
|
61
|
+
let resp;
|
|
62
|
+
try { resp = await http.get("insights", { status: "1", all: opts.all ? "1" : undefined }); } catch (e) { fail("Status failed", e); }
|
|
63
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
64
|
+
|
|
65
|
+
let orgs = resp.orgs || [];
|
|
66
|
+
const total = orgs.length;
|
|
67
|
+
const enabledTotal = orgs.filter((o) => o.enabled).length;
|
|
68
|
+
if (opts.on) orgs = orgs.filter((o) => o.enabled);
|
|
69
|
+
if (opts.off) orgs = orgs.filter((o) => !o.enabled);
|
|
70
|
+
const minChats = Number(opts.minChats ?? 0);
|
|
71
|
+
if (minChats > 0) orgs = orgs.filter((o) => o.contacts_30d >= minChats);
|
|
72
|
+
const sort = opts.sort || "chats";
|
|
73
|
+
orgs.sort((a, b) =>
|
|
74
|
+
sort === "name" ? a.name.localeCompare(b.name)
|
|
75
|
+
: sort === "last" ? (new Date(b.last_report?.at || 0) - new Date(a.last_report?.at || 0))
|
|
76
|
+
: (b.contacts_30d - a.contacts_30d) || a.name.localeCompare(b.name));
|
|
77
|
+
|
|
78
|
+
console.log(`Customer Insights Report — ${enabledTotal} of ${total} org(s) enabled${resp.include_inactive ? " (incl. inactive)" : ""}` +
|
|
79
|
+
`${opts.on ? " · showing ON" : opts.off ? " · showing OFF" : ""}${minChats ? ` · ≥${minChats} chatting contacts/30d` : ""}\n`);
|
|
80
|
+
if (!orgs.length) { console.log("(no orgs match the filter)"); return; }
|
|
81
|
+
|
|
82
|
+
const nameW = Math.min(28, Math.max(4, ...orgs.map((o) => o.name.length)));
|
|
83
|
+
console.log(`${pad("NAME", nameW)} ${pad("STATE", 5)} ${padL("CHATS30D", 8)} ${pad("KEY", 3)} ${pad("LAST REPORT", 18)} ${pad("RCPT", 4)} SCHEDULES`);
|
|
84
|
+
for (const o of orgs) {
|
|
85
|
+
const state = o.enabled ? "ON" : o.configured ? "off" : "—";
|
|
86
|
+
const last = o.last_report ? `${fmtWhen(o.last_report.at)}${o.last_report.store_only ? " s" : ""}` : "never";
|
|
87
|
+
const sched = o.enabled ? o.schedules_described.join(", ") + (o.schedules_source === "implicit-daily" ? " (implicit)" : "") : "";
|
|
88
|
+
console.log(
|
|
89
|
+
`${pad(o.name.slice(0, nameW), nameW)} ${pad(state, 5)} ${padL(o.contacts_30d, 8)} ${pad(o.has_openai_key ? "yes" : "NO", 3)} ${pad(last, 18)} ${padL(o.recipients.length, 4)} ${sched}${o.inactive ? " (inactive)" : ""}`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
console.log(`\n${orgs.length} org(s) shown · CHATS30D = distinct contacts who messaged in the last 30 days · KEY = has its own OpenAI key (required) · "s" = store-only`);
|
|
93
|
+
console.log("Enable one: flowiq insights enable <org_id> --weekly monday --recipient client@example.com");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function statusOne(orgId, opts = {}) {
|
|
97
|
+
requireUuid(orgId);
|
|
98
|
+
let resp;
|
|
99
|
+
try { resp = await http.get("insights", { organization_id: orgId }); } catch (e) { fail("Status failed", e); }
|
|
100
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
101
|
+
|
|
102
|
+
console.log(`${resp.name} (${resp.id})${resp.inactive ? " [INACTIVE]" : ""}`);
|
|
103
|
+
console.log(` insights: ${resp.enabled ? "ENABLED" : resp.configured ? "disabled (config kept)" : "never configured"}`);
|
|
104
|
+
if (resp.enabled || resp.configured) {
|
|
105
|
+
console.log(` schedules: ${resp.schedules_described.join(", ") || "(none)"}${resp.schedules_source === "implicit-daily" ? " ⚠ no schedules stored — the scheduler runs DAILY standard by default" : resp.schedules_source === "legacy" ? " (legacy single-config shape)" : ""}`);
|
|
106
|
+
console.log(` recipients: ${resp.recipients.length ? resp.recipients.join(", ") : `(none — emails would go to ${resp.engine_fallback_email})`}`);
|
|
107
|
+
}
|
|
108
|
+
console.log(` openai key: ${resp.has_openai_key ? "own key present" : "NONE — the engines refuse to run"}${resp.has_spare_openai_key ? " (+ spare)" : ""}`);
|
|
109
|
+
console.log(` studio config: ${resp.studio_configured_at ? `saved ${fmtWhen(resp.studio_configured_at)} (advanced engine)` : "none (advanced runs on engine defaults)"}`);
|
|
110
|
+
console.log(` chats (30d): ${resp.contacts_30d} contacts · ${resp.messages_30d} messages · last inbound ${fmtWhen(resp.last_inbound_at)}`);
|
|
111
|
+
console.log(` reports (30d): ${resp.reports_30d}`);
|
|
112
|
+
if (resp.reports?.length) {
|
|
113
|
+
console.log("\n Recent reports (SAST):");
|
|
114
|
+
for (const r of resp.reports) {
|
|
115
|
+
console.log(` ${fmtWhen(r.at)} ${pad(r.kind || "?", 8)} ${padL(r.contacts ?? "?", 5)} contacts ${padL(r.messages ?? "?", 6)} msgs ` +
|
|
116
|
+
`${r.store_only ? "stored only" : `${r.emails_sent ?? 0} email(s)${r.emails_failed ? `, ${r.emails_failed} failed` : ""}`}` +
|
|
117
|
+
`${r.recipients?.length && !r.store_only ? ` → ${r.recipients.join(", ")}` : ""}${r.source && r.source !== "edge" ? ` [${r.source}]` : ""}`);
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
console.log("\n No reports yet.");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── enable / disable ─────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
export async function enable(orgId, opts = {}) {
|
|
127
|
+
requireUuid(orgId);
|
|
128
|
+
let schedules;
|
|
129
|
+
try { schedules = schedulesFromOpts({ daily: opts.daily, weekly: opts.weekly, monthly: opts.monthly, report: opts.report, storeOnly: opts.storeOnly }); }
|
|
130
|
+
catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
|
|
131
|
+
if (!schedules && (opts.report || opts.storeOnly)) {
|
|
132
|
+
console.error("Error: --report / --store-only describe a schedule — add --daily, --weekly or --monthly to say which.");
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
const recipients = {};
|
|
136
|
+
if (opts.recipient?.length) recipients.replace = opts.recipient;
|
|
137
|
+
if (opts.addRecipient?.length) recipients.add = opts.addRecipient;
|
|
138
|
+
if (opts.removeRecipient?.length) recipients.remove = opts.removeRecipient;
|
|
139
|
+
|
|
140
|
+
const body = { organization_id: orgId, action: "enable", dry_run: opts.dryRun === true, force: opts.force === true };
|
|
141
|
+
if (schedules) body.schedules = schedules;
|
|
142
|
+
if (Object.keys(recipients).length) body.recipients = recipients;
|
|
143
|
+
|
|
144
|
+
let resp;
|
|
145
|
+
try { resp = await http.post("insights", body); } catch (e) { fail("Enable failed", e); }
|
|
146
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
147
|
+
|
|
148
|
+
const o = resp.organization;
|
|
149
|
+
const after = resp.after;
|
|
150
|
+
console.log(`${resp.dry_run ? "DRY RUN — " : ""}${o.name}: insights ${resp.before?.enabled ? "already on, config updated" : "ENABLED"}${resp.forced ? " (FORCED past pre-flight)" : ""}`);
|
|
151
|
+
console.log(` schedules: ${(after.schedules || []).map(describeSchedule).join(", ")} [${resp.schedules_source}]`);
|
|
152
|
+
console.log(` recipients: ${after.recipient_emails?.length ? after.recipient_emails.join(", ") : "(none)"}`);
|
|
153
|
+
printProblems(resp);
|
|
154
|
+
if (resp.dry_run) {
|
|
155
|
+
console.log(resp.would_write ? "\n Would write. Re-run without --dry-run." : "\n Would NOT write — fix the ✗ items (or --force).");
|
|
156
|
+
} else {
|
|
157
|
+
console.log("\n Next run: 09:00 SAST on the next matching day (cron 72). Fire one now: flowiq insights run " + orgId + " --commit");
|
|
158
|
+
console.log(" Undo: flowiq insights disable " + orgId);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function disable(orgId, opts = {}) {
|
|
163
|
+
requireUuid(orgId);
|
|
164
|
+
let resp;
|
|
165
|
+
try { resp = await http.post("insights", { organization_id: orgId, action: "disable" }); } catch (e) { fail("Disable failed", e); }
|
|
166
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
167
|
+
console.log(`${resp.organization.name}: insights ${resp.changed ? "DISABLED" : "already disabled"} (schedules + recipients kept; re-enable with \`flowiq insights enable\`)`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── run (Run Now) ────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
export async function run(orgId, opts = {}) {
|
|
173
|
+
requireUuid(orgId);
|
|
174
|
+
const body = { organization_id: orgId, action: "run", commit: opts.commit === true, force: opts.force === true, store_only: opts.storeOnly === true };
|
|
175
|
+
if (opts.from || opts.to) {
|
|
176
|
+
if (!opts.from || !opts.to) { console.error("Error: --from and --to go together (YYYY-MM-DD, SAST days)."); process.exit(1); }
|
|
177
|
+
try { const r = sastRange(opts.from, opts.to); body.start_date = r.start; body.end_date = r.end; }
|
|
178
|
+
catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
|
|
179
|
+
} else if (opts.period) {
|
|
180
|
+
if (!["daily", "weekly", "monthly"].includes(opts.period)) { console.error("Error: --period must be daily, weekly or monthly."); process.exit(1); }
|
|
181
|
+
body.period = opts.period;
|
|
182
|
+
}
|
|
183
|
+
if (opts.report) body.report_type = opts.report;
|
|
184
|
+
if (opts.recipient?.length) body.recipients = opts.recipient;
|
|
185
|
+
|
|
186
|
+
let resp;
|
|
187
|
+
try { resp = await http.post("insights", body); } catch (e) { fail("Run failed", e); }
|
|
188
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
189
|
+
|
|
190
|
+
const o = resp.organization;
|
|
191
|
+
console.log(`${resp.dry_run ? "DRY RUN — " : "STARTED — "}${o.name}: ${resp.period} ${resp.report_type} report via ${resp.engine}`);
|
|
192
|
+
console.log(` window: ${fmtWhen(resp.window.start)} → ${fmtWhen(resp.window.end)} SAST (${resp.messages_in_window ?? "?"} customer messages)`);
|
|
193
|
+
console.log(` delivery: ${resp.store_only ? "store only — no emails" : `email → ${resp.recipients.join(", ")}`}`);
|
|
194
|
+
printProblems(resp);
|
|
195
|
+
if (resp.dry_run) {
|
|
196
|
+
console.log(resp.would_run ? "\n Would run. Add --commit to fire it." : "\n Would NOT run — fix the ✗ items (or --force).");
|
|
197
|
+
} else {
|
|
198
|
+
console.log(`\n job ${resp.job_id || "?"} — python-render is working in the background (a few minutes for a large org).`);
|
|
199
|
+
console.log(` Check: flowiq insights status ${orgId} (the report appears under "Recent reports")`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -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,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
|
|
@@ -291,7 +294,7 @@ export function run(argv) {
|
|
|
291
294
|
.action((orgId, name, opts) => templatesCmd.show(orgId, name, opts));
|
|
292
295
|
|
|
293
296
|
// org (read-only org summary for the prompt-builder skill, creds stripped)
|
|
294
|
-
const org = program.command("org").description("Org info (read)
|
|
297
|
+
const org = program.command("org").description("Org info (read), create a new organization, read/set its feature flags");
|
|
295
298
|
org.command("list [search]")
|
|
296
299
|
.description("List organizations with their UUIDs — the lookup every other command needs (filter by name/slug)")
|
|
297
300
|
.option("--all", "include inactive organizations")
|
|
@@ -309,6 +312,84 @@ export function run(argv) {
|
|
|
309
312
|
.option("--json", "print the raw JSON payload")
|
|
310
313
|
.action((orgId, opts) => orgCmd.info(orgId, opts));
|
|
311
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
|
+
|
|
312
393
|
// agent (list / create / config — config defaults to the active agent, or --agent <id>)
|
|
313
394
|
const agent = program.command("agent").description("List, create, and configure an org's agents");
|
|
314
395
|
agent.command("list <organization_id>")
|
|
@@ -783,6 +864,12 @@ export function run(argv) {
|
|
|
783
864
|
.option("--out <file>", "write the JSON to a file instead of stdout")
|
|
784
865
|
.action((orgId, path, opts) => storeApiCmd.wooGet(orgId, path, opts));
|
|
785
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
|
+
|
|
786
873
|
// guide (bundled docs — always match the installed version)
|
|
787
874
|
program.command("guide")
|
|
788
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
|
+
});
|
package/src/update-check.js
CHANGED
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
// can never corrupt piped / --json output) when the installed version is older
|
|
5
5
|
// than the latest published on npm.
|
|
6
6
|
//
|
|
7
|
+
// ⚠️ It used to be gated on `process.stderr.isTTY` as well, which meant it was
|
|
8
|
+
// invisible to exactly the audience that most needs it: an AI assistant running
|
|
9
|
+
// `flowiq` through a tool call gets a PIPE, not a TTY, so isTTY is undefined and
|
|
10
|
+
// the hint never printed. Teammates' Claude sessions could therefore run a
|
|
11
|
+
// months-old CLI forever without ever being told. Writing to stderr is already
|
|
12
|
+
// the whole safety property (it cannot corrupt piped stdout or --json), so the
|
|
13
|
+
// TTY gate bought nothing and cost everything. Removed 9 Sep 2026.
|
|
14
|
+
//
|
|
7
15
|
// The nudge is driven by a CACHED value read SYNCHRONOUSLY — zero network on the
|
|
8
16
|
// hot path, so no command is ever slowed. The cache is refreshed at most once a
|
|
9
17
|
// day by a DETACHED, unref'd child process, so even `flowiq --version` returns
|
|
@@ -88,16 +96,42 @@ async function refreshLatest(prevLatest) {
|
|
|
88
96
|
|
|
89
97
|
// Print the nudge (from cache, synchronously) and, if the cache is stale, spawn
|
|
90
98
|
// a detached child to refresh it for the NEXT run. Never throws, never blocks.
|
|
99
|
+
/** The cached npm `latest` + when it was refreshed — used by `flowiq doctor`. */
|
|
100
|
+
export function cachedLatest() {
|
|
101
|
+
const c = readCacheSync();
|
|
102
|
+
return { latest: c?.latest || null, checkedAt: c?.checked_at || null };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Ask npm for the latest version NOW (doctor only — never on the hot path). */
|
|
106
|
+
export async function fetchLatestNow(timeoutMs = FETCH_TIMEOUT_MS) {
|
|
107
|
+
const ctrl = new AbortController();
|
|
108
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
109
|
+
try {
|
|
110
|
+
const res = await fetch(REGISTRY, { signal: ctrl.signal, headers: { Accept: "application/json" } });
|
|
111
|
+
if (!res.ok) return null;
|
|
112
|
+
const body = await res.json();
|
|
113
|
+
if (body?.version) await writeCache({ checked_at: Date.now(), latest: body.version });
|
|
114
|
+
return body?.version || null;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
} finally {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
91
122
|
export function maybeNotifyUpdate(currentVersion) {
|
|
92
123
|
try {
|
|
93
124
|
if (disabled()) return;
|
|
94
125
|
const cache = readCacheSync();
|
|
95
126
|
const latest = cache?.latest;
|
|
96
127
|
|
|
97
|
-
if (latest &&
|
|
128
|
+
if (latest && cmpVersion(latest, currentVersion) > 0) {
|
|
129
|
+
// The marker line is deliberately stable and unambiguous: an assistant
|
|
130
|
+
// reading this in a tool result should update without needing to be asked.
|
|
98
131
|
process.stderr.write(
|
|
99
|
-
`\n ⬆
|
|
100
|
-
`
|
|
132
|
+
`\n ⬆ FLOWIQ CLI OUT OF DATE — installed ${currentVersion}, latest ${latest}\n` +
|
|
133
|
+
` Run this before continuing: npm i -g ${PKG}@latest\n` +
|
|
134
|
+
` (commands still work; newer ones may be missing or behave differently)\n\n`
|
|
101
135
|
);
|
|
102
136
|
}
|
|
103
137
|
|