@flowapt/flowiq-cli 0.1.5 → 0.1.7
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 +167 -3
- package/bin/flowiq.js +0 -0
- package/package.json +4 -3
- package/src/commands/agent-config.js +95 -0
- package/src/commands/agent-test.js +232 -0
- package/src/commands/agent-updates.js +186 -0
- package/src/commands/export.js +131 -0
- package/src/commands/flowmod.js +130 -0
- package/src/commands/groups.js +329 -0
- package/src/commands/org.js +33 -0
- package/src/commands/pinboard.js +174 -0
- package/src/commands/templates.js +156 -0
- package/src/data/scenarios.qa.json +41 -0
- package/src/index.js +171 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// `flowiq agent-updates pull <org_id> [flags]` / `list`.
|
|
2
|
+
// Read-only: fetches an org's agent_updates + chat context via /cli/agent-updates,
|
|
3
|
+
// then downloads any attachments client-side into ./.flowiq/agent-updates/files/.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { http } from "../http.js";
|
|
8
|
+
|
|
9
|
+
const AU_DIR = path.resolve(process.cwd(), ".flowiq", "agent-updates");
|
|
10
|
+
const FILES_DIRNAME = "files";
|
|
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
|
+
const DEFAULT_MAX_MB = 25;
|
|
13
|
+
|
|
14
|
+
function slugify(name, fallback) {
|
|
15
|
+
const s = String(name || "")
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
18
|
+
.replace(/-+/g, "-")
|
|
19
|
+
.replace(/^-|-$/g, "");
|
|
20
|
+
return s || fallback;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function fileExists(p) {
|
|
24
|
+
try { await fs.access(p); return true; } catch { return false; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function oneLine(s, n = 120) {
|
|
28
|
+
const t = String(s ?? "").replace(/\s+/g, " ").trim();
|
|
29
|
+
return t.length > n ? t.slice(0, n - 1) + "…" : t;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Filesystem-safe filename (strip path, control chars, separators).
|
|
33
|
+
function safeFilename(name, fallback) {
|
|
34
|
+
const base = String(name || "").split("?")[0].split(/[\\/]/).pop().trim();
|
|
35
|
+
const cleaned = base.replace(/[\x00-\x1f]/g, "").replace(/[\\/]/g, "_");
|
|
36
|
+
return cleaned || fallback;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Download one URL to destPath, respecting a byte cap. Never throws.
|
|
40
|
+
async function downloadOne(url, destPath, maxBytes) {
|
|
41
|
+
let res;
|
|
42
|
+
try { res = await fetch(url); }
|
|
43
|
+
catch (e) { return { ok: false, reason: `fetch error: ${e.message}` }; }
|
|
44
|
+
if (!res.ok) return { ok: false, reason: `http ${res.status}` };
|
|
45
|
+
const len = Number(res.headers.get("content-length") || 0);
|
|
46
|
+
if (len && len > maxBytes) return { ok: false, reason: `too large (${len} bytes > cap)` };
|
|
47
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
48
|
+
if (buf.length > maxBytes) return { ok: false, reason: `too large (${buf.length} bytes > cap)` };
|
|
49
|
+
await fs.mkdir(path.dirname(destPath), { recursive: true });
|
|
50
|
+
await fs.writeFile(destPath, buf);
|
|
51
|
+
return { ok: true, bytes: buf.length };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function downloadUpdateFiles(update, maxBytes) {
|
|
55
|
+
const baseDir = path.join(AU_DIR, FILES_DIRNAME, update.id);
|
|
56
|
+
const atts = Array.isArray(update.attachments) ? update.attachments : [];
|
|
57
|
+
for (let i = 0; i < atts.length; i++) {
|
|
58
|
+
const a = atts[i];
|
|
59
|
+
if (!a || !a.url) continue;
|
|
60
|
+
const fname = safeFilename(a.filename, `attachment-${i + 1}`);
|
|
61
|
+
if (a.size && a.size > maxBytes) {
|
|
62
|
+
a.download = { ok: false, reason: `declared size ${a.size} bytes > cap` };
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const r = await downloadOne(a.url, path.join(baseDir, fname), maxBytes);
|
|
66
|
+
a.download = r;
|
|
67
|
+
if (r.ok) a.local_path = path.join(baseDir, fname);
|
|
68
|
+
}
|
|
69
|
+
if (update.superadmin_response_image) {
|
|
70
|
+
const fname = "admin-" + safeFilename(update.superadmin_response_image, "response-image");
|
|
71
|
+
const r = await downloadOne(update.superadmin_response_image, path.join(baseDir, fname), maxBytes);
|
|
72
|
+
update.superadmin_response_image_download = r;
|
|
73
|
+
if (r.ok) update.superadmin_response_image_local = path.join(baseDir, fname);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function pull(orgId, opts = {}) {
|
|
78
|
+
if (!UUID_RE.test(orgId)) {
|
|
79
|
+
console.error(`Error: "${orgId}" is not a valid organization UUID.`);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const downloadFiles = !opts.noFiles;
|
|
84
|
+
const maxMb = opts.maxMb != null ? Number.parseFloat(opts.maxMb) : DEFAULT_MAX_MB;
|
|
85
|
+
if (!Number.isFinite(maxMb) || maxMb <= 0) {
|
|
86
|
+
console.error(`Invalid --max-mb: ${opts.maxMb}`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
const maxBytes = Math.round(maxMb * 1024 * 1024);
|
|
90
|
+
|
|
91
|
+
const query = { organization_id: orgId };
|
|
92
|
+
if (opts.status) query.status = opts.status;
|
|
93
|
+
if (opts.titles) query.titles = opts.titles;
|
|
94
|
+
if (opts.before != null) query.before = String(opts.before);
|
|
95
|
+
if (opts.after != null) query.after = String(opts.after);
|
|
96
|
+
if (opts.recent != null) query.recent = String(opts.recent);
|
|
97
|
+
|
|
98
|
+
let resp;
|
|
99
|
+
try {
|
|
100
|
+
resp = await http.get("agent-updates", query);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
console.error(`Pull failed: ${e.message}`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Download attachments client-side (mutates the update objects with local_path).
|
|
107
|
+
if (downloadFiles) {
|
|
108
|
+
for (const u of resp.updates || []) {
|
|
109
|
+
try { await downloadUpdateFiles(u, maxBytes); }
|
|
110
|
+
catch (e) { u._file_download_error = e.message; }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
resp.attachments_downloaded = downloadFiles;
|
|
114
|
+
resp.attachment_max_mb = maxMb;
|
|
115
|
+
|
|
116
|
+
await fs.mkdir(AU_DIR, { recursive: true });
|
|
117
|
+
const slug = slugify(resp.organization_name, resp.organization_id);
|
|
118
|
+
const filePath = path.join(AU_DIR, `${slug}.json`);
|
|
119
|
+
const overwriting = await fileExists(filePath);
|
|
120
|
+
await fs.writeFile(filePath, JSON.stringify(resp, null, 2) + "\n", "utf8");
|
|
121
|
+
|
|
122
|
+
const updates = resp.updates || [];
|
|
123
|
+
console.log(`${overwriting ? "Overwrote" : "Wrote"} ${filePath}`);
|
|
124
|
+
console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
|
|
125
|
+
console.log(` status filter: ${resp.status_filter}`);
|
|
126
|
+
console.log(` title filter: ${resp.title_filter ? resp.title_filter.join(" | ") : "(none — all)"}`);
|
|
127
|
+
console.log(` attachments: ${downloadFiles ? `auto-download ON (cap ${maxMb}MB)` : "OFF (--no-files)"}`);
|
|
128
|
+
console.log(` updates: ${updates.length}`);
|
|
129
|
+
if (!updates.length) {
|
|
130
|
+
console.log(" (no matching updates)");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
console.log("");
|
|
134
|
+
updates.forEach((u, i) => {
|
|
135
|
+
const date = String(u.created_at || "").slice(0, 16).replace("T", " ");
|
|
136
|
+
console.log(` [${i + 1}] ${u.title}`);
|
|
137
|
+
console.log(` priority=${u.priority} status=${u.status} raised=${date}${u.created_by_name ? ` by ${u.created_by_name}` : ""}`);
|
|
138
|
+
console.log(` desc: ${oneLine(u.description, 140)}`);
|
|
139
|
+
if (u.contact) console.log(` contact: ${u.contact.full_name ?? "(no name)"} (${u.contact.whatsapp_id ?? u.contact.id})`);
|
|
140
|
+
if (u.attachments && u.attachments.length) {
|
|
141
|
+
console.log(` attachments: ${u.attachments.length} (${u.attachments.map((a) => a.filename || a.type || "file").join(", ")})`);
|
|
142
|
+
for (const a of u.attachments) {
|
|
143
|
+
if (a.local_path) console.log(` ↓ ${a.local_path}`);
|
|
144
|
+
else if (a.download && !a.download.ok) console.log(` ✗ ${a.filename || "file"}: ${a.download.reason}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (u.superadmin_response_image_local) console.log(` admin response image: ↓ ${u.superadmin_response_image_local}`);
|
|
148
|
+
if (u.context && u.context.messages && u.context.messages.length) {
|
|
149
|
+
const trig = u.context.messages.find((m) => m.is_trigger);
|
|
150
|
+
console.log(` context: ${u.context.total} msgs (anchor=${u.context.anchor})${trig ? ` · trigger: "${oneLine(trig.message, 80)}"` : ""}`);
|
|
151
|
+
} else {
|
|
152
|
+
console.log(` context: (none linked)`);
|
|
153
|
+
}
|
|
154
|
+
console.log("");
|
|
155
|
+
});
|
|
156
|
+
console.log("Read the JSON for full update text + chat context. Attachments (paths marked ↓) were downloaded to");
|
|
157
|
+
console.log(`${path.join(AU_DIR, FILES_DIRNAME)}/<update-id>/ — Read those files for visual context, then act.`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function list() {
|
|
161
|
+
let files;
|
|
162
|
+
try {
|
|
163
|
+
files = (await fs.readdir(AU_DIR)).filter((f) => f.endsWith(".json"));
|
|
164
|
+
} catch {
|
|
165
|
+
console.log("(no local snapshots — run `agent-updates pull <organization_id>`)");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (!files.length) {
|
|
169
|
+
console.log("(no local snapshots — run `agent-updates pull <organization_id>`)");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
files.sort();
|
|
173
|
+
for (const f of files) {
|
|
174
|
+
const fp = path.join(AU_DIR, f);
|
|
175
|
+
try {
|
|
176
|
+
const d = JSON.parse(await fs.readFile(fp, "utf8"));
|
|
177
|
+
console.log(f);
|
|
178
|
+
console.log(` org: ${d.organization_name ?? "(unknown)"} (${d.organization_id ?? "?"})`);
|
|
179
|
+
console.log(` filter: status=${d.status_filter ?? "?"} title=${d.title_filter ? d.title_filter.join("|") : "(all)"}`);
|
|
180
|
+
console.log(` updates: ${d.total_updates ?? "?"}`);
|
|
181
|
+
console.log(` pulled: ${d.pulled_at ?? "(unknown)"}`);
|
|
182
|
+
} catch (e) {
|
|
183
|
+
console.log(`${f} [invalid JSON: ${e.message}]`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// `flowiq export chats <organization_id> [--out <path>]`.
|
|
2
|
+
// Full chat export → TXT, byte-identical to the in-app "Export Settings TXT".
|
|
3
|
+
// The server (/cli/export-chats) supplies keyset pages (ordered, whatsapp_id
|
|
4
|
+
// resolved); ALL grouping + formatting happens here, verbatim from the legacy
|
|
5
|
+
// export-chats.mjs, so the output matches the deployed feature exactly.
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import fsp from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { http } from "../http.js";
|
|
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;
|
|
13
|
+
|
|
14
|
+
function slugify(name, fallback) {
|
|
15
|
+
const s = String(name || "")
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
18
|
+
.replace(/-+/g, "-")
|
|
19
|
+
.replace(/^-|-$/g, "");
|
|
20
|
+
return s || fallback;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Verbatim from export-chats.mjs.
|
|
24
|
+
function getSenderLabel(senderType) {
|
|
25
|
+
if (senderType.startsWith("user-")) return "customer";
|
|
26
|
+
if (senderType.startsWith("bot-")) return "bot";
|
|
27
|
+
if (senderType.startsWith("human-")) return "agent";
|
|
28
|
+
if (senderType.startsWith("flowmod-")) return "flowmod";
|
|
29
|
+
if (senderType.startsWith("call-")) return "call";
|
|
30
|
+
if (senderType === "tool-call") return "tool";
|
|
31
|
+
return senderType;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function chats(orgId, opts = {}) {
|
|
35
|
+
if (!UUID_RE.test(orgId)) {
|
|
36
|
+
console.error(`Error: "${orgId}" is not a valid organization UUID.`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const t0 = Date.now();
|
|
41
|
+
|
|
42
|
+
// --- Drive the keyset page loop, accumulating messages in global order ---
|
|
43
|
+
const messages = [];
|
|
44
|
+
let organizationName = null;
|
|
45
|
+
let cursor = null;
|
|
46
|
+
let pages = 0;
|
|
47
|
+
for (;;) {
|
|
48
|
+
const query = { organization_id: orgId };
|
|
49
|
+
if (cursor) {
|
|
50
|
+
query.cursor_created_at = cursor.created_at;
|
|
51
|
+
query.cursor_id = cursor.id;
|
|
52
|
+
}
|
|
53
|
+
let resp;
|
|
54
|
+
try {
|
|
55
|
+
resp = await http.get("export-chats", query);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
console.error(`Export failed on page ${pages + 1}: ${e.message}`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
if (resp.organization_name != null) organizationName = resp.organization_name;
|
|
61
|
+
const page = resp.page || [];
|
|
62
|
+
for (const r of page) messages.push(r);
|
|
63
|
+
pages++;
|
|
64
|
+
if (pages % 20 === 0) {
|
|
65
|
+
console.log(` ...${messages.length.toLocaleString("en-US")} messages (${pages} pages)`);
|
|
66
|
+
}
|
|
67
|
+
if (resp.done || !resp.next_cursor) break;
|
|
68
|
+
cursor = resp.next_cursor;
|
|
69
|
+
}
|
|
70
|
+
console.log(`Messages fetched: ${messages.length} in ${pages} page${pages === 1 ? "" : "s"}`);
|
|
71
|
+
|
|
72
|
+
// --- Group exactly like the deployed exportTXT: plain object, waId key ---
|
|
73
|
+
const grouped = {};
|
|
74
|
+
for (const row of messages) {
|
|
75
|
+
const whatsappId = row.whatsapp_id ?? null;
|
|
76
|
+
const waId = (whatsappId || "unknown").replace(/[+ ]/g, "");
|
|
77
|
+
if (!grouped[waId]) grouped[waId] = [];
|
|
78
|
+
grouped[waId].push(row);
|
|
79
|
+
}
|
|
80
|
+
const uniqueContacts = Object.keys(grouped).length;
|
|
81
|
+
|
|
82
|
+
// --- Resolve output path ---
|
|
83
|
+
let outPath = opts.out;
|
|
84
|
+
if (!outPath) {
|
|
85
|
+
const dir = path.resolve(process.cwd(), ".flowiq", "exports");
|
|
86
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
87
|
+
outPath = path.join(dir, `${slugify(organizationName, orgId)}-chats.txt`);
|
|
88
|
+
} else {
|
|
89
|
+
outPath = path.resolve(outPath);
|
|
90
|
+
await fsp.mkdir(path.dirname(outPath), { recursive: true });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --- Write the TXT (streamed; content identical to the deployed string build) ---
|
|
94
|
+
const out = fs.createWriteStream(outPath, { encoding: "utf8" });
|
|
95
|
+
const write = (s) =>
|
|
96
|
+
new Promise((resolve, reject) => {
|
|
97
|
+
if (out.write(s)) resolve();
|
|
98
|
+
else out.once("drain", resolve);
|
|
99
|
+
out.once("error", reject);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
let header = `${organizationName || "Organization"} — Chat Export\n`;
|
|
103
|
+
header += `Total messages: ${messages.length.toLocaleString()} | Contacts: ${uniqueContacts}\n`;
|
|
104
|
+
header += `Exported: ${new Date().toLocaleString("en-ZA", { timeZone: "Africa/Johannesburg" })}\n`;
|
|
105
|
+
header += `${"=".repeat(60)}\n\n`;
|
|
106
|
+
await write(header);
|
|
107
|
+
|
|
108
|
+
for (const [waId, msgs] of Object.entries(grouped)) {
|
|
109
|
+
let chunk = `--- ${waId} ---\n`;
|
|
110
|
+
for (const msg of msgs) {
|
|
111
|
+
const date = msg.created_at
|
|
112
|
+
? new Date(msg.created_at).toLocaleString("en-ZA", { timeZone: "Africa/Johannesburg" })
|
|
113
|
+
: "unknown";
|
|
114
|
+
const label = getSenderLabel(msg.sender_type || "");
|
|
115
|
+
let line = `[${date}] (${label}) ${msg.message || ""}`;
|
|
116
|
+
if (msg.media_url) line += ` [Media: ${msg.media_type || "file"} - ${msg.media_url}]`;
|
|
117
|
+
if (msg.voice_note_transcription) line += ` [Voice: ${msg.voice_note_transcription}]`;
|
|
118
|
+
chunk += `${line}\n`;
|
|
119
|
+
}
|
|
120
|
+
chunk += `\n`;
|
|
121
|
+
await write(chunk);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
await new Promise((resolve, reject) => out.end((err) => (err ? reject(err) : resolve())));
|
|
125
|
+
|
|
126
|
+
const secs = ((Date.now() - t0) / 1000).toFixed(1);
|
|
127
|
+
console.log(`\nDone in ${secs}s`);
|
|
128
|
+
console.log(` total messages: ${messages.length.toLocaleString()}`);
|
|
129
|
+
console.log(` groups: ${uniqueContacts}`);
|
|
130
|
+
console.log(` file: ${outPath}`);
|
|
131
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// `flowiq flowmod pull <organization_id>` /
|
|
2
|
+
// `flowiq flowmod push <slug>`.
|
|
3
|
+
// Round-trips an org's FlowMod master-group prompts + config (the
|
|
4
|
+
// flowmod_master_groups + flowmod_prompts tables) through a local JSON file.
|
|
5
|
+
// No DB credentials on disk — all privileged work happens server-side in
|
|
6
|
+
// /api/cli/flowmod.
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { http } from "../http.js";
|
|
11
|
+
|
|
12
|
+
const FLOWMOD_DIR = path.resolve(process.cwd(), ".flowiq", "flowmod");
|
|
13
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
14
|
+
|
|
15
|
+
function slugify(name, fallback) {
|
|
16
|
+
const s = String(name || "")
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.replace(/&/g, "and")
|
|
19
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
20
|
+
.replace(/-+/g, "-")
|
|
21
|
+
.replace(/^-|-$/g, "");
|
|
22
|
+
return s || fallback;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function fileExists(p) {
|
|
26
|
+
try { await fs.access(p); return true; } catch { return false; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function resolvePushPath(identifier) {
|
|
30
|
+
const candidates = [];
|
|
31
|
+
if (path.isAbsolute(identifier)) {
|
|
32
|
+
candidates.push(identifier);
|
|
33
|
+
} else if (identifier.includes("/") || identifier.includes("\\")) {
|
|
34
|
+
candidates.push(path.resolve(identifier));
|
|
35
|
+
} else {
|
|
36
|
+
const base = identifier.endsWith(".json") ? identifier : `${identifier}.json`;
|
|
37
|
+
candidates.push(path.join(FLOWMOD_DIR, base));
|
|
38
|
+
candidates.push(path.resolve(base));
|
|
39
|
+
}
|
|
40
|
+
for (const c of candidates) {
|
|
41
|
+
if (await fileExists(c)) return c;
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`File not found. Tried:\n ${candidates.join("\n ")}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function mainPromptChars(mg) {
|
|
47
|
+
const prompts = mg.prompts || [];
|
|
48
|
+
// Prefer the master-level main (no group_ids); fall back to any main prompt
|
|
49
|
+
// (some master_groups pin their main prompt to specific group_ids).
|
|
50
|
+
const main =
|
|
51
|
+
prompts.find((p) => p.type === "main" && (!Array.isArray(p.group_ids) || p.group_ids.length === 0)) ||
|
|
52
|
+
prompts.find((p) => p.type === "main");
|
|
53
|
+
return main ? (main.text || "").length : 0;
|
|
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("flowmod", { organization_id: orgId });
|
|
64
|
+
} catch (e) {
|
|
65
|
+
console.error(`Pull failed: ${e.message}`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
await fs.mkdir(FLOWMOD_DIR, { recursive: true });
|
|
70
|
+
const slug = slugify(resp.organization_name, resp.organization_id);
|
|
71
|
+
const filePath = path.join(FLOWMOD_DIR, `${slug}.json`);
|
|
72
|
+
const overwriting = await fileExists(filePath);
|
|
73
|
+
await fs.writeFile(filePath, JSON.stringify(resp, null, 2) + "\n", "utf8");
|
|
74
|
+
|
|
75
|
+
const totalPrompts = (resp.master_groups || []).reduce((a, mg) => a + (mg.prompts?.length ?? 0), 0);
|
|
76
|
+
console.log(`${overwriting ? "Overwrote" : "Wrote"} ${filePath}`);
|
|
77
|
+
console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
|
|
78
|
+
console.log(` master_groups: ${resp.master_groups?.length ?? 0} (${totalPrompts} prompt rows)`);
|
|
79
|
+
for (const mg of resp.master_groups || []) {
|
|
80
|
+
const cfg = mg.config || {};
|
|
81
|
+
const cfgBits = [
|
|
82
|
+
cfg.model && `model=${cfg.model}`,
|
|
83
|
+
cfg.reasoningEffort && `effort=${cfg.reasoningEffort}`,
|
|
84
|
+
Array.isArray(mg.custom_tools) && mg.custom_tools.length ? `${mg.custom_tools.length} tools` : null,
|
|
85
|
+
].filter(Boolean).join(", ");
|
|
86
|
+
console.log(` • ${mg.name}: main=${mainPromptChars(mg)} chars${cfgBits ? ` (${cfgBits})` : ""}`);
|
|
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 (!Array.isArray(data.master_groups) || data.master_groups.length === 0) {
|
|
111
|
+
console.error(`${filePath}: master_groups must be a non-empty array`);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let resp;
|
|
116
|
+
try {
|
|
117
|
+
resp = await http.post("flowmod", {
|
|
118
|
+
organization_id: data.organization_id,
|
|
119
|
+
master_groups: data.master_groups,
|
|
120
|
+
});
|
|
121
|
+
} catch (e) {
|
|
122
|
+
console.error(`Push failed: ${e.message}`);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
console.log(`Pushed ${filePath}`);
|
|
127
|
+
console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
|
|
128
|
+
console.log(` master_groups: ${resp.master_groups_updated} updated`);
|
|
129
|
+
console.log(` prompts: ${resp.prompts_updated} updated`);
|
|
130
|
+
}
|