@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.
- package/README.md +152 -10
- package/TEAM-GUIDE.md +28 -9
- package/package.json +1 -1
- package/src/agent-config.test.mjs +39 -0
- package/src/batch-a.test.mjs +36 -0
- package/src/commands/agent-config.js +141 -9
- package/src/commands/agent-test.js +23 -9
- package/src/commands/agent-updates.js +4 -3
- package/src/commands/audit.js +8 -1
- package/src/commands/broadcast.js +18 -2
- package/src/commands/doctor.js +13 -4
- package/src/commands/gaps.js +346 -0
- package/src/commands/keywords.js +43 -2
- package/src/commands/links.js +8 -0
- package/src/commands/prompts.js +25 -3
- package/src/commands/store-api.js +53 -6
- package/src/commands/templates.js +48 -5
- package/src/gaps.test.mjs +48 -0
- package/src/http.js +34 -8
- package/src/index.js +107 -9
|
@@ -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
|
+
}
|
package/src/commands/keywords.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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("");
|
package/src/commands/links.js
CHANGED
|
@@ -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 = {
|
package/src/commands/prompts.js
CHANGED
|
@@ -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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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(`\
|
|
289
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
126
|
-
console.log(
|
|
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() {
|