@flowapt/flowiq-cli 0.3.7 → 0.3.8
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 +92 -0
- package/TEAM-GUIDE.md +4 -1
- package/package.json +1 -1
- package/src/commands/audit.js +170 -0
- package/src/commands/prompts.js +173 -0
- package/src/http.js +37 -2
- package/src/index.js +34 -0
package/README.md
CHANGED
|
@@ -88,6 +88,32 @@ Notes:
|
|
|
88
88
|
single "Main" section from `settings.system_prompt` and returns
|
|
89
89
|
`bootstrapped: true`. The CLI prints a heads-up before push.
|
|
90
90
|
|
|
91
|
+
#### Prompt version history — `flowiq prompts history|restore` (v0.3.8)
|
|
92
|
+
|
|
93
|
+
A push overwrites `prompt_sections` in place, so the previous prompt used to
|
|
94
|
+
be gone for good. Since v0.3.8 every push **snapshots the prompt it replaced**
|
|
95
|
+
into the audit log, making every past version retrievable.
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
flowiq prompts history <organization_id> # every recorded push: when / who / size
|
|
99
|
+
flowiq prompts restore <organization_id> <audit_id> # DRY-RUN (writes a working copy)
|
|
100
|
+
flowiq prompts restore <organization_id> <audit_id> --commit # make it live
|
|
101
|
+
flowiq prompts restore <organization_id> <audit_id> --after --commit
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
- `history` lists each push as `<prior size> → <new size>` with the audit id
|
|
105
|
+
to feed `restore`. `--agent <id>` scopes to a non-active agent.
|
|
106
|
+
- `restore` **defaults to the version the push REPLACED** (i.e. "undo this
|
|
107
|
+
push"). `--after` restores the version that push made live — use it to jump
|
|
108
|
+
back to a specific older prompt rather than undoing one step.
|
|
109
|
+
- **Dry-run by default.** It always writes
|
|
110
|
+
`.flowiq/prompts/<org>-restore-<audit8>.json` so you can read or edit the
|
|
111
|
+
recovered version first; `--commit` pushes it.
|
|
112
|
+
- A restore goes through the normal push path, so it gets the same validation
|
|
113
|
+
**and its own audit row** — a rollback is never an untracked edit.
|
|
114
|
+
- History starts at the first push after v0.3.8 shipped. Versions overwritten
|
|
115
|
+
before that were never snapshotted and cannot be recovered.
|
|
116
|
+
|
|
91
117
|
### Questionnaires — `flowiq questionnaires pull|push <slug>` (alias `q`)
|
|
92
118
|
|
|
93
119
|
Round-trips `agents.questionnaire` JSONB.
|
|
@@ -390,6 +416,19 @@ flowiq kw push <slug> --prune # ALSO deletes DB keywords missing from
|
|
|
390
416
|
collapses to 1 at runtime — rejected).
|
|
391
417
|
- `field:"attributes"` actions are warned (full jsonb replace; constant
|
|
392
418
|
values only) but applied — this CLI is their only safe editing surface.
|
|
419
|
+
- **Action types accepted** (all seven the runtime implements):
|
|
420
|
+
`send_message` · `update_contact_field` · `add_contact_tag` ·
|
|
421
|
+
`remove_contact_tag` · `set_agent` · `delay` · `combined`.
|
|
422
|
+
Per-type rules: tag actions need a non-empty `tags[]` (or a single `tag`
|
|
423
|
+
string) or the runtime writes no tag at all; `set_agent.agent_id` must be
|
|
424
|
+
an agent UUID, or `null`/`""` to CLEAR the contact's binding (warned, since
|
|
425
|
+
clearing is easy to do by accident); `delay.ms` is a number ≥ 0 and is
|
|
426
|
+
**clamped to 5000ms** at runtime (Meta's webhook ack window) — a larger
|
|
427
|
+
value is accepted with a warning that the extra wait never happens;
|
|
428
|
+
`combined.actions[]` sub-types are checked and warned, not blocked.
|
|
429
|
+
*(Before v0.3.9 only the first two + `combined` were accepted, so a
|
|
430
|
+
zero-edit pull→push failed for any org using tag / set_agent / delay
|
|
431
|
+
actions.)*
|
|
393
432
|
- `action_config.link_preview: false` disables WhatsApp's link-preview card
|
|
394
433
|
on that action's text send (absent/`true` = preview on, the default).
|
|
395
434
|
Passed through verbatim; also toggleable per action in the dashboard.
|
|
@@ -410,6 +449,59 @@ flowiq m pull <contact_id> --count 25
|
|
|
410
449
|
|
|
411
450
|
`--count` defaults to 25, max 200.
|
|
412
451
|
|
|
452
|
+
### Audit log — `flowiq audit [org] | audit show <id>` (v0.3.8)
|
|
453
|
+
|
|
454
|
+
**Who changed what, when — with the full before/after content.** Every
|
|
455
|
+
mutating CLI action writes a `staff_cli_audit` row server-side: the staff user
|
|
456
|
+
+ key, the endpoint + action, the org / agent / target, the CLI version and
|
|
457
|
+
IP, and the complete prior + new state of whatever it changed. It's what makes
|
|
458
|
+
`prompts history` / `restore` possible, and it's the answer to "who pushed
|
|
459
|
+
that?" and "what did this look like last week?".
|
|
460
|
+
|
|
461
|
+
```bash
|
|
462
|
+
flowiq audit <organization_id> # recent activity on an org
|
|
463
|
+
flowiq audit # across all orgs
|
|
464
|
+
flowiq audit <org> --endpoint prompts --action push # one surface
|
|
465
|
+
flowiq audit <org> --user matt --since 2026-07-01 # one person, one window
|
|
466
|
+
flowiq audit <org> --status blocked # attempts that were REFUSED
|
|
467
|
+
flowiq audit show <audit_id> # one entry in full
|
|
468
|
+
flowiq audit show <audit_id> --content # + the before/after payloads
|
|
469
|
+
flowiq audit show <audit_id> --out entry.json # dump the whole entry
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
Filters: `--endpoint --action --status --agent --target --user --since --until
|
|
473
|
+
--limit` (default 50, max 200) `--json`.
|
|
474
|
+
|
|
475
|
+
**What's captured, and how much:**
|
|
476
|
+
|
|
477
|
+
| Surface | Action | Content stored |
|
|
478
|
+
|---|---|---|
|
|
479
|
+
| `prompts` / `questionnaires` / `fine-tuning` / `knowledge` / `custom-tools` / `keywords` / `flowmod` | push | **full before + after** |
|
|
480
|
+
| `custom-tools` | enable / disable, **blocked** destructive push | full before + after |
|
|
481
|
+
| `agent-config` | config | before + after snapshot + the changed keys |
|
|
482
|
+
| `agents` / `org` / `meta-templates` | create | the created record / submitted request |
|
|
483
|
+
| `messaging-webhooks` / `webhooks` | push, reconnect | full before + after |
|
|
484
|
+
| `pinboard` | push | full prior row + the new one |
|
|
485
|
+
| `agent-updates` | resolve | prior status + the client-facing note |
|
|
486
|
+
| `broadcast` | **send (committed)**, retry | the exact payload sent + the resulting broadcastId |
|
|
487
|
+
| `segments` | apply / untag | the batch tags + their contact ids |
|
|
488
|
+
| `tag` | field/cohort/segment/attributes/messages/remove | the tag + the exact selector + counts (not per-contact) |
|
|
489
|
+
| `contacts-upsert` | upsert | the upserted rows |
|
|
490
|
+
| `agent-stress` | cleanup | the deleted test contacts |
|
|
491
|
+
| `messages` / `export-chats` | read / export | **metadata only** — who read whose chat, never a second copy of it |
|
|
492
|
+
|
|
493
|
+
Notes:
|
|
494
|
+
- **Dry-runs are not audited** (they change nothing) — but a **blocked**
|
|
495
|
+
destructive push IS, with `status=blocked`.
|
|
496
|
+
- Listings never include content, so they stay small over 95k-char prompts;
|
|
497
|
+
`audit show` fetches it.
|
|
498
|
+
- Payloads over 2MB are stored truncated with a marker rather than dropped —
|
|
499
|
+
an oversized push is never failed by the audit layer.
|
|
500
|
+
- An audit write can never break your action: it's best-effort and swallows
|
|
501
|
+
its own errors.
|
|
502
|
+
- **Read-only, and there is no `audit delete`.** An audit log you can edit
|
|
503
|
+
isn't an audit log.
|
|
504
|
+
|
|
413
505
|
### Webhooks (Shopify/WooCommerce) — `flowiq webhooks pull|push|reconnect` (alias `wh`)
|
|
414
506
|
|
|
415
507
|
Round-trips the **inbound** Shopify or WooCommerce platform webhooks (the
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -77,7 +77,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
77
77
|
| Edit the text knowledge sources (get_more_answers playbooks) | `flowiq kn pull <org_id>` → edit `sources[]` → `flowiq kn push <slug>` |
|
|
78
78
|
| Edit the custom tools (API-call tools) | `flowiq ct pull <org_id>` → edit `tools[]` → `flowiq ct push <slug> --dry-run` → `flowiq ct push <slug>` |
|
|
79
79
|
| Turn one custom tool on/off | `flowiq ct enable\|disable <org_id> <tool_name>` |
|
|
80
|
-
| Edit keyword auto-replies (incl. competition entry keywords) | `flowiq kw pull <org_id>` → edit → `flowiq kw push <slug>` |
|
|
80
|
+
| Edit keyword auto-replies (incl. competition entry keywords, add/remove-tag, set-agent and delay actions) | `flowiq kw pull <org_id>` → edit → `flowiq kw push <slug> --dry-run` → `flowiq kw push <slug>` |
|
|
81
81
|
| See an org's agents / create one | `flowiq agent list <org_id>` / `flowiq agent create <org_id> --name "…"` |
|
|
82
82
|
| Change agent model / tool flags | `flowiq agent config <org_id> --model … --tool view_cart_tool=true` |
|
|
83
83
|
| Talk to the live agent safely (no real WhatsApp ever sent) | `flowiq test send <org_id> "hi, do you sell X?"` |
|
|
@@ -96,6 +96,9 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
96
96
|
| Check how a broadcast is landing (read/delivered/sent/failed) | `flowiq bc status <org_id> <broadcastId>` (the `broadcastId` a `--python` send prints) |
|
|
97
97
|
| See WHO failed on a broadcast + why (Meta error) | `flowiq bc status <org_id> <broadcastId> --failures` |
|
|
98
98
|
| Re-send a broadcast to only the ones that failed | `flowiq bc retry <org_id> <broadcastId>` (dry-run) → `… --commit` |
|
|
99
|
+
| **Get an OLD version of a prompt back** | `flowiq prompts history <org_id>` (pick the version) → `flowiq prompts restore <org_id> <audit_id>` (dry-run) → `… --commit` |
|
|
100
|
+
| **See who changed what, and when** | `flowiq audit <org_id>` — add `--endpoint prompts`, `--user <name>`, `--since 2026-07-01` to narrow |
|
|
101
|
+
| See exactly what a change looked like (before → after) | `flowiq audit show <audit_id> --content` (or `--out entry.json`) |
|
|
99
102
|
| Read a contact's chat | `flowiq m pull <contact_id>` then open the JSON |
|
|
100
103
|
| Export an org's full chat history | `flowiq export chats <org_id>` |
|
|
101
104
|
| Check / create WhatsApp templates | `flowiq tpl pull <org_id>` / `flowiq tpl create <org_id> --request-file req.json` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowapt/flowiq-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
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,170 @@
|
|
|
1
|
+
// `flowiq audit` — read the staff-CLI audit trail.
|
|
2
|
+
//
|
|
3
|
+
// Every mutating CLI action writes a `staff_cli_audit` row server-side
|
|
4
|
+
// (api/cli/_audit.js) carrying who / what / when / where PLUS the full
|
|
5
|
+
// before+after content. This command is the read side:
|
|
6
|
+
//
|
|
7
|
+
// flowiq audit <org> — recent activity on an org
|
|
8
|
+
// flowiq audit --endpoint prompts — filter by surface
|
|
9
|
+
// flowiq audit --user matt --since 2026-07-01
|
|
10
|
+
// flowiq audit show <audit_id> — one entry, full detail
|
|
11
|
+
// flowiq audit show <audit_id> --content — + the before/after payloads
|
|
12
|
+
// flowiq audit show <audit_id> --out f.json — dump the entry to a file
|
|
13
|
+
//
|
|
14
|
+
// Read-only by design: there is no `audit delete`. An audit log you can edit
|
|
15
|
+
// isn't an audit log.
|
|
16
|
+
|
|
17
|
+
import fs from "node:fs/promises";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { http } from "../http.js";
|
|
20
|
+
|
|
21
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
22
|
+
|
|
23
|
+
/** 2026-07-27 14:32 SAST — the log is read by humans in SAST. */
|
|
24
|
+
export function fmtSast(iso) {
|
|
25
|
+
if (!iso) return "—";
|
|
26
|
+
try {
|
|
27
|
+
const d = new Date(iso);
|
|
28
|
+
const s = new Intl.DateTimeFormat("en-ZA", {
|
|
29
|
+
timeZone: "Africa/Johannesburg",
|
|
30
|
+
year: "numeric", month: "2-digit", day: "2-digit",
|
|
31
|
+
hour: "2-digit", minute: "2-digit", hour12: false,
|
|
32
|
+
}).format(d);
|
|
33
|
+
// en-ZA gives "2026/07/27, 14:32"
|
|
34
|
+
return s.replace(/\//g, "-").replace(",", "");
|
|
35
|
+
} catch {
|
|
36
|
+
return iso;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function pad(s, n) {
|
|
41
|
+
const str = String(s ?? "");
|
|
42
|
+
return str.length >= n ? str.slice(0, n) : str + " ".repeat(n - str.length);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const STATUS_MARK = { ok: " ", blocked: "⛔", failed: "✗" };
|
|
46
|
+
|
|
47
|
+
export async function list(orgId, opts = {}) {
|
|
48
|
+
if (orgId && !UUID_RE.test(orgId)) {
|
|
49
|
+
console.error(`Error: "${orgId}" is not a valid organization UUID.`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
let resp;
|
|
53
|
+
try {
|
|
54
|
+
resp = await http.get("audit", {
|
|
55
|
+
organization_id: orgId,
|
|
56
|
+
agent_id: opts.agent,
|
|
57
|
+
endpoint: opts.endpoint,
|
|
58
|
+
action: opts.action,
|
|
59
|
+
status: opts.status,
|
|
60
|
+
target_id: opts.target,
|
|
61
|
+
user: opts.user,
|
|
62
|
+
since: opts.since,
|
|
63
|
+
until: opts.until,
|
|
64
|
+
limit: opts.limit,
|
|
65
|
+
});
|
|
66
|
+
} catch (e) {
|
|
67
|
+
console.error(`Audit query failed: ${e.message}`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (opts.json) {
|
|
72
|
+
console.log(JSON.stringify(resp, null, 2));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const entries = resp.entries || [];
|
|
77
|
+
if (!entries.length) {
|
|
78
|
+
console.log("No audit entries match those filters.");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log("");
|
|
83
|
+
console.log(`${entries.length} entr${entries.length === 1 ? "y" : "ies"} (newest first)`);
|
|
84
|
+
console.log("");
|
|
85
|
+
console.log(` ${pad("WHEN (SAST)", 17)} ${pad("WHO", 24)} ${pad("WHAT", 26)} SUMMARY`);
|
|
86
|
+
console.log(` ${"-".repeat(17)} ${"-".repeat(24)} ${"-".repeat(26)} ${"-".repeat(40)}`);
|
|
87
|
+
for (const e of entries) {
|
|
88
|
+
const who = (e.user_email || "—").replace(/@.*$/, "");
|
|
89
|
+
const what = `${e.endpoint} ${e.action}`;
|
|
90
|
+
const mark = STATUS_MARK[e.status] ?? " ";
|
|
91
|
+
console.log(`${mark} ${pad(fmtSast(e.created_at), 17)} ${pad(who, 24)} ${pad(what, 26)} ${e.summary ?? ""}`);
|
|
92
|
+
if (!orgId && e.organization_name) {
|
|
93
|
+
console.log(` ${" ".repeat(17)} ${" ".repeat(24)} ${" ".repeat(26)} └ org: ${e.organization_name}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
console.log("");
|
|
97
|
+
console.log(` ids: ${entries.map((e) => e.id.slice(0, 8)).join(" ")}`);
|
|
98
|
+
console.log(` detail: flowiq audit show <audit_id> (full id, or the 8-char prefix won't work — copy from --json)`);
|
|
99
|
+
console.log("");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function show(auditId, opts = {}) {
|
|
103
|
+
if (!UUID_RE.test(auditId)) {
|
|
104
|
+
console.error(`Error: "${auditId}" is not a valid audit entry id (uuid).`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
let resp;
|
|
108
|
+
try {
|
|
109
|
+
resp = await http.get("audit", { id: auditId });
|
|
110
|
+
} catch (e) {
|
|
111
|
+
console.error(`Audit lookup failed: ${e.message}`);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
const e = resp.entry;
|
|
115
|
+
|
|
116
|
+
if (opts.out) {
|
|
117
|
+
const outPath = path.resolve(opts.out);
|
|
118
|
+
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
119
|
+
await fs.writeFile(outPath, JSON.stringify(e, null, 2) + "\n", "utf8");
|
|
120
|
+
console.log(`Wrote ${outPath}`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (opts.json) {
|
|
124
|
+
console.log(JSON.stringify(e, null, 2));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
console.log("");
|
|
129
|
+
console.log(` ${e.endpoint} ${e.action}${e.status !== "ok" ? ` [${e.status.toUpperCase()}]` : ""}`);
|
|
130
|
+
console.log(` ${e.summary ?? ""}`);
|
|
131
|
+
console.log("");
|
|
132
|
+
console.log(` when: ${fmtSast(e.created_at)} SAST`);
|
|
133
|
+
console.log(` who: ${e.user_email ?? "—"}`);
|
|
134
|
+
if (e.organization_name || e.organization_id) {
|
|
135
|
+
console.log(` org: ${e.organization_name ?? "—"} (${e.organization_id ?? "—"})`);
|
|
136
|
+
}
|
|
137
|
+
if (e.agent_id) console.log(` agent: ${e.agent_name ?? "—"} (${e.agent_id})`);
|
|
138
|
+
if (e.target_id) console.log(` target: ${e.target_id}`);
|
|
139
|
+
if (e.cli_version) console.log(` cli: v${e.cli_version}`);
|
|
140
|
+
if (e.ip) console.log(` ip: ${e.ip}`);
|
|
141
|
+
if (e.error) console.log(` error: ${e.error}`);
|
|
142
|
+
console.log(` id: ${e.id}`);
|
|
143
|
+
|
|
144
|
+
if (e.metadata && Object.keys(e.metadata).length) {
|
|
145
|
+
console.log("");
|
|
146
|
+
console.log(" metadata:");
|
|
147
|
+
for (const line of JSON.stringify(e.metadata, null, 2).split("\n")) console.log(` ${line}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const beforeChars = e.before_content ? JSON.stringify(e.before_content).length : 0;
|
|
151
|
+
const afterChars = e.after_content ? JSON.stringify(e.after_content).length : 0;
|
|
152
|
+
console.log("");
|
|
153
|
+
console.log(` content: before ${beforeChars.toLocaleString()} chars · after ${afterChars.toLocaleString()} chars`);
|
|
154
|
+
|
|
155
|
+
if (opts.content) {
|
|
156
|
+
if (e.before_content) {
|
|
157
|
+
console.log("");
|
|
158
|
+
console.log(" ── BEFORE ──────────────────────────────────────────────");
|
|
159
|
+
console.log(JSON.stringify(e.before_content, null, 2));
|
|
160
|
+
}
|
|
161
|
+
if (e.after_content) {
|
|
162
|
+
console.log("");
|
|
163
|
+
console.log(" ── AFTER ───────────────────────────────────────────────");
|
|
164
|
+
console.log(JSON.stringify(e.after_content, null, 2));
|
|
165
|
+
}
|
|
166
|
+
} else if (beforeChars || afterChars) {
|
|
167
|
+
console.log(` (use --content to print them, or --out <file>.json to dump the whole entry)`);
|
|
168
|
+
}
|
|
169
|
+
console.log("");
|
|
170
|
+
}
|
package/src/commands/prompts.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import fs from "node:fs/promises";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { http } from "../http.js";
|
|
9
|
+
import { fmtSast } from "./audit.js";
|
|
9
10
|
|
|
10
11
|
const PROMPTS_DIR = path.resolve(process.cwd(), ".flowiq", "prompts");
|
|
11
12
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
@@ -125,3 +126,175 @@ export async function push(identifier) {
|
|
|
125
126
|
console.log(` content: ${resp.total_chars.toLocaleString()} chars`);
|
|
126
127
|
console.log(` prompt now: ${resp.system_prompt_chars.toLocaleString()} chars`);
|
|
127
128
|
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// history / restore — powered by the staff-CLI audit log (staff_cli_audit).
|
|
132
|
+
// Every `prompts push` snapshots the prompt it replaced, so every past version
|
|
133
|
+
// is retrievable even though the agents row itself only holds the latest.
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
/** `flowiq prompts history <org>` — every recorded push, newest first. */
|
|
137
|
+
export async function history(orgId, opts = {}) {
|
|
138
|
+
if (!UUID_RE.test(orgId)) {
|
|
139
|
+
console.error(`Error: "${orgId}" is not a valid organization UUID.`);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
let resp;
|
|
143
|
+
try {
|
|
144
|
+
resp = await http.get("audit", {
|
|
145
|
+
organization_id: orgId,
|
|
146
|
+
agent_id: opts.agent,
|
|
147
|
+
endpoint: "prompts",
|
|
148
|
+
action: "push",
|
|
149
|
+
limit: opts.limit,
|
|
150
|
+
});
|
|
151
|
+
} catch (e) {
|
|
152
|
+
console.error(`History lookup failed: ${e.message}`);
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (opts.json) {
|
|
157
|
+
console.log(JSON.stringify(resp, null, 2));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const entries = resp.entries || [];
|
|
162
|
+
if (!entries.length) {
|
|
163
|
+
console.log("");
|
|
164
|
+
console.log("No prompt-push history recorded for this org yet.");
|
|
165
|
+
console.log("(History starts from the first push after the audit log shipped —");
|
|
166
|
+
console.log(" earlier versions were never snapshotted and can't be recovered.)");
|
|
167
|
+
console.log("");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
console.log("");
|
|
172
|
+
console.log(`Prompt history — ${entries[0].organization_name ?? orgId} (${entries.length} push${entries.length === 1 ? "" : "es"}, newest first)`);
|
|
173
|
+
console.log("");
|
|
174
|
+
for (const e of entries) {
|
|
175
|
+
const m = e.metadata || {};
|
|
176
|
+
const from = `${m.prior_sections ?? "?"} sec / ${(m.prior_total_chars ?? 0).toLocaleString()} chars`;
|
|
177
|
+
const to = `${m.sections ?? "?"} sec / ${(m.total_chars ?? 0).toLocaleString()} chars`;
|
|
178
|
+
console.log(` ${fmtSast(e.created_at)} SAST ${e.user_email ?? "—"}`);
|
|
179
|
+
console.log(` ${from} → ${to}${e.agent_name ? ` [agent: ${e.agent_name}]` : ""}`);
|
|
180
|
+
console.log(` ${e.id}`);
|
|
181
|
+
console.log("");
|
|
182
|
+
}
|
|
183
|
+
console.log(" Restore the prompt as it was BEFORE one of these pushes:");
|
|
184
|
+
console.log(` flowiq prompts restore ${orgId} <id>`);
|
|
185
|
+
console.log(" …or re-install the version a push made live:");
|
|
186
|
+
console.log(` flowiq prompts restore ${orgId} <id> --after`);
|
|
187
|
+
console.log("");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* `flowiq prompts restore <org> <audit_id>` — re-push a past version.
|
|
192
|
+
*
|
|
193
|
+
* Default restores `before_content` ("undo this push"); `--after` restores the
|
|
194
|
+
* version that push made live. The restore itself goes through the ordinary
|
|
195
|
+
* /cli/prompts POST, so it gets the same validation AND its own audit row —
|
|
196
|
+
* a rollback is never an untracked edit.
|
|
197
|
+
*/
|
|
198
|
+
export async function restore(orgId, auditId, opts = {}) {
|
|
199
|
+
if (!UUID_RE.test(orgId)) {
|
|
200
|
+
console.error(`Error: "${orgId}" is not a valid organization UUID.`);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
if (!UUID_RE.test(auditId)) {
|
|
204
|
+
console.error(`Error: "${auditId}" is not a valid audit entry id (get one from \`flowiq prompts history\`).`);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let entry;
|
|
209
|
+
try {
|
|
210
|
+
entry = (await http.get("audit", { id: auditId })).entry;
|
|
211
|
+
} catch (e) {
|
|
212
|
+
console.error(`Audit lookup failed: ${e.message}`);
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (entry.endpoint !== "prompts") {
|
|
217
|
+
console.error(`Error: audit entry ${auditId} is a "${entry.endpoint} ${entry.action}", not a prompt push.`);
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
if (entry.organization_id !== orgId) {
|
|
221
|
+
console.error(`Error: that audit entry belongs to a different org (${entry.organization_name ?? entry.organization_id}).`);
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const side = opts.after ? "after_content" : "before_content";
|
|
226
|
+
const label = opts.after ? "the version this push MADE LIVE" : "the version this push REPLACED";
|
|
227
|
+
const snapshot = entry[side];
|
|
228
|
+
const sections = snapshot?.prompt_sections;
|
|
229
|
+
if (!Array.isArray(sections) || sections.length === 0) {
|
|
230
|
+
console.error(`Error: that entry has no ${opts.after ? "after" : "before"} prompt to restore.`);
|
|
231
|
+
if (!opts.after) console.error("(If this was the very first recorded push, there was no prior version — try --after on an older entry.)");
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
if (snapshot._truncated) {
|
|
235
|
+
console.error("Error: that snapshot was truncated when stored (oversized payload) and can't be restored safely.");
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const totalChars = sections.reduce((a, s) => a + (s.content?.length || 0), 0);
|
|
240
|
+
console.log("");
|
|
241
|
+
console.log(`Restoring ${label}:`);
|
|
242
|
+
console.log(` from push: ${fmtSast(entry.created_at)} SAST by ${entry.user_email ?? "—"}`);
|
|
243
|
+
console.log(` org: ${entry.organization_name ?? orgId}`);
|
|
244
|
+
console.log(` agent: ${entry.agent_name ?? "—"} (${entry.agent_id})`);
|
|
245
|
+
console.log(` sections: ${sections.length}`);
|
|
246
|
+
console.log(` content: ${totalChars.toLocaleString()} chars`);
|
|
247
|
+
console.log("");
|
|
248
|
+
|
|
249
|
+
// Always write the working copy — even on a dry run, so the restored version
|
|
250
|
+
// can be inspected/edited before it goes live.
|
|
251
|
+
await fs.mkdir(PROMPTS_DIR, { recursive: true });
|
|
252
|
+
const orgSlug = slugify(entry.organization_name, orgId);
|
|
253
|
+
const filePath = path.join(PROMPTS_DIR, `${orgSlug}-restore-${auditId.slice(0, 8)}.json`);
|
|
254
|
+
await fs.writeFile(
|
|
255
|
+
filePath,
|
|
256
|
+
JSON.stringify(
|
|
257
|
+
{
|
|
258
|
+
organization_id: orgId,
|
|
259
|
+
organization_name: entry.organization_name,
|
|
260
|
+
agent_id: entry.agent_id,
|
|
261
|
+
agent_name: entry.agent_name,
|
|
262
|
+
restored_from_audit_id: auditId,
|
|
263
|
+
restored_side: opts.after ? "after" : "before",
|
|
264
|
+
prompt_sections: sections,
|
|
265
|
+
},
|
|
266
|
+
null,
|
|
267
|
+
2
|
|
268
|
+
) + "\n",
|
|
269
|
+
"utf8"
|
|
270
|
+
);
|
|
271
|
+
console.log(` working copy: ${filePath}`);
|
|
272
|
+
|
|
273
|
+
if (!opts.commit) {
|
|
274
|
+
console.log("");
|
|
275
|
+
console.log(" DRY RUN — nothing was pushed. Re-run with --commit to make this live:");
|
|
276
|
+
console.log(` flowiq prompts restore ${orgId} ${auditId}${opts.after ? " --after" : ""} --commit`);
|
|
277
|
+
console.log(` …or edit the file above and \`flowiq prompts push ${path.basename(filePath, ".json")}\`.`);
|
|
278
|
+
console.log("");
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let resp;
|
|
283
|
+
try {
|
|
284
|
+
resp = await http.post("prompts", {
|
|
285
|
+
organization_id: orgId,
|
|
286
|
+
prompt_sections: sections,
|
|
287
|
+
agent_id: entry.agent_id,
|
|
288
|
+
});
|
|
289
|
+
} catch (e) {
|
|
290
|
+
console.error(`Restore push failed: ${e.message}`);
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
console.log("");
|
|
295
|
+
console.log(` ✓ Restored — ${resp.agent_name} (${resp.agent_id})`);
|
|
296
|
+
console.log(` sections: ${resp.sections}`);
|
|
297
|
+
console.log(` prompt now: ${resp.system_prompt_chars.toLocaleString()} chars`);
|
|
298
|
+
console.log(` (this restore is itself recorded in the audit log)`);
|
|
299
|
+
console.log("");
|
|
300
|
+
}
|
package/src/http.js
CHANGED
|
@@ -3,8 +3,20 @@
|
|
|
3
3
|
// - carry Authorization: Bearer <token>
|
|
4
4
|
// - parse JSON, surfacing { error, message } shape with a readable error.
|
|
5
5
|
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import path from "node:path";
|
|
6
9
|
import { loadConfig } from "./config.js";
|
|
7
10
|
|
|
11
|
+
// Sent on every request as X-Flowiq-Cli-Version so the server-side audit log
|
|
12
|
+
// records WHICH CLI version made a change (a stale install is a real source of
|
|
13
|
+
// surprising pushes).
|
|
14
|
+
let CLI_VERSION = "unknown";
|
|
15
|
+
try {
|
|
16
|
+
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
CLI_VERSION = JSON.parse(readFileSync(path.join(dir, "..", "package.json"), "utf8")).version || "unknown";
|
|
18
|
+
} catch { /* version is a nice-to-have; never block a request on it */ }
|
|
19
|
+
|
|
8
20
|
async function call(method, endpoint, { query, body } = {}) {
|
|
9
21
|
const cfg = await loadConfig();
|
|
10
22
|
if (!cfg.token) {
|
|
@@ -27,6 +39,7 @@ async function call(method, endpoint, { query, body } = {}) {
|
|
|
27
39
|
headers: {
|
|
28
40
|
Authorization: `Bearer ${cfg.token}`,
|
|
29
41
|
Accept: "application/json",
|
|
42
|
+
"X-Flowiq-Cli-Version": CLI_VERSION,
|
|
30
43
|
},
|
|
31
44
|
};
|
|
32
45
|
if (body !== undefined) {
|
|
@@ -36,8 +49,8 @@ async function call(method, endpoint, { query, body } = {}) {
|
|
|
36
49
|
|
|
37
50
|
const resp = await fetch(url, init);
|
|
38
51
|
const text = await resp.text();
|
|
39
|
-
let parsed;
|
|
40
|
-
try { parsed = text ? JSON.parse(text) : {}; } catch { parsed = { _raw: text }; }
|
|
52
|
+
let parsed, isJson = true;
|
|
53
|
+
try { parsed = text ? JSON.parse(text) : {}; } catch { parsed = { _raw: text }; isJson = false; }
|
|
41
54
|
if (!resp.ok) {
|
|
42
55
|
const err = new Error(
|
|
43
56
|
parsed.message || parsed.error || `${method} ${endpoint} → HTTP ${resp.status}`
|
|
@@ -46,6 +59,28 @@ async function call(method, endpoint, { query, body } = {}) {
|
|
|
46
59
|
err.body = parsed;
|
|
47
60
|
throw err;
|
|
48
61
|
}
|
|
62
|
+
// A 2xx that is not JSON is never a real API response — it is an HTML page
|
|
63
|
+
// (Vercel SSO / login / error page, or the SPA's index.html served for an
|
|
64
|
+
// endpoint that doesn't exist on that deployment). Returning it let callers
|
|
65
|
+
// "succeed" on garbage: a pull wrote `.flowiq/<topic>/undefined.json` with the
|
|
66
|
+
// HTML stuffed in `_raw`, masking an auth or deploy failure as a good pull.
|
|
67
|
+
if (!isJson) {
|
|
68
|
+
const ctype = resp.headers.get("content-type") || "none";
|
|
69
|
+
const looksHtml = /^\s*<(?:!doctype|html)\b/i.test(text);
|
|
70
|
+
const err = new Error(
|
|
71
|
+
`${method} ${endpoint} → HTTP ${resp.status} returned ${looksHtml ? "an HTML page" : "a non-JSON body"} ` +
|
|
72
|
+
`(content-type: ${ctype}), not an API response.\n` +
|
|
73
|
+
` URL: ${url}\n` +
|
|
74
|
+
` Likely: an SSO / login / error page at that host, a wrong api_url, or this endpoint ` +
|
|
75
|
+
`isn't deployed there.\n` +
|
|
76
|
+
` Check: flowiq auth whoami · api_url in ~/.config/flowiq/auth.json\n` +
|
|
77
|
+
` Body: ${text.slice(0, 140).replace(/\s+/g, " ").trim()}…`
|
|
78
|
+
);
|
|
79
|
+
err.status = resp.status;
|
|
80
|
+
err.code = "ENONJSON";
|
|
81
|
+
err.body = parsed;
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
49
84
|
return parsed;
|
|
50
85
|
}
|
|
51
86
|
|
package/src/index.js
CHANGED
|
@@ -29,6 +29,7 @@ import * as segmentsCmd from "./commands/segments.js";
|
|
|
29
29
|
import * as tagCmd from "./commands/tag.js";
|
|
30
30
|
import * as keywordsCmd from "./commands/keywords.js";
|
|
31
31
|
import * as guideCmd from "./commands/guide.js";
|
|
32
|
+
import * as auditCmd from "./commands/audit.js";
|
|
32
33
|
import { maybeNotifyUpdate } from "./update-check.js";
|
|
33
34
|
|
|
34
35
|
// Read version from package.json so it stays in sync with the published npm
|
|
@@ -67,6 +68,17 @@ export function run(argv) {
|
|
|
67
68
|
.description("Fetch an agent's prompt_sections into a local JSON file (default: active agent)")
|
|
68
69
|
.option("--agent <id>", "target a specific agent instead of the org's active one")
|
|
69
70
|
.action((orgId, opts) => promptsCmd.pull(orgId, opts));
|
|
71
|
+
prompts.command("history <organization_id>")
|
|
72
|
+
.description("Every recorded prompt push (who / when / size) — the version list for `restore`")
|
|
73
|
+
.option("--agent <id>", "history for a specific (non-active) agent")
|
|
74
|
+
.option("--limit <n>", "max entries (default 50, max 200)")
|
|
75
|
+
.option("--json", "raw JSON")
|
|
76
|
+
.action((orgId, opts) => promptsCmd.history(orgId, opts));
|
|
77
|
+
prompts.command("restore <organization_id> <audit_id>")
|
|
78
|
+
.description("Re-push a past prompt version from the audit log (DRY-RUN unless --commit)")
|
|
79
|
+
.option("--after", "restore the version that push MADE LIVE (default: the one it REPLACED)")
|
|
80
|
+
.option("--commit", "actually push it (default writes a working copy only)")
|
|
81
|
+
.action((orgId, auditId, opts) => promptsCmd.restore(orgId, auditId, opts));
|
|
70
82
|
prompts.command("push <slug-or-path>")
|
|
71
83
|
.description("Apply a local JSON file's prompt_sections back to the agent it was pulled from (regenerates system_prompt server-side)")
|
|
72
84
|
.action((id) => promptsCmd.push(id));
|
|
@@ -261,6 +273,28 @@ export function run(argv) {
|
|
|
261
273
|
.option("--yes", "skip the interactive confirm gate (the --note requirement still applies)")
|
|
262
274
|
.action((updateId, opts) => agentUpdatesCmd.resolve(updateId, opts));
|
|
263
275
|
|
|
276
|
+
// audit (who did what, when — with the full before/after content)
|
|
277
|
+
const audit = program.command("audit")
|
|
278
|
+
.description("Staff-CLI audit trail: who changed what, when — with full before/after content")
|
|
279
|
+
.argument("[organization_id]", "scope to one org (omit for all orgs)")
|
|
280
|
+
.option("--endpoint <name>", "prompts | questionnaires | fine-tuning | knowledge | custom-tools | keywords | agent-config | broadcast | tag | …")
|
|
281
|
+
.option("--action <name>", "push | create | config | resolve | send | apply | read | …")
|
|
282
|
+
.option("--status <s>", "ok | blocked | failed")
|
|
283
|
+
.option("--agent <id>", "scope to one agent")
|
|
284
|
+
.option("--target <id>", "scope to one target (task id, tool name, tag, broadcast id)")
|
|
285
|
+
.option("--user <substr>", "filter by staff email (substring)")
|
|
286
|
+
.option("--since <date>", "YYYY-MM-DD or ISO")
|
|
287
|
+
.option("--until <date>", "YYYY-MM-DD or ISO")
|
|
288
|
+
.option("--limit <n>", "max entries (default 50, max 200)")
|
|
289
|
+
.option("--json", "raw JSON")
|
|
290
|
+
.action((orgId, opts) => auditCmd.list(orgId, opts));
|
|
291
|
+
audit.command("show <audit_id>")
|
|
292
|
+
.description("One audit entry in full")
|
|
293
|
+
.option("--content", "also print the before/after payloads")
|
|
294
|
+
.option("--out <path>", "write the whole entry (incl. content) to a JSON file")
|
|
295
|
+
.option("--json", "raw JSON")
|
|
296
|
+
.action((auditId, opts) => auditCmd.show(auditId, opts));
|
|
297
|
+
|
|
264
298
|
// export (full chat history → TXT, byte-identical to the in-app export)
|
|
265
299
|
const exp = program.command("export").description("Export data to local files");
|
|
266
300
|
exp.command("chats <organization_id>")
|