@flowapt/flowiq-cli 0.1.2 → 0.1.5
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 +122 -16
- package/package.json +1 -1
- package/src/commands/fine-tuning.js +132 -0
- package/src/commands/messaging-webhooks.js +123 -0
- package/src/commands/webhooks.js +186 -0
- package/src/index.js +51 -1
package/README.md
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
# flowiq
|
|
2
2
|
|
|
3
|
-
FlowIQ staff CLI. Round-trips agent prompts, questionnaires,
|
|
4
|
-
|
|
5
|
-
service role never leaves
|
|
3
|
+
FlowIQ staff CLI. Round-trips agent prompts, questionnaires, fine-tuning,
|
|
4
|
+
contact chats, platform webhooks, and outbound messaging webhooks through
|
|
5
|
+
`https://api.flowiq.live/cli/*` so the Supabase service role never leaves
|
|
6
|
+
the server.
|
|
6
7
|
|
|
7
8
|
## Install
|
|
8
9
|
|
|
9
10
|
```bash
|
|
10
11
|
npm i -g @flowapt/flowiq-cli
|
|
12
|
+
flowiq --version # should print 0.1.x
|
|
11
13
|
```
|
|
12
14
|
|
|
13
15
|
## Authenticate
|
|
14
16
|
|
|
15
|
-
Get a personal staff API key from
|
|
17
|
+
Get a personal staff API key from a FlowIQ super-admin (an `fiq_staff_…`
|
|
16
18
|
string). Then:
|
|
17
19
|
|
|
18
20
|
```bash
|
|
@@ -36,33 +38,137 @@ flowiq auth logout
|
|
|
36
38
|
|
|
37
39
|
## Commands
|
|
38
40
|
|
|
39
|
-
|
|
41
|
+
All `pull` commands write to `./.flowiq/<topic>/<slug>.json` relative to
|
|
42
|
+
your current working directory. We recommend picking a dedicated folder
|
|
43
|
+
for your CLI work:
|
|
40
44
|
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
```bash
|
|
46
|
+
mkdir -p ~/flowapt-cli && cd ~/flowapt-cli
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Prompts — `flowiq prompts pull|push <slug>`
|
|
50
|
+
|
|
51
|
+
Round-trips an org's active WhatsApp agent's system prompt
|
|
52
|
+
(`agents.settings.prompt_sections`).
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
flowiq prompts pull <organization_id>
|
|
56
|
+
# edit ./.flowiq/prompts/<slug>.json — modify section titles/content
|
|
57
|
+
flowiq prompts push <slug>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Notes:
|
|
61
|
+
- `system_prompt` is regenerated server-side from `prompt_sections` on
|
|
62
|
+
push using the `## SECTION: <title>` format.
|
|
63
|
+
- Sections support optional `hidden: boolean` + `channels: string[]`
|
|
64
|
+
(`web` / `whatsapp` / `messenger` / `instagram`) — validated on push.
|
|
65
|
+
- For legacy agents without a sections array, pull auto-bootstraps a
|
|
66
|
+
single "Main" section from `settings.system_prompt` and returns
|
|
67
|
+
`bootstrapped: true`. The CLI prints a heads-up before push.
|
|
68
|
+
|
|
69
|
+
### Questionnaires — `flowiq questionnaires pull|push <slug>` (alias `q`)
|
|
70
|
+
|
|
71
|
+
Round-trips `agents.questionnaire` JSONB.
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
flowiq q pull <organization_id>
|
|
75
|
+
# edit ./.flowiq/questionnaires/<slug>.json — fill in answers
|
|
76
|
+
flowiq q push <slug>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
If the DB column is null, pull seeds the local file from a default
|
|
80
|
+
template (9 sections / 23 questions). Optional per-question `note` field
|
|
81
|
+
is preserved round-trip.
|
|
82
|
+
|
|
83
|
+
### Fine-tuning — `flowiq fine-tuning pull|push <slug>` (alias `ft`)
|
|
84
|
+
|
|
85
|
+
Round-trips `agents.fine_tuning` (text instructions) + `agents.fine_tuning_entries`
|
|
86
|
+
(structured Q&A entries).
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
flowiq ft pull <organization_id>
|
|
90
|
+
# edit ./.flowiq/fine-tuning/<slug>.json
|
|
91
|
+
flowiq ft push <slug>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Each entry has: `id`, `customer_question`, `correct_answer`, `agent_answer`,
|
|
95
|
+
`notes`, `date` (YYYY-MM-DD), `category` (Product Info / Pricing / Warranty /
|
|
96
|
+
Shipping / Returns / Promotions / Technical Support / Tone Adjustment / Other),
|
|
97
|
+
`suggested_tone` (Friendly / Professional / Encouraging / Brief / Detailed),
|
|
98
|
+
`status` (Pending / Resolved), `messages: []`, `media: []`.
|
|
99
|
+
|
|
100
|
+
### Messages — `flowiq messages pull <contact_id>` (alias `m`)
|
|
101
|
+
|
|
102
|
+
Read-only pull of a contact's `helpdesk_messages` history. Anchors on the
|
|
103
|
+
Nth-most-recent `user-*` message, then grabs every row from that anchor
|
|
104
|
+
onward (all sender types — bot-*, tool-call, member, human-*, broadcast,
|
|
105
|
+
call-*, system, etc).
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
flowiq m pull <contact_id> --count 25
|
|
109
|
+
# writes ./.flowiq/messages/<contact-slug>-<org-slug>.json
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`--count` defaults to 25, max 200.
|
|
113
|
+
|
|
114
|
+
### Webhooks (Shopify/WooCommerce) — `flowiq webhooks pull|push|reconnect` (alias `wh`)
|
|
115
|
+
|
|
116
|
+
Round-trips the **inbound** Shopify or WooCommerce platform webhooks (the
|
|
117
|
+
"Active Webhooks" panel on the Configuration page).
|
|
43
118
|
|
|
44
119
|
```bash
|
|
45
|
-
flowiq
|
|
46
|
-
#
|
|
120
|
+
flowiq wh pull <organization_id>
|
|
121
|
+
# auto-detects platform from the org row
|
|
122
|
+
flowiq wh push <slug> # create any topic in the JSON not currently live
|
|
123
|
+
flowiq wh push <slug> --prune # also delete any live webhook not in the JSON
|
|
124
|
+
flowiq wh reconnect <slug> <webhook_id> # WooCommerce-only: flip back to status=active
|
|
47
125
|
```
|
|
48
126
|
|
|
49
|
-
|
|
127
|
+
- For orgs with `feature_flags.ip_whitelist === true` (e.g. customers on
|
|
128
|
+
Wordfence), all calls are routed via python-render's static-IP proxy.
|
|
129
|
+
- `--prune` is **direct-mode only** (python-render proxy has no delete
|
|
130
|
+
route) — on ip_whitelist orgs prune prints a clear "delete via store
|
|
131
|
+
admin UI" message instead.
|
|
132
|
+
- Reconnect is WC-only. Shopify webhooks are always active when present.
|
|
133
|
+
|
|
134
|
+
### Messaging webhooks — `flowiq messaging-webhooks pull|push` (alias `mw`)
|
|
50
135
|
|
|
51
|
-
|
|
52
|
-
|
|
136
|
+
Round-trips the **outbound** messaging webhooks in the `meta_webhooks`
|
|
137
|
+
table (the right-hand panel on the Configuration page — these tell
|
|
138
|
+
FlowIQ where to POST when a customer messaging event fires).
|
|
53
139
|
|
|
54
140
|
```bash
|
|
55
|
-
flowiq
|
|
56
|
-
|
|
141
|
+
flowiq mw pull <organization_id>
|
|
142
|
+
flowiq mw push <slug> --dry-run # diff preview, no writes
|
|
143
|
+
flowiq mw push <slug> # FULL-REPLACE: deletes every row, then inserts the JSON
|
|
57
144
|
```
|
|
58
145
|
|
|
59
|
-
|
|
146
|
+
Push is full-replace — if someone added a row via the UI between your
|
|
147
|
+
pull and your push, it gets wiped. Pull-edit-push within one session.
|
|
148
|
+
|
|
149
|
+
Validation: `platform` ∈ `{whatsapp, web}`, `type` from the per-platform
|
|
150
|
+
allowed enum, valid http(s) URL, no duplicate (platform, type, url)
|
|
151
|
+
tuples.
|
|
152
|
+
|
|
153
|
+
## Environment variable overrides
|
|
60
154
|
|
|
61
155
|
| Var | Default | Use |
|
|
62
156
|
|---|---|---|
|
|
63
|
-
| `FLOWIQ_API_URL` | `https://api.flowiq.live` | Override the API host (
|
|
157
|
+
| `FLOWIQ_API_URL` | `https://api.flowiq.live` | Override the API host (local dev, staging). |
|
|
64
158
|
| `FLOWIQ_TOKEN` | (saved in `~/.config/flowiq/auth.json`) | Override the auth token, useful for CI. |
|
|
65
159
|
|
|
160
|
+
## Upgrading
|
|
161
|
+
|
|
162
|
+
When a new version ships, just re-run:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
npm i -g @flowapt/flowiq-cli
|
|
166
|
+
flowiq --version
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Your saved token in `~/.config/flowiq/auth.json` is preserved across
|
|
170
|
+
upgrades.
|
|
171
|
+
|
|
66
172
|
## License
|
|
67
173
|
|
|
68
174
|
UNLICENSED. Internal staff tool — install requires a valid `fiq_staff_…`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowapt/flowiq-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, webhooks, and more without ever touching service-role credentials.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// `flowiq fine-tuning pull <organization_id>` /
|
|
2
|
+
// `flowiq fine-tuning push <slug>`.
|
|
3
|
+
// Round-trips the dedicated `agents.fine_tuning` + `agents.fine_tuning_entries`
|
|
4
|
+
// columns through a local JSON file. No DB credentials on disk.
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { http } from "../http.js";
|
|
9
|
+
|
|
10
|
+
const FT_DIR = path.resolve(process.cwd(), ".flowiq", "fine-tuning");
|
|
11
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
12
|
+
|
|
13
|
+
function slugify(name, fallback) {
|
|
14
|
+
const s = String(name || "")
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
17
|
+
.replace(/-+/g, "-")
|
|
18
|
+
.replace(/^-|-$/g, "");
|
|
19
|
+
return s || fallback;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function fileExists(p) {
|
|
23
|
+
try { await fs.access(p); return true; } catch { return false; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function resolvePushPath(identifier) {
|
|
27
|
+
const candidates = [];
|
|
28
|
+
if (path.isAbsolute(identifier)) {
|
|
29
|
+
candidates.push(identifier);
|
|
30
|
+
} else if (identifier.includes("/") || identifier.includes("\\")) {
|
|
31
|
+
candidates.push(path.resolve(identifier));
|
|
32
|
+
} else {
|
|
33
|
+
const base = identifier.endsWith(".json") ? identifier : `${identifier}.json`;
|
|
34
|
+
candidates.push(path.join(FT_DIR, base));
|
|
35
|
+
candidates.push(path.resolve(base));
|
|
36
|
+
}
|
|
37
|
+
for (const c of candidates) {
|
|
38
|
+
if (await fileExists(c)) return c;
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`File not found. Tried:\n ${candidates.join("\n ")}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function countEntries(entries) {
|
|
44
|
+
if (!Array.isArray(entries)) return { total: 0, pending: 0, resolved: 0, byCategory: {} };
|
|
45
|
+
const byCategory = {};
|
|
46
|
+
let pending = 0, resolved = 0;
|
|
47
|
+
for (const e of entries) {
|
|
48
|
+
if (e?.status === "Pending") pending += 1;
|
|
49
|
+
else if (e?.status === "Resolved") resolved += 1;
|
|
50
|
+
const cat = e?.category ?? "(none)";
|
|
51
|
+
byCategory[cat] = (byCategory[cat] ?? 0) + 1;
|
|
52
|
+
}
|
|
53
|
+
return { total: entries.length, pending, resolved, byCategory };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function pull(orgId) {
|
|
57
|
+
if (!UUID_RE.test(orgId)) {
|
|
58
|
+
console.error(`Error: "${orgId}" is not a valid organization UUID.`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
let resp;
|
|
62
|
+
try {
|
|
63
|
+
resp = await http.get("fine-tuning", { organization_id: orgId });
|
|
64
|
+
} catch (e) {
|
|
65
|
+
console.error(`Pull failed: ${e.message}`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
await fs.mkdir(FT_DIR, { recursive: true });
|
|
70
|
+
const slug = slugify(resp.organization_name, resp.organization_id);
|
|
71
|
+
const filePath = path.join(FT_DIR, `${slug}.json`);
|
|
72
|
+
const overwriting = await fileExists(filePath);
|
|
73
|
+
await fs.writeFile(filePath, JSON.stringify(resp, null, 2) + "\n", "utf8");
|
|
74
|
+
|
|
75
|
+
const stats = countEntries(resp.fine_tuning_entries);
|
|
76
|
+
console.log(`${overwriting ? "Overwrote" : "Wrote"} ${filePath}`);
|
|
77
|
+
console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
|
|
78
|
+
console.log(` agent: ${resp.agent_name} (${resp.agent_id})`);
|
|
79
|
+
console.log(` instructions: ${resp.fine_tuning.length} chars`);
|
|
80
|
+
console.log(` entries: ${stats.total} (${stats.pending} pending, ${stats.resolved} resolved)`);
|
|
81
|
+
if (stats.total > 0) {
|
|
82
|
+
const cats = Object.entries(stats.byCategory)
|
|
83
|
+
.sort((a, b) => b[1] - a[1])
|
|
84
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
85
|
+
.join(", ");
|
|
86
|
+
console.log(` by category: ${cats}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function push(identifier) {
|
|
91
|
+
let filePath;
|
|
92
|
+
try {
|
|
93
|
+
filePath = await resolvePushPath(identifier);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.error(`Error: ${e.message}`);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
99
|
+
let data;
|
|
100
|
+
try { data = JSON.parse(raw); }
|
|
101
|
+
catch (e) {
|
|
102
|
+
console.error(`Invalid JSON in ${filePath}: ${e.message}`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!data.organization_id || !UUID_RE.test(data.organization_id)) {
|
|
107
|
+
console.error(`${filePath}: organization_id missing or invalid`);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
if (typeof data.fine_tuning !== "string") {
|
|
111
|
+
console.error(`${filePath}: fine_tuning must be a string (use "" if none)`);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let resp;
|
|
116
|
+
try {
|
|
117
|
+
resp = await http.post("fine-tuning", {
|
|
118
|
+
organization_id: data.organization_id,
|
|
119
|
+
fine_tuning: data.fine_tuning,
|
|
120
|
+
fine_tuning_entries: data.fine_tuning_entries ?? [],
|
|
121
|
+
});
|
|
122
|
+
} catch (e) {
|
|
123
|
+
console.error(`Push failed: ${e.message}`);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log(`Pushed ${filePath}`);
|
|
128
|
+
console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
|
|
129
|
+
console.log(` agent: ${resp.agent_name} (${resp.agent_id})`);
|
|
130
|
+
console.log(` instructions: ${resp.instructions_chars} chars`);
|
|
131
|
+
console.log(` entries: ${resp.entries.total} (${resp.entries.pending} pending, ${resp.entries.resolved} resolved)`);
|
|
132
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// `flowiq messaging-webhooks pull <organization_id>` /
|
|
2
|
+
// `flowiq messaging-webhooks push <slug> [--dry-run]`.
|
|
3
|
+
// Full-replace round-trip of `meta_webhooks` rows.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { http } from "../http.js";
|
|
8
|
+
|
|
9
|
+
const MW_DIR = path.resolve(process.cwd(), ".flowiq", "messaging-webhooks");
|
|
10
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
11
|
+
|
|
12
|
+
function slugify(name, fallback) {
|
|
13
|
+
const s = String(name || "")
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
16
|
+
.replace(/-+/g, "-")
|
|
17
|
+
.replace(/^-|-$/g, "");
|
|
18
|
+
return s || fallback;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function fileExists(p) {
|
|
22
|
+
try { await fs.access(p); return true; } catch { return false; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function resolvePushPath(identifier) {
|
|
26
|
+
const candidates = [];
|
|
27
|
+
if (path.isAbsolute(identifier)) {
|
|
28
|
+
candidates.push(identifier);
|
|
29
|
+
} else if (identifier.includes("/") || identifier.includes("\\")) {
|
|
30
|
+
candidates.push(path.resolve(identifier));
|
|
31
|
+
} else {
|
|
32
|
+
const base = identifier.endsWith(".json") ? identifier : `${identifier}.json`;
|
|
33
|
+
candidates.push(path.join(MW_DIR, base));
|
|
34
|
+
candidates.push(path.resolve(base));
|
|
35
|
+
}
|
|
36
|
+
for (const c of candidates) {
|
|
37
|
+
if (await fileExists(c)) return c;
|
|
38
|
+
}
|
|
39
|
+
throw new Error(`File not found. Tried:\n ${candidates.join("\n ")}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function pull(orgId) {
|
|
43
|
+
if (!UUID_RE.test(orgId)) {
|
|
44
|
+
console.error(`Error: "${orgId}" is not a valid organization UUID.`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
let resp;
|
|
48
|
+
try {
|
|
49
|
+
resp = await http.get("messaging-webhooks", { organization_id: orgId });
|
|
50
|
+
} catch (e) {
|
|
51
|
+
console.error(`Pull failed: ${e.message}`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await fs.mkdir(MW_DIR, { recursive: true });
|
|
56
|
+
const slug = slugify(resp.organization_name, resp.organization_id);
|
|
57
|
+
const filePath = path.join(MW_DIR, `${slug}.json`);
|
|
58
|
+
const overwriting = await fileExists(filePath);
|
|
59
|
+
await fs.writeFile(filePath, JSON.stringify(resp, null, 2) + "\n", "utf8");
|
|
60
|
+
|
|
61
|
+
const rows = Array.isArray(resp.messaging_webhooks) ? resp.messaging_webhooks : [];
|
|
62
|
+
console.log(`${overwriting ? "Overwrote" : "Wrote"} ${filePath}`);
|
|
63
|
+
console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
|
|
64
|
+
console.log(` rows: ${rows.length}`);
|
|
65
|
+
if (rows.length && resp.by_platform) {
|
|
66
|
+
const platStr = Object.entries(resp.by_platform).map(([k, v]) => `${k}=${v}`).join(", ");
|
|
67
|
+
console.log(` by plat: ${platStr}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function push(identifier, opts = {}) {
|
|
72
|
+
let filePath;
|
|
73
|
+
try {
|
|
74
|
+
filePath = await resolvePushPath(identifier);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
console.error(`Error: ${e.message}`);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
80
|
+
let data;
|
|
81
|
+
try { data = JSON.parse(raw); }
|
|
82
|
+
catch (e) {
|
|
83
|
+
console.error(`Invalid JSON in ${filePath}: ${e.message}`);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!data.organization_id || !UUID_RE.test(data.organization_id)) {
|
|
88
|
+
console.error(`${filePath}: organization_id missing or invalid`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
if (!Array.isArray(data.messaging_webhooks)) {
|
|
92
|
+
console.error(`${filePath}: messaging_webhooks must be an array`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let resp;
|
|
97
|
+
try {
|
|
98
|
+
resp = await http.post("messaging-webhooks", {
|
|
99
|
+
organization_id: data.organization_id,
|
|
100
|
+
messaging_webhooks: data.messaging_webhooks.map((w) => ({
|
|
101
|
+
url: w.url, type: w.type, platform: w.platform,
|
|
102
|
+
})),
|
|
103
|
+
dry_run: !!opts.dryRun,
|
|
104
|
+
});
|
|
105
|
+
} catch (e) {
|
|
106
|
+
console.error(`Push failed: ${e.message}`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const isDryRun = !!opts.dryRun;
|
|
111
|
+
console.log(`${isDryRun ? "DRY RUN" : "Pushed"} ${filePath}`);
|
|
112
|
+
console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
|
|
113
|
+
console.log(` desired: ${resp.diff.desired}`);
|
|
114
|
+
console.log(` existing: ${resp.diff.existing}`);
|
|
115
|
+
console.log(` to insert: ${resp.diff.to_insert}`);
|
|
116
|
+
console.log(` to delete: ${resp.diff.to_delete}`);
|
|
117
|
+
console.log(` unchanged: ${resp.diff.unchanged}`);
|
|
118
|
+
if (isDryRun) {
|
|
119
|
+
console.log("\nDry-run only — no changes written. Re-run without --dry-run to apply.");
|
|
120
|
+
} else {
|
|
121
|
+
console.log(`\n✓ Replaced ${resp.replaced} → ${resp.now} row(s)`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// `flowiq webhooks pull <organization_id>` /
|
|
2
|
+
// `flowiq webhooks push <slug> [--prune]` /
|
|
3
|
+
// `flowiq webhooks reconnect <slug> <webhook_id>`.
|
|
4
|
+
// Round-trips Shopify/WooCommerce platform webhooks via /cli/webhooks.
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { http } from "../http.js";
|
|
9
|
+
|
|
10
|
+
const WH_DIR = path.resolve(process.cwd(), ".flowiq", "webhooks");
|
|
11
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
12
|
+
|
|
13
|
+
function slugify(name, fallback) {
|
|
14
|
+
const s = String(name || "")
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
17
|
+
.replace(/-+/g, "-")
|
|
18
|
+
.replace(/^-|-$/g, "");
|
|
19
|
+
return s || fallback;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function fileExists(p) {
|
|
23
|
+
try { await fs.access(p); return true; } catch { return false; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function resolvePushPath(identifier) {
|
|
27
|
+
const candidates = [];
|
|
28
|
+
if (path.isAbsolute(identifier)) {
|
|
29
|
+
candidates.push(identifier);
|
|
30
|
+
} else if (identifier.includes("/") || identifier.includes("\\")) {
|
|
31
|
+
candidates.push(path.resolve(identifier));
|
|
32
|
+
} else {
|
|
33
|
+
const base = identifier.endsWith(".json") ? identifier : `${identifier}.json`;
|
|
34
|
+
candidates.push(path.join(WH_DIR, base));
|
|
35
|
+
candidates.push(path.resolve(base));
|
|
36
|
+
}
|
|
37
|
+
for (const c of candidates) {
|
|
38
|
+
if (await fileExists(c)) return c;
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`File not found. Tried:\n ${candidates.join("\n ")}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function pull(orgId) {
|
|
44
|
+
if (!UUID_RE.test(orgId)) {
|
|
45
|
+
console.error(`Error: "${orgId}" is not a valid organization UUID.`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
let resp;
|
|
49
|
+
try {
|
|
50
|
+
resp = await http.get("webhooks", { organization_id: orgId });
|
|
51
|
+
} catch (e) {
|
|
52
|
+
console.error(`Pull failed: ${e.message}`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await fs.mkdir(WH_DIR, { recursive: true });
|
|
57
|
+
const slug = slugify(resp.organization_name, resp.organization_id);
|
|
58
|
+
const filePath = path.join(WH_DIR, `${slug}.json`);
|
|
59
|
+
const overwriting = await fileExists(filePath);
|
|
60
|
+
await fs.writeFile(filePath, JSON.stringify(resp, null, 2) + "\n", "utf8");
|
|
61
|
+
|
|
62
|
+
console.log(`${overwriting ? "Overwrote" : "Wrote"} ${filePath}`);
|
|
63
|
+
console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
|
|
64
|
+
console.log(` platform: ${resp.platform}${resp.ip_whitelist ? " (via python-render)" : ""}`);
|
|
65
|
+
console.log(` webhooks: ${resp.webhooks.length} live`);
|
|
66
|
+
if (resp.webhooks.length && resp.by_status) {
|
|
67
|
+
const s = Object.entries(resp.by_status).map(([k, v]) => `${k}=${v}`).join(", ");
|
|
68
|
+
console.log(` by status: ${s}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function push(identifier, opts = {}) {
|
|
73
|
+
let filePath;
|
|
74
|
+
try {
|
|
75
|
+
filePath = await resolvePushPath(identifier);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
console.error(`Error: ${e.message}`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
81
|
+
let data;
|
|
82
|
+
try { data = JSON.parse(raw); }
|
|
83
|
+
catch (e) {
|
|
84
|
+
console.error(`Invalid JSON in ${filePath}: ${e.message}`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!data.organization_id || !UUID_RE.test(data.organization_id)) {
|
|
89
|
+
console.error(`${filePath}: organization_id missing or invalid`);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
if (!["shopify", "woocommerce"].includes(data.platform)) {
|
|
93
|
+
console.error(`${filePath}: platform must be 'shopify' or 'woocommerce'`);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
if (!Array.isArray(data.webhooks)) {
|
|
97
|
+
console.error(`${filePath}: webhooks must be an array`);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let resp;
|
|
102
|
+
try {
|
|
103
|
+
resp = await http.post("webhooks", {
|
|
104
|
+
action: "push",
|
|
105
|
+
organization_id: data.organization_id,
|
|
106
|
+
platform: data.platform,
|
|
107
|
+
webhooks: data.webhooks.map((w) => ({ topic: w.topic })),
|
|
108
|
+
prune: !!opts.prune,
|
|
109
|
+
});
|
|
110
|
+
} catch (e) {
|
|
111
|
+
console.error(`Push failed: ${e.message}`);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
console.log(`Pushed ${filePath}`);
|
|
116
|
+
console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
|
|
117
|
+
console.log(` platform: ${resp.platform}${resp.ip_whitelist ? " (via python-render)" : ""}`);
|
|
118
|
+
const cr = resp.create_results || {};
|
|
119
|
+
const dr = resp.prune_results || {};
|
|
120
|
+
console.log(` to create: ${resp.diff.to_create.length}${resp.diff.to_create.length ? " → " + resp.diff.to_create.join(", ") : ""}`);
|
|
121
|
+
console.log(` to delete: ${resp.diff.to_delete.length}${resp.diff.to_delete.length && !opts.prune ? " (pass --prune to delete)" : ""}`);
|
|
122
|
+
if (cr.created !== undefined && resp.diff.to_create.length) {
|
|
123
|
+
console.log(` ✓ created ${cr.created}/${resp.diff.to_create.length}`);
|
|
124
|
+
}
|
|
125
|
+
if (cr.failures?.length) {
|
|
126
|
+
console.log(` ⚠ ${cr.failures.length} create failure(s):`);
|
|
127
|
+
for (const f of cr.failures) console.log(` - ${f.topic}: ${f.error}`);
|
|
128
|
+
}
|
|
129
|
+
if (opts.prune && resp.diff.to_delete.length) {
|
|
130
|
+
if (dr.skipped_reason) {
|
|
131
|
+
console.log(` ⚠ prune skipped: ${dr.skipped_reason}`);
|
|
132
|
+
} else {
|
|
133
|
+
console.log(` ✓ deleted ${dr.deleted}/${resp.diff.to_delete.length}`);
|
|
134
|
+
if (dr.failures?.length) {
|
|
135
|
+
console.log(` ⚠ ${dr.failures.length} delete failure(s):`);
|
|
136
|
+
for (const f of dr.failures) console.log(` - ${f.topic}: ${f.error}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
console.log(`Done. created=${cr.created || 0}, deleted=${dr.deleted || 0}, untouched=${resp.diff.untouched}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function reconnect(identifier, webhookIdRaw) {
|
|
144
|
+
let filePath;
|
|
145
|
+
try {
|
|
146
|
+
filePath = await resolvePushPath(identifier);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
console.error(`Error: ${e.message}`);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
152
|
+
let data;
|
|
153
|
+
try { data = JSON.parse(raw); }
|
|
154
|
+
catch (e) {
|
|
155
|
+
console.error(`Invalid JSON in ${filePath}: ${e.message}`);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
if (data.platform !== "woocommerce") {
|
|
159
|
+
console.error("Reconnect is WooCommerce-only. Shopify webhooks are always active when present.");
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
const webhookId = Number(webhookIdRaw);
|
|
163
|
+
if (!Number.isFinite(webhookId) || webhookId <= 0) {
|
|
164
|
+
console.error(`Invalid webhook id "${webhookIdRaw}" (must be a positive number).`);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let resp;
|
|
169
|
+
try {
|
|
170
|
+
resp = await http.post("webhooks", {
|
|
171
|
+
action: "reconnect",
|
|
172
|
+
organization_id: data.organization_id,
|
|
173
|
+
platform: "woocommerce",
|
|
174
|
+
webhook_id: webhookId,
|
|
175
|
+
});
|
|
176
|
+
} catch (e) {
|
|
177
|
+
console.error(`Reconnect failed: ${e.message}`);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
console.log(
|
|
182
|
+
`Reconnecting WooCommerce webhook id=${webhookId} for ` +
|
|
183
|
+
`${resp.organization_name}${resp.ip_whitelist ? " (via python-render)" : ""}`
|
|
184
|
+
);
|
|
185
|
+
console.log(` ✓ ${resp.topic || "(unknown topic)"} → status=${resp.status}`);
|
|
186
|
+
}
|
package/src/index.js
CHANGED
|
@@ -2,17 +2,29 @@
|
|
|
2
2
|
// commander dispatches to src/commands/* based on argv.
|
|
3
3
|
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import path from "node:path";
|
|
5
8
|
import * as authCmd from "./commands/auth.js";
|
|
6
9
|
import * as promptsCmd from "./commands/prompts.js";
|
|
7
10
|
import * as questionnairesCmd from "./commands/questionnaires.js";
|
|
8
11
|
import * as messagesCmd from "./commands/messages.js";
|
|
12
|
+
import * as fineTuningCmd from "./commands/fine-tuning.js";
|
|
13
|
+
import * as messagingWebhooksCmd from "./commands/messaging-webhooks.js";
|
|
14
|
+
import * as webhooksCmd from "./commands/webhooks.js";
|
|
15
|
+
|
|
16
|
+
// Read version from package.json so it stays in sync with the published npm
|
|
17
|
+
// version automatically (single source of truth — bumping package.json on each
|
|
18
|
+
// `npm version patch` / `npm publish` is enough).
|
|
19
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const pkg = JSON.parse(readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
|
|
9
21
|
|
|
10
22
|
export function run(argv) {
|
|
11
23
|
const program = new Command();
|
|
12
24
|
program
|
|
13
25
|
.name("flowiq")
|
|
14
26
|
.description("FlowIQ staff CLI — round-trip agent prompts, webhooks, and more, without service-role credentials.")
|
|
15
|
-
.version(
|
|
27
|
+
.version(pkg.version);
|
|
16
28
|
|
|
17
29
|
// auth
|
|
18
30
|
const auth = program.command("auth").description("Manage CLI authentication");
|
|
@@ -53,5 +65,43 @@ export function run(argv) {
|
|
|
53
65
|
.option("--count <n>", "number of most-recent user-* messages to anchor on", "25")
|
|
54
66
|
.action((contactId, opts) => messagesCmd.pull(contactId, opts));
|
|
55
67
|
|
|
68
|
+
// fine-tuning (agents.fine_tuning text + agents.fine_tuning_entries jsonb)
|
|
69
|
+
const ft = program.command("fine-tuning")
|
|
70
|
+
.alias("ft")
|
|
71
|
+
.description("Round-trip the active agent's fine-tuning instructions + Q&A entries");
|
|
72
|
+
ft.command("pull <organization_id>")
|
|
73
|
+
.description("Fetch agents.fine_tuning + agents.fine_tuning_entries into a local JSON file")
|
|
74
|
+
.action((orgId) => fineTuningCmd.pull(orgId));
|
|
75
|
+
ft.command("push <slug-or-path>")
|
|
76
|
+
.description("Apply a local JSON file's fine_tuning + fine_tuning_entries to the agent")
|
|
77
|
+
.action((id) => fineTuningCmd.push(id));
|
|
78
|
+
|
|
79
|
+
// messaging-webhooks (outbound notifications via meta_webhooks table)
|
|
80
|
+
const mw = program.command("messaging-webhooks")
|
|
81
|
+
.alias("mw")
|
|
82
|
+
.description("Round-trip outbound messaging webhooks (meta_webhooks table)");
|
|
83
|
+
mw.command("pull <organization_id>")
|
|
84
|
+
.description("Pull all meta_webhooks rows for the org into a local JSON file")
|
|
85
|
+
.action((orgId) => messagingWebhooksCmd.pull(orgId));
|
|
86
|
+
mw.command("push <slug-or-path>")
|
|
87
|
+
.description("FULL-REPLACE: deletes every existing row for the org then inserts the JSON. Pass --dry-run for diff preview.")
|
|
88
|
+
.option("--dry-run", "preview the diff without writing anything")
|
|
89
|
+
.action((id, opts) => messagingWebhooksCmd.push(id, opts));
|
|
90
|
+
|
|
91
|
+
// webhooks (Shopify/WooCommerce platform webhooks; auto-detects platform + ip_whitelist proxy)
|
|
92
|
+
const wh = program.command("webhooks")
|
|
93
|
+
.alias("wh")
|
|
94
|
+
.description("Round-trip Shopify or WooCommerce platform webhooks (auto-detects platform; routes via python-render when feature_flags.ip_whitelist is on)");
|
|
95
|
+
wh.command("pull <organization_id>")
|
|
96
|
+
.description("Fetch the FlowIQ-pointing webhooks live from Shopify/WC into a local JSON file")
|
|
97
|
+
.action((orgId) => webhooksCmd.pull(orgId));
|
|
98
|
+
wh.command("push <slug-or-path>")
|
|
99
|
+
.description("Create any topic in the JSON not currently live on the store. With --prune, also DELETES any live webhook not in the JSON.")
|
|
100
|
+
.option("--prune", "also delete live webhooks that aren't in the JSON (direct-mode only; python-render proxy has no delete route)")
|
|
101
|
+
.action((id, opts) => webhooksCmd.push(id, opts));
|
|
102
|
+
wh.command("reconnect <slug-or-path> <webhook_id>")
|
|
103
|
+
.description("WooCommerce-only: flip a webhook back to status=active (WC auto-disables after delivery failures)")
|
|
104
|
+
.action((id, webhookId) => webhooksCmd.reconnect(id, webhookId));
|
|
105
|
+
|
|
56
106
|
program.parseAsync(argv);
|
|
57
107
|
}
|