@flowapt/flowiq-cli 0.9.6 → 0.9.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.
@@ -0,0 +1,346 @@
1
+ // `flowiq gaps report|list|show|hit|note|status` — CLI gap reports and feature
2
+ // requests (alias `flowiq requests`), via /cli/gaps.
3
+ // report file one: something the CLI cannot do, does wrong, or should do
4
+ // list open requests, most-hit first (every status with --status all)
5
+ // show one request in full, with every hit, note and status change
6
+ // hit "this bit me too": +1 with what you were doing (the ranking signal)
7
+ // note add context without counting a hit
8
+ // status planned / in_progress / done / declined / duplicate (maintainers;
9
+ // closing emails everyone who reported or hit it)
10
+ // Anyone with a staff key can report, hit and note — including a Claude
11
+ // session driving the CLI, which is marked `claude` automatically.
12
+
13
+ import fs from "node:fs/promises";
14
+ import path from "node:path";
15
+ import { http } from "../http.js";
16
+
17
+ const KINDS = ["gap", "idea", "bug"];
18
+ const STATUSES = ["open", "planned", "in_progress", "done", "declined", "duplicate"];
19
+ const STATUS_ORDER = ["in_progress", "planned", "open", "done", "declined", "duplicate"];
20
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
21
+
22
+ /** Who is typing: a Claude session, a script/pipe, or a person at a terminal. */
23
+ export function filedVia(env = process.env, stdin = process.stdin, stdout = process.stdout) {
24
+ if (env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT) return "claude";
25
+ if (!stdin?.isTTY || !stdout?.isTTY) return "script";
26
+ return "human";
27
+ }
28
+
29
+ /** "42", "#42" or a UUID; anything else is refused before a request is made. */
30
+ export function normaliseRef(ref) {
31
+ const raw = String(ref ?? "").trim().replace(/^#/, "");
32
+ if (/^\d{1,12}$/.test(raw)) return raw;
33
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) return raw;
34
+ return null;
35
+ }
36
+
37
+ function sastParts(ts) {
38
+ const parts = new Intl.DateTimeFormat("en-CA", {
39
+ timeZone: "Africa/Johannesburg",
40
+ year: "numeric", month: "2-digit", day: "2-digit",
41
+ hour: "2-digit", minute: "2-digit", hour12: false,
42
+ }).formatToParts(new Date(ts));
43
+ return Object.fromEntries(parts.map((p) => [p.type, p.value]));
44
+ }
45
+
46
+ function fmtTs(ts) {
47
+ if (!ts) return "-";
48
+ const p = sastParts(ts);
49
+ const hour = p.hour === "24" ? "00" : p.hour;
50
+ return `${Number(p.day)} ${MONTHS[Number(p.month) - 1]} ${p.year} ${hour}:${p.minute}`;
51
+ }
52
+
53
+ export function ago(ts, now = Date.now()) {
54
+ if (!ts) return "-";
55
+ const mins = Math.max(0, Math.round((now - new Date(ts).getTime()) / 60000));
56
+ if (mins < 60) return `${mins}m ago`;
57
+ const hrs = Math.round(mins / 60);
58
+ if (hrs < 48) return `${hrs}h ago`;
59
+ return `${Math.round(hrs / 24)}d ago`;
60
+ }
61
+
62
+ function who(email) {
63
+ if (!email) return "?";
64
+ return String(email).split("@")[0];
65
+ }
66
+
67
+ function indent(text, pad = " ") {
68
+ return String(text ?? "").trim().split("\n").map((l) => `${pad}${l}`).join("\n");
69
+ }
70
+
71
+ function fail(prefix, e) {
72
+ console.error(`${prefix}: ${e.message}`);
73
+ if (e.body?.error && !String(e.message).includes(e.body.error)) console.error(` ${e.body.error}`);
74
+ process.exit(1);
75
+ }
76
+
77
+ function requireRef(ref) {
78
+ const r = normaliseRef(ref);
79
+ if (!r) {
80
+ console.error(`Error: "${ref}" is not a request number (e.g. 42 or #42). Find it with \`flowiq gaps list\`.`);
81
+ process.exit(1);
82
+ }
83
+ return r;
84
+ }
85
+
86
+ function printSimilar(similar, heading) {
87
+ if (!similar?.length) return;
88
+ console.log(heading);
89
+ for (const s of similar) {
90
+ const shipped = s.status === "done" && s.resolved_version ? ` in ${s.resolved_version}` : "";
91
+ console.log(` #${s.number} ${s.title} [${s.status}${shipped} · ${s.hit_count} hit(s) · match ${s.score}]`);
92
+ }
93
+ }
94
+
95
+ // ── report ──────────────────────────────────────────────────────────────────
96
+ export async function report(titleWords, opts = {}) {
97
+ const title = (Array.isArray(titleWords) ? titleWords.join(" ") : String(titleWords || "")).trim();
98
+ if (title.length < 3) {
99
+ console.error('Error: give a one-line title, e.g. flowiq gaps report "agent config cannot set tool_instructions" --command "agent config"');
100
+ process.exit(1);
101
+ }
102
+ const kind = String(opts.kind || "gap").toLowerCase();
103
+ if (!KINDS.includes(kind)) {
104
+ console.error(`Error: --kind must be ${KINDS.join(" | ")} (gap = the CLI cannot do it, bug = it does it wrong, idea = it should).`);
105
+ process.exit(1);
106
+ }
107
+ let detail = opts.detail ?? null;
108
+ if (opts.detailFile) {
109
+ try {
110
+ detail = await fs.readFile(path.resolve(process.cwd(), opts.detailFile), "utf8");
111
+ } catch (e) {
112
+ console.error(`Error: cannot read --detail-file ${opts.detailFile}: ${e.message}`);
113
+ process.exit(1);
114
+ }
115
+ }
116
+
117
+ let resp;
118
+ try {
119
+ resp = await http.post("gaps", {
120
+ action: "report",
121
+ title,
122
+ kind,
123
+ command: opts.command ?? null,
124
+ detail,
125
+ error_text: opts.error ?? null,
126
+ workaround: opts.workaround ?? null,
127
+ priority: opts.priority ?? "normal",
128
+ organization_id: opts.org ?? null,
129
+ filed_via: filedVia(),
130
+ force_new: Boolean(opts.new),
131
+ dry_run: Boolean(opts.dryRun),
132
+ });
133
+ } catch (e) {
134
+ if (e.status === 409) {
135
+ console.error(e.body?.error || e.message);
136
+ printSimilar(e.body?.similar, "\nAlready on the list:");
137
+ process.exit(1);
138
+ }
139
+ fail("Report failed", e);
140
+ }
141
+ if (opts.json) {
142
+ console.log(JSON.stringify(resp, null, 2));
143
+ return;
144
+ }
145
+ if (resp.dry_run) {
146
+ console.log(`DRY RUN · nothing filed · "${title}"`);
147
+ if (!resp.similar?.length) console.log(" No similar requests on the list. File it by re-running without --dry-run.");
148
+ printSimilar(resp.similar, "Similar requests:");
149
+ if (resp.would_block) console.log("\nA real report would be held as a duplicate: hit the matching one, or pass --new if it is different.");
150
+ return;
151
+ }
152
+ const r = resp.request;
153
+ console.log(`Filed #${r.number} (${r.kind}${r.command ? ` · flowiq ${r.command}` : ""}): ${r.title}`);
154
+ console.log(` by ${r.reporter_email ?? "?"} via ${r.filed_via}${r.organization_name ? ` · org ${r.organization_name}` : ""}`);
155
+ printSimilar(resp.similar, "\nPossibly related:");
156
+ console.log(`\nTrack it: flowiq gaps show ${r.number} · hit it again next time: flowiq gaps hit ${r.number} --note "…"`);
157
+ }
158
+
159
+ // ── list ────────────────────────────────────────────────────────────────────
160
+ export async function list(opts = {}) {
161
+ const query = {};
162
+ if (opts.status) query.status = opts.status;
163
+ if (opts.command) query.command = opts.command;
164
+ if (opts.kind) query.kind = opts.kind;
165
+ if (opts.mine) query.mine = "1";
166
+ if (opts.search) query.search = opts.search;
167
+ if (opts.sort) query.sort = opts.sort;
168
+ if (opts.limit != null) query.limit = String(opts.limit);
169
+
170
+ let resp;
171
+ try {
172
+ resp = await http.get("gaps", query);
173
+ } catch (e) {
174
+ fail("List failed", e);
175
+ }
176
+ if (opts.json) {
177
+ console.log(JSON.stringify(resp, null, 2));
178
+ return;
179
+ }
180
+
181
+ const totals = STATUS_ORDER.filter((s) => resp.counts?.[s]).map((s) => `${s} ${resp.counts[s]}`).join(" · ");
182
+ console.log(`CLI requests · status ${resp.status_filter} · ${resp.total} shown · every status: ${totals || "none yet"}`);
183
+ const rows = resp.requests || [];
184
+ if (!rows.length) {
185
+ console.log(" (nothing matches)");
186
+ console.log('\nFile one: flowiq gaps report "<what the CLI cannot do>" --command "<topic>"');
187
+ return;
188
+ }
189
+ for (const r of rows) {
190
+ const cmd = r.command ? `[${r.command}] ` : "";
191
+ const shipped = r.status === "done" && r.resolved_version ? ` ${r.resolved_version}` : "";
192
+ console.log(`\n#${r.number} ${r.kind.padEnd(4)} ×${r.hit_count} ${cmd}${r.title}`);
193
+ const bits = [
194
+ `${r.status}${shipped}`,
195
+ r.priority !== "normal" ? `${r.priority} priority` : null,
196
+ `by ${who(r.reporter_email)}${r.filed_via === "claude" ? " (Claude)" : ""} ${ago(r.created_at)}`,
197
+ r.hit_count > 1 ? `last hit ${ago(r.last_hit_at)}` : null,
198
+ r.organization_name ? `org ${r.organization_name}` : null,
199
+ ].filter(Boolean);
200
+ console.log(` ${bits.join(" · ")}`);
201
+ }
202
+ if (resp.truncated) console.log(`\n⚠ the limit (${resp.limit}) was reached: pass --limit, or narrow with --status / --command / --search.`);
203
+ console.log("\nOne in full: flowiq gaps show <number> · same problem? flowiq gaps hit <number> --note \"…\"");
204
+ }
205
+
206
+ // ── show ────────────────────────────────────────────────────────────────────
207
+ export async function show(ref, opts = {}) {
208
+ const r0 = requireRef(ref);
209
+ let resp;
210
+ try {
211
+ resp = await http.get("gaps", { ref: r0 });
212
+ } catch (e) {
213
+ fail("Show failed", e);
214
+ }
215
+ if (opts.json) {
216
+ console.log(JSON.stringify(resp, null, 2));
217
+ return;
218
+ }
219
+ const r = resp.request;
220
+ console.log(`#${r.number} ${r.title}`);
221
+ console.log(` kind: ${r.kind}${r.command ? ` · flowiq ${r.command}` : ""} · priority ${r.priority}`);
222
+ console.log(` status: ${r.status}${r.resolved_version ? ` (${r.resolved_version})` : ""}${resp.duplicate_of ? ` of #${resp.duplicate_of.number} "${resp.duplicate_of.title}"` : ""}`);
223
+ console.log(` hits: ${r.hit_count} · last ${fmtTs(r.last_hit_at)} SAST`);
224
+ console.log(` filed: ${fmtTs(r.created_at)} SAST by ${r.reporter_email ?? "?"} via ${r.filed_via ?? "?"}${r.cli_version ? ` (CLI ${r.cli_version})` : ""}`);
225
+ if (r.organization_name) console.log(` org: ${r.organization_name}`);
226
+ if (r.resolved_at) console.log(` closed: ${fmtTs(r.resolved_at)} SAST by ${r.resolved_by_email ?? "?"}`);
227
+ console.log(` id: ${r.id}`);
228
+ if (r.detail) console.log(`\nDetail\n${indent(r.detail, " ")}`);
229
+ if (r.error_text) console.log(`\nError (verbatim)\n${indent(r.error_text, " ")}`);
230
+ if (r.workaround) console.log(`\nWorkaround used\n${indent(r.workaround, " ")}`);
231
+ if (r.resolution_note) console.log(`\nResolution\n${indent(r.resolution_note, " ")}`);
232
+
233
+ const events = resp.events || [];
234
+ if (events.length) {
235
+ console.log(`\nHistory (${events.length})`);
236
+ for (const e of events) {
237
+ const head = e.kind === "status"
238
+ ? `status ${e.from_status} → ${e.to_status}`
239
+ : e.kind === "hit" ? "hit again" : "note";
240
+ const org = e.organization_name ? ` · ${e.organization_name}` : "";
241
+ console.log(` ${fmtTs(e.created_at)} ${who(e.user_email)}${e.filed_via === "claude" ? " (Claude)" : ""}: ${head}${org}`);
242
+ if (e.body) console.log(indent(e.body, " "));
243
+ if (e.error_text) console.log(indent(`error: ${e.error_text}`, " "));
244
+ }
245
+ }
246
+ console.log(`\nSame problem? flowiq gaps hit ${r.number} --note "what you were doing"`);
247
+ console.log(`Maintainers (${(resp.editors || []).join(", ")}): flowiq gaps status ${r.number} --to done --version <x.y.z> --note "…"`);
248
+ }
249
+
250
+ // ── hit ─────────────────────────────────────────────────────────────────────
251
+ export async function hit(ref, opts = {}) {
252
+ const r0 = requireRef(ref);
253
+ let resp;
254
+ try {
255
+ resp = await http.post("gaps", {
256
+ action: "hit",
257
+ ref: r0,
258
+ note: opts.note ?? null,
259
+ error_text: opts.error ?? null,
260
+ organization_id: opts.org ?? null,
261
+ filed_via: filedVia(),
262
+ });
263
+ } catch (e) {
264
+ fail("Hit failed", e);
265
+ }
266
+ if (opts.json) {
267
+ console.log(JSON.stringify(resp, null, 2));
268
+ return;
269
+ }
270
+ const r = resp.request;
271
+ console.log(`+1 on #${r.number} (${resp.hit_count} hits): ${r.title}`);
272
+ if (r.status === "done") {
273
+ console.log(` ⚠ this is marked DONE${r.resolved_version ? ` in ${r.resolved_version}` : ""}. Update first (npm i -g @flowapt/flowiq-cli@latest); if it still fails, say so with: flowiq gaps note ${r.number} "still fails on <version>: …"`);
274
+ } else if (r.status !== "open") {
275
+ console.log(` status: ${r.status}`);
276
+ }
277
+ }
278
+
279
+ // ── note ────────────────────────────────────────────────────────────────────
280
+ export async function note(ref, textWords, opts = {}) {
281
+ const r0 = requireRef(ref);
282
+ const text = (Array.isArray(textWords) ? textWords.join(" ") : String(textWords || "")).trim();
283
+ if (!text) {
284
+ console.error("Error: give the note text, e.g. flowiq gaps note 42 \"also happens with --agent\"");
285
+ process.exit(1);
286
+ }
287
+ let resp;
288
+ try {
289
+ resp = await http.post("gaps", { action: "note", ref: r0, body: text });
290
+ } catch (e) {
291
+ fail("Note failed", e);
292
+ }
293
+ if (opts.json) {
294
+ console.log(JSON.stringify(resp, null, 2));
295
+ return;
296
+ }
297
+ console.log(`Noted on #${resp.request.number}: ${resp.request.title}`);
298
+ }
299
+
300
+ // ── status ──────────────────────────────────────────────────────────────────
301
+ export async function status(ref, opts = {}) {
302
+ const r0 = requireRef(ref);
303
+ const to = String(opts.to || "").toLowerCase();
304
+ if (!STATUSES.includes(to)) {
305
+ console.error(`Error: --to must be one of ${STATUSES.join(" | ")} (got "${opts.to ?? ""}").`);
306
+ process.exit(1);
307
+ }
308
+ if (["done", "declined"].includes(to) && !(opts.note && opts.note.trim())) {
309
+ console.error(`Error: --note is required for ${to}. The people who asked get it by email: what shipped, or why not.`);
310
+ process.exit(1);
311
+ }
312
+ let dupRef = null;
313
+ if (to === "duplicate") {
314
+ dupRef = normaliseRef(opts.of);
315
+ if (!dupRef) {
316
+ console.error("Error: --to duplicate needs --of <number>: the request this one duplicates.");
317
+ process.exit(1);
318
+ }
319
+ }
320
+ let resp;
321
+ try {
322
+ resp = await http.post("gaps", {
323
+ action: "status",
324
+ ref: r0,
325
+ status: to,
326
+ note: opts.note ?? null,
327
+ version: opts.version ?? null,
328
+ duplicate_of: dupRef,
329
+ notify: opts.notify !== false,
330
+ });
331
+ } catch (e) {
332
+ fail("Status change failed", e);
333
+ }
334
+ if (opts.json) {
335
+ console.log(JSON.stringify(resp, null, 2));
336
+ return;
337
+ }
338
+ const r = resp.request;
339
+ console.log(`#${r.number} ${resp.previous_status} → ${r.status}${r.resolved_version ? ` (${r.resolved_version})` : ""}: ${r.title}`);
340
+ if (resp.duplicate_of) console.log(` merged into #${resp.duplicate_of.number} "${resp.duplicate_of.title}" (hits carried over)`);
341
+ const n = resp.notified || {};
342
+ if (n.skipped) return;
343
+ if (n.sent?.length) console.log(` emailed: ${n.sent.join(", ")}`);
344
+ else console.log(" nobody else to email (you were the only reporter)");
345
+ for (const f of n.failed || []) console.log(` ⚠ email to ${f.to} failed: ${f.error}`);
346
+ }
@@ -37,6 +37,32 @@ async function resolvePushPath(identifier) {
37
37
  throw new Error(`File not found. Tried:\n ${candidates.join("\n ")}`);
38
38
  }
39
39
 
40
+ const sastDay = (iso) => {
41
+ try { return new Date(iso).toLocaleString("sv-SE", { timeZone: "Africa/Johannesburg" }).slice(0, 16); }
42
+ catch { return String(iso).slice(0, 16); }
43
+ };
44
+
45
+ // The keyword-scheduler cron (every 30 min) flips `active` on at start_date and
46
+ // off at end_date; the runtime itself reads `active` only. So an ACTIVE row with
47
+ // a future start is answering already, and an active row past its end has not
48
+ // been switched off yet.
49
+ export function scheduleLabel(k, now = Date.now()) {
50
+ const start = k.start_date ? Date.parse(k.start_date) : null;
51
+ const end = k.end_date ? Date.parse(k.end_date) : null;
52
+ const parts = [];
53
+ if (start && start > now) {
54
+ parts.push(k.active === false
55
+ ? ` · starts ${sastDay(k.start_date)} SAST`
56
+ : ` · ⚠ LIVE BEFORE ITS START (${sastDay(k.start_date)} SAST): active, so it answers now`);
57
+ }
58
+ if (end) {
59
+ parts.push(end <= now && k.active !== false
60
+ ? ` · ⚠ PAST ITS END (${sastDay(k.end_date)} SAST) but still active`
61
+ : ` · ends ${sastDay(k.end_date)}${end > now ? " SAST" : ""}`);
62
+ }
63
+ return parts.join("");
64
+ }
65
+
40
66
  export async function pull(orgId) {
41
67
  if (!UUID_RE.test(orgId)) {
42
68
  console.error(`Error: "${orgId}" is not a valid organization UUID.`);
@@ -59,9 +85,24 @@ export async function pull(orgId) {
59
85
  console.log(`${overwriting ? "Overwrote" : "Wrote"} ${filePath}`);
60
86
  console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
61
87
  console.log(` keywords: ${resp.keyword_count}`);
62
- for (const k of resp.keywords || []) {
88
+ // Active rows first, in the order the runtime tries them (first match wins),
89
+ // then the inactive ones.
90
+ const all = resp.keywords || [];
91
+ const order = resp.runtime_order || [];
92
+ const byId = new Map(all.map((k) => [k.id, k]));
93
+ const listed = [
94
+ ...order.map((id) => byId.get(id)).filter(Boolean),
95
+ ...all.filter((k) => !order.includes(k.id)),
96
+ ];
97
+ if (order.length) console.log(` (active rows in runtime order: the first matching row answers)`);
98
+ for (const k of listed) {
63
99
  const acts = (k.keyword_actions || []).length;
64
- console.log(` ${String(k.type).padEnd(28)} [${(k.keywords || []).join(", ")}] · ${acts} action(s)${k.active === false ? " · INACTIVE" : ""}${k.end_date ? ` · ends ${k.end_date.slice(0, 10)}` : ""}`);
100
+ console.log(` ${String(k.type).padEnd(28)} [${(k.keywords || []).join(", ")}] · ${acts} action(s)${k.active === false ? " · INACTIVE" : ""}${scheduleLabel(k)}`);
101
+ }
102
+ for (const c of resp.collisions || []) {
103
+ console.log("");
104
+ console.log(` ⚠ TRIGGER COLLISION: ${c.triggers.map((t) => `"${t}"`).join(", ")} matches both "${c.winner.type}" and "${c.shadowed.type}".`);
105
+ console.log(` ${c.note}.`);
65
106
  }
66
107
  if (resp.has_attribute_actions) {
67
108
  console.log("");
@@ -115,6 +115,7 @@ export async function shorten(orgId, opts = {}) {
115
115
  content,
116
116
  domain: opts.domain,
117
117
  dry_run: dryRun,
118
+ confirm_new_campaign: !!opts.newCampaign,
118
119
  });
119
120
  } catch (e) {
120
121
  console.error(`Shorten failed: ${e.message}`);
@@ -128,6 +129,13 @@ export async function shorten(orgId, opts = {}) {
128
129
  : "no UTM tags (plain short link)";
129
130
  console.log(`${resp.organization_name} · ${resp.domain}`);
130
131
  console.log(`${utm}\n`);
132
+ if (resp.campaign_conflicts?.length) {
133
+ const c = resp.campaign_conflicts[0];
134
+ console.log(`⚠ CAMPAIGN FORK: this org already runs ${resp.campaign_conflicts.map((x) => `"${x.campaign}" (${x.links} link${x.links === 1 ? "" : "s"})`).join(", ")},`);
135
+ console.log(` the same campaign as "${resp.utm?.campaign}" spelled differently. The Broadcasts tab would show two rows.`);
136
+ console.log(` Reuse it: --campaign ${c.campaign} --raw-campaign or confirm a separate campaign: --new-campaign`);
137
+ console.log("");
138
+ }
131
139
 
132
140
  for (const r of resp.results) {
133
141
  const label = {
@@ -5,6 +5,7 @@
5
5
 
6
6
  import fs from "node:fs/promises";
7
7
  import path from "node:path";
8
+ import { randomUUID } from "node:crypto";
8
9
  import { http } from "../http.js";
9
10
  import { fmtSast } from "./audit.js";
10
11
 
@@ -54,15 +55,16 @@ export async function pull(orgId, opts = {}) {
54
55
  process.exit(1);
55
56
  }
56
57
 
57
- await fs.mkdir(PROMPTS_DIR, { recursive: true });
58
58
  // Non-active agents get an agent-suffixed filename so pulling multiple agents
59
59
  // from the same org doesn't clobber each other's working copy. The active
60
- // agent keeps the plain <org>.json name (backwards-compatible).
60
+ // agent keeps the plain <org>.json name (backwards-compatible). --out writes
61
+ // anywhere else (a scratch copy that leaves the working file alone).
61
62
  const orgSlug = slugify(resp.organization_name, resp.organization_id);
62
63
  const slug = resp.is_active === false
63
64
  ? `${orgSlug}-${slugify(resp.agent_name, resp.agent_id.slice(0, 8))}`
64
65
  : orgSlug;
65
- const filePath = path.join(PROMPTS_DIR, `${slug}.json`);
66
+ const filePath = opts.out ? path.resolve(opts.out) : path.join(PROMPTS_DIR, `${slug}.json`);
67
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
66
68
  const overwriting = await fileExists(filePath);
67
69
  await fs.writeFile(filePath, JSON.stringify(resp, null, 2) + "\n", "utf8");
68
70
 
@@ -82,6 +84,18 @@ export async function pull(orgId, opts = {}) {
82
84
  }
83
85
  }
84
86
 
87
+ /** Give every section without an id a fresh uuid. Returns the titles it touched. */
88
+ export function mintSectionIds(sections) {
89
+ const touched = [];
90
+ for (const sec of sections) {
91
+ if (sec && typeof sec === "object" && (typeof sec.id !== "string" || !sec.id.trim())) {
92
+ sec.id = randomUUID();
93
+ touched.push(sec.title || "(untitled)");
94
+ }
95
+ }
96
+ return touched;
97
+ }
98
+
85
99
  export async function push(identifier) {
86
100
  let filePath;
87
101
  try {
@@ -107,6 +121,14 @@ export async function push(identifier) {
107
121
  process.exit(1);
108
122
  }
109
123
 
124
+ // A section added by hand has no id yet. Mint one and write it back to the
125
+ // file BEFORE pushing, so a later pull or push keeps the same id.
126
+ const minted = mintSectionIds(data.prompt_sections);
127
+ if (minted.length) {
128
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2) + "\n", "utf8");
129
+ console.log(`Added an id to ${minted.length} new section(s) in ${filePath}: ${minted.map((t) => `"${t}"`).join(", ")}`);
130
+ }
131
+
110
132
  let resp;
111
133
  try {
112
134
  resp = await http.post("prompts", {
@@ -265,6 +265,45 @@ async function rawGet(orgId, path, query, { label } = {}) {
265
265
  return resp.data;
266
266
  }
267
267
 
268
+ // Shopify's own product search (title, SKU, vendor, tags…), so a partial title
269
+ // on a 3,594-product catalogue is found without scanning pages. Anything
270
+ // Shopify's search syntax takes works here too: "sku:75461", "handle:foo".
271
+ const STOCK_SEARCH_GQL = `query StockSearch($q: String!) {
272
+ products(first: 50, query: $q) {
273
+ edges { node {
274
+ legacyResourceId title status
275
+ variants(first: 100) { edges { node {
276
+ legacyResourceId title sku price inventoryPolicy inventoryQuantity
277
+ inventoryItem { legacyResourceId }
278
+ } } }
279
+ } }
280
+ }
281
+ }`;
282
+
283
+ async function searchProducts(orgId, search) {
284
+ console.error(` $ flowiq shopify gql <org> --query '${STOCK_SEARCH_GQL.replace(/\s+/g, " ")}' --var q=${JSON.stringify(search)} # Shopify product search`);
285
+ const resp = await http.post("store-api", {
286
+ platform: "shopify", organization_id: orgId, mode: "graphql",
287
+ graphql: { query: STOCK_SEARCH_GQL, variables: { q: search } },
288
+ });
289
+ if (resp.graphql_errors?.length) throw new Error(`Shopify search failed: ${resp.graphql_errors.map((e) => e.message).join("; ")}`);
290
+ // Map to the REST shape the rest of the recipe reads.
291
+ return (resp.data?.products?.edges || []).map(({ node: p }) => ({
292
+ id: Number(p.legacyResourceId),
293
+ title: p.title,
294
+ status: String(p.status || "").toLowerCase(),
295
+ variants: (p.variants?.edges || []).map(({ node: v }) => ({
296
+ id: Number(v.legacyResourceId),
297
+ title: v.title,
298
+ sku: v.sku,
299
+ price: v.price,
300
+ inventory_policy: String(v.inventoryPolicy || "").toLowerCase(),
301
+ inventory_quantity: v.inventoryQuantity,
302
+ inventory_item_id: v.inventoryItem?.legacyResourceId ? Number(v.inventoryItem.legacyResourceId) : null,
303
+ })),
304
+ }));
305
+ }
306
+
268
307
  export async function shopifyStock(orgId, search, opts = {}) {
269
308
  requireOrg(orgId);
270
309
  if (!search || !String(search).trim()) {
@@ -278,17 +317,22 @@ export async function shopifyStock(orgId, search, opts = {}) {
278
317
  const p = await rawGet(orgId, "products.json", { title: search, limit: 50 }, { label: "find the product" });
279
318
  products = (p?.products || []).filter((x) =>
280
319
  String(x.title).toLowerCase().includes(String(search).toLowerCase()));
320
+ let fromSearch = false;
281
321
  if (!products.length) {
282
- const all = await rawGet(orgId, "products.json", { limit: 250, fields: "id,title,variants,status" },
283
- { label: "no title match — scanning the catalogue" });
284
- products = (all?.products || []).filter((x) =>
285
- String(x.title).toLowerCase().includes(String(search).toLowerCase()));
322
+ // No exact title: ask Shopify's search, then keep the titles holding every
323
+ // word of the search (or, when none do, what Shopify ranked closest).
324
+ const found = await searchProducts(orgId, String(search));
325
+ const words = String(search).toLowerCase().split(/\s+/).filter((w) => w && !w.includes(":"));
326
+ const strict = found.filter((x) => words.every((w) => String(x.title).toLowerCase().includes(w)));
327
+ products = strict.length ? strict : found;
328
+ fromSearch = products.length > 0 && !strict.length && words.length > 0;
286
329
  }
287
330
  if (!products.length) {
288
- console.error(`\nNothing matched "${search}". This searches product TITLES only — ` +
289
- `the passthrough can query anything else Shopify exposes.`);
331
+ console.error(`\nShopify's product search found nothing for "${search}" (it searches the whole catalogue: ` +
332
+ `title, SKU, vendor, tags). Try fewer words, or "sku:<sku>" / "handle:<handle>".`);
290
333
  process.exit(1);
291
334
  }
335
+ if (fromSearch) console.error(` (no title holds every word of "${search}"; showing Shopify's closest matches)`);
292
336
  const itemIds = products.flatMap((x) => (x.variants || []).map((v) => v.inventory_item_id)).filter(Boolean);
293
337
  // 2. inventory_item_id → per-location levels 3. location_id → names
294
338
  levels = itemIds.length
@@ -335,6 +379,9 @@ export async function shopifyStock(orgId, search, opts = {}) {
335
379
  }
336
380
  console.error("");
337
381
  console.log(renderTable(rows) ?? "(no variants)");
382
+ if (products.flatMap((x) => x.variants || []).length > 50) {
383
+ console.error(`\n⚠ per-location levels cover the first 50 variants only; narrow the search for the rest.`);
384
+ }
338
385
  console.error(`\n${products.length} product(s) · ${rows.length} variant(s). ` +
339
386
  `"not tracked" means Shopify holds no inventory record at that location — it is not a zero.`);
340
387
  }
@@ -91,6 +91,10 @@ export async function create(orgId, opts = {}) {
91
91
  } catch (e) {
92
92
  console.error(`Create failed: ${e.message}`);
93
93
  if (e.body) console.error(JSON.stringify(e.body, null, 2));
94
+ if (e.status === 504 || /TIMEOUT/i.test(e.message)) {
95
+ console.error(`\n⚠ The template may already be created at Meta (the upload can outlive the proxy). Do NOT retry yet; check first:`);
96
+ console.error(` flowiq templates status ${organizationId} --live --name ${templateRequest.name}`);
97
+ }
94
98
  process.exit(1);
95
99
  }
96
100
 
@@ -103,6 +107,8 @@ export async function status(orgId, opts = {}) {
103
107
  console.error(`Error: "${orgId}" is not a valid organization UUID.`);
104
108
  process.exit(1);
105
109
  }
110
+ if (opts.watch && !opts.live) opts.live = true;
111
+ if (opts.live) return liveStatus(orgId, opts);
106
112
  let resp;
107
113
  try {
108
114
  resp = await http.get("meta-templates", { organization_id: orgId, name: opts.name });
@@ -112,18 +118,55 @@ export async function status(orgId, opts = {}) {
112
118
  }
113
119
  const rows = resp.templates || [];
114
120
  if (!rows.length) {
115
- console.log(opts.name ? `(no templates matching "${opts.name}")` : "(no templates on record for this org)");
121
+ // An empty FlowIQ table is not "no templates": templates made in Meta's
122
+ // own tools, or on a swapped WABA, exist only at Meta (Health Matrix, 22 Sep).
123
+ console.log(opts.name
124
+ ? `No template matching "${opts.name}" is recorded in FlowIQ. Meta may still hold one: flowiq templates status ${orgId} --live --name ${opts.name}`
125
+ : `No templates are recorded in FlowIQ for this org. Meta may still hold some: flowiq templates status ${orgId} --live (or templates pull ${orgId}).`);
116
126
  return;
117
127
  }
118
- console.log(`${rows.length} template row${rows.length === 1 ? "" : "s"}${opts.name ? ` matching "${opts.name}"` : ""}:`);
128
+ console.log(`${rows.length} template row${rows.length === 1 ? "" : "s"}${opts.name ? ` matching "${opts.name}"` : ""} recorded in FlowIQ:`);
119
129
  for (const t of rows) {
120
130
  const name = t.template_name || t.name || "(unnamed)";
121
- const st = t.status || "(no status)";
131
+ // meta_status is what Meta's status webhook last reported; `status` is often
132
+ // left at its submit-time value.
133
+ const st = t.meta_status || t.status || "(no status)";
122
134
  const cat = t.category ? ` [${t.category}]` : "";
123
135
  console.log(` ${name.padEnd(40)} ${String(st).padEnd(16)}${cat}`);
124
136
  }
125
- console.log("\nNote: status is captured at submit time (older rows may be null). For the live");
126
- console.log("Meta-side status + rejection reasons, run `templates pull <org_id>`.");
137
+ console.log(`\nThis is FlowIQ's record (Meta's webhook keeps it current). Straight from Meta, with rejection reasons:`);
138
+ console.log(` flowiq templates status ${orgId} --live${opts.name ? ` --name ${opts.name}` : ""} [--watch]`);
139
+ }
140
+
141
+ const TERMINAL = new Set(["APPROVED", "REJECTED", "DISABLED", "PAUSED", "DELETED", "LIMIT_EXCEEDED"]);
142
+
143
+ async function liveStatus(orgId, opts) {
144
+ const intervalMs = 15000, maxMs = 15 * 60 * 1000, started = Date.now();
145
+ for (;;) {
146
+ let resp;
147
+ try {
148
+ resp = await http.get("templates", { organization_id: orgId, name: opts.name });
149
+ } catch (e) {
150
+ console.error(`Live status failed: ${e.message}`);
151
+ process.exit(1);
152
+ }
153
+ const rows = resp.templates || [];
154
+ const stamp = new Date().toLocaleTimeString("en-GB", { timeZone: "Africa/Johannesburg" });
155
+ if (!rows.length) {
156
+ console.log(`${stamp} Meta holds no template${opts.name ? ` matching "${opts.name}"` : "s"} on ${resp.organization_name}'s WABA.`);
157
+ } else {
158
+ console.log(`${stamp} ${rows.length} template${rows.length === 1 ? "" : "s"} at Meta (${resp.organization_name})${opts.name ? ` matching "${opts.name}"` : ""}:`);
159
+ for (const t of rows) {
160
+ const moved = t.previous_category && t.previous_category !== t.category ? ` (was ${t.previous_category})` : "";
161
+ const why = t.rejected_reason && t.rejected_reason !== "NONE" ? ` reason: ${t.rejected_reason}` : "";
162
+ console.log(` ${String(t.name).padEnd(40)} ${String(t.status).padEnd(16)} [${t.category}${moved}] ${t.language}${t.parameter_format ? ` · ${t.parameter_format}` : ""}${why}`);
163
+ }
164
+ }
165
+ const pending = rows.filter((t) => !TERMINAL.has(String(t.status)));
166
+ if (!opts.watch || (rows.length && !pending.length)) return;
167
+ if (Date.now() - started > maxMs) { console.log(`Still not decided after 15 min; stopped watching.`); return; }
168
+ await new Promise((r) => setTimeout(r, intervalMs));
169
+ }
127
170
  }
128
171
 
129
172
  export async function list() {