@flowapt/flowiq-cli 0.6.7 → 0.7.0
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 +235 -26
- package/TEAM-GUIDE.md +50 -3
- package/package.json +4 -2
- package/src/campaign-naming.js +164 -0
- package/src/campaign-naming.test.mjs +76 -0
- package/src/commands/doctor.js +150 -0
- package/src/commands/insights.js +201 -0
- package/src/commands/links.js +50 -4
- package/src/commands/org-flags.js +151 -0
- package/src/commands/templates.js +201 -0
- package/src/flag-values.js +42 -0
- package/src/flag-values.test.mjs +51 -0
- package/src/http.js +38 -0
- package/src/index.js +98 -4
- package/src/insights-config.js +69 -0
- package/src/insights-config.test.mjs +51 -0
- package/src/update-check.js +37 -3
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { resolveCampaign, resolveContent, dateToken, parseDateToken, isConforming, pascal } from "./campaign-naming.js";
|
|
4
|
+
|
|
5
|
+
const NOW = new Date(2026, 8, 9); // 9 Sep 2026, local
|
|
6
|
+
|
|
7
|
+
test("Matt's worked example", () => {
|
|
8
|
+
const r = resolveCampaign("Spring Promotion", { now: NOW });
|
|
9
|
+
assert.equal(r.value, "9Sep_SpringPromotion");
|
|
10
|
+
assert.ok(r.changed);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("already correct is left alone and reports no change", () => {
|
|
14
|
+
const r = resolveCampaign("9Sep_SpringPromotion", { now: NOW });
|
|
15
|
+
assert.equal(r.value, "9Sep_SpringPromotion");
|
|
16
|
+
assert.equal(r.changed, false);
|
|
17
|
+
assert.deepEqual(r.notes, []);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("the house habit: fused date, no underscore (924 tags in 12 months)", () => {
|
|
21
|
+
assert.equal(resolveCampaign("10JunFathersDay", { now: NOW }).value, "10Jun_FathersDay");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("Sept is corrected to Sep, and the date is kept", () => {
|
|
25
|
+
const r = resolveCampaign("5Sept_BraaiDayOffer", { now: NOW });
|
|
26
|
+
assert.equal(r.value, "5Sep_BraaiDayOffer");
|
|
27
|
+
assert.ok(r.notes.some((n) => n.includes("Sept")));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("leading zero is dropped", () => {
|
|
31
|
+
assert.equal(resolveCampaign("09Sep_Foo", { now: NOW }).value, "9Sep_Foo");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("--date overrides a send scheduled for another day", () => {
|
|
35
|
+
assert.equal(resolveCampaign("Heritage Day", { date: "2026-09-24", now: NOW }).value, "24Sep_HeritageDay");
|
|
36
|
+
assert.equal(resolveCampaign("24Sep_HeritageDay", { date: "25Sep", now: NOW }).value, "25Sep_HeritageDay");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("--date accepts SA d/m/yyyy and long month names", () => {
|
|
40
|
+
assert.equal(parseDateToken("24/09/2026"), "24Sep");
|
|
41
|
+
assert.equal(parseDateToken("24 September"), "24Sep");
|
|
42
|
+
assert.equal(parseDateToken("2026-09-24"), "24Sep");
|
|
43
|
+
assert.equal(parseDateToken("banana"), null);
|
|
44
|
+
assert.equal(parseDateToken("24/13/2026"), null);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("separators and casing all collapse to PascalCase", () => {
|
|
48
|
+
assert.equal(pascal("spring-promotion"), "SpringPromotion");
|
|
49
|
+
assert.equal(pascal("spring_promotion"), "SpringPromotion");
|
|
50
|
+
assert.equal(pascal(" spring promotion "), "SpringPromotion");
|
|
51
|
+
assert.equal(pascal("R500 promo"), "R500Promo");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("an acronym keeps its case", () => {
|
|
55
|
+
assert.equal(resolveCampaign("CTWA Launch", { now: NOW }).value, "9Sep_CTWALaunch");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("empty and title-less inputs error rather than minting rubbish", () => {
|
|
59
|
+
assert.ok(resolveCampaign("", { now: NOW }).error);
|
|
60
|
+
assert.ok(resolveCampaign("9Sep", { now: NOW }).error);
|
|
61
|
+
assert.ok(resolveCampaign("Spring", { date: "nonsense", now: NOW }).error);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("utm_content is per-link and gets no date", () => {
|
|
65
|
+
assert.equal(resolveContent("View More").value, "ViewMore");
|
|
66
|
+
assert.equal(resolveContent("").value, "");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("dateToken / isConforming", () => {
|
|
70
|
+
assert.equal(dateToken(NOW), "9Sep");
|
|
71
|
+
assert.equal(dateToken(new Date(2026, 11, 25)), "25Dec");
|
|
72
|
+
assert.ok(isConforming("9Sep_SpringPromotion"));
|
|
73
|
+
assert.ok(!isConforming("9Sept_SpringPromotion"));
|
|
74
|
+
assert.ok(!isConforming("SpringPromotion"));
|
|
75
|
+
assert.ok(!isConforming("9Sep_Spring_Promotion"));
|
|
76
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// `flowiq doctor` — one command that answers "is my CLI healthy and current?"
|
|
2
|
+
//
|
|
3
|
+
// Written because the honest answer used to need three commands and some
|
|
4
|
+
// knowledge: `flowiq --version` (installed), `npm view` (latest), and nothing
|
|
5
|
+
// at all for "does the SERVER still speak my dialect?". A teammate — or a
|
|
6
|
+
// teammate's AI assistant — needs one place that says what is wrong and the
|
|
7
|
+
// exact command to fix it.
|
|
8
|
+
//
|
|
9
|
+
// Read-only, no org needed, safe to run any time. Exit code 1 when something
|
|
10
|
+
// actionable is wrong, so it can gate a script.
|
|
11
|
+
|
|
12
|
+
import { http, serverContract, cliVersion } from "../http.js";
|
|
13
|
+
import { cachedLatest, fetchLatestNow, cmpVersion } from "../update-check.js";
|
|
14
|
+
import { loadConfig, configDir } from "../config.js";
|
|
15
|
+
|
|
16
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
17
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
18
|
+
const amber = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
19
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
20
|
+
|
|
21
|
+
const OK = green("✓");
|
|
22
|
+
const WARN = amber("⚠");
|
|
23
|
+
const BAD = red("✗");
|
|
24
|
+
|
|
25
|
+
function ageLabel(ts) {
|
|
26
|
+
if (!ts) return "never";
|
|
27
|
+
const h = (Date.now() - ts) / 3600000;
|
|
28
|
+
if (h < 1) return `${Math.round(h * 60)} min ago`;
|
|
29
|
+
if (h < 48) return `${h.toFixed(1)} h ago`;
|
|
30
|
+
return `${Math.round(h / 24)} days ago`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function doctor(opts = {}) {
|
|
34
|
+
const installed = cliVersion();
|
|
35
|
+
const problems = [];
|
|
36
|
+
const out = {
|
|
37
|
+
cli_version: installed,
|
|
38
|
+
npm_latest: null,
|
|
39
|
+
server_min_version: null,
|
|
40
|
+
api_build: null,
|
|
41
|
+
api_url: null,
|
|
42
|
+
authenticated: false,
|
|
43
|
+
user: null,
|
|
44
|
+
problems: [],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const lines = [];
|
|
48
|
+
lines.push(`flowiq doctor`);
|
|
49
|
+
lines.push("");
|
|
50
|
+
|
|
51
|
+
// ── 1. installed vs npm latest ────────────────────────────────────────────
|
|
52
|
+
// Ask the registry LIVE here (with a short timeout). The background cache is
|
|
53
|
+
// refreshed at most once a day, so on the day of a release it is stale — which
|
|
54
|
+
// is precisely when someone runs doctor.
|
|
55
|
+
const live = await fetchLatestNow(3000);
|
|
56
|
+
const cached = cachedLatest();
|
|
57
|
+
const latest = live || cached.latest;
|
|
58
|
+
out.npm_latest = latest;
|
|
59
|
+
|
|
60
|
+
if (!latest) {
|
|
61
|
+
lines.push(` ${WARN} version installed ${installed} · could not reach npm ${dim("(offline?)")}`);
|
|
62
|
+
} else if (cmpVersion(latest, installed) > 0) {
|
|
63
|
+
lines.push(` ${BAD} version installed ${installed} · latest ${latest}`);
|
|
64
|
+
problems.push({
|
|
65
|
+
what: `CLI is out of date (${installed} → ${latest})`,
|
|
66
|
+
fix: "npm i -g @flowapt/flowiq-cli@latest",
|
|
67
|
+
});
|
|
68
|
+
} else if (cmpVersion(installed, latest) > 0) {
|
|
69
|
+
// A dev machine, a worktree build, or a version bumped but not yet
|
|
70
|
+
// published. Saying "(latest)" here would be a lie, and it is exactly the
|
|
71
|
+
// state in which someone wonders why teammates do not have their feature.
|
|
72
|
+
lines.push(` ${WARN} version installed ${installed} is AHEAD of npm (${latest}) ${dim("— unpublished build")}`);
|
|
73
|
+
} else {
|
|
74
|
+
lines.push(` ${OK} version ${installed} ${dim(live ? "(latest)" : `(latest, cached ${ageLabel(cached.checkedAt)})`)}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── 2. config + auth ──────────────────────────────────────────────────────
|
|
78
|
+
let cfg = {};
|
|
79
|
+
try { cfg = await loadConfig(); } catch { /* handled below */ }
|
|
80
|
+
out.api_url = cfg.api_url || null;
|
|
81
|
+
lines.push(` ${cfg.api_url ? OK : BAD} api ${cfg.api_url || "not configured"} ${dim(configDir())}`);
|
|
82
|
+
if (!cfg.api_url) problems.push({ what: "no api_url configured", fix: "flowiq auth login" });
|
|
83
|
+
|
|
84
|
+
if (!cfg.token) {
|
|
85
|
+
lines.push(` ${BAD} auth not authenticated`);
|
|
86
|
+
problems.push({ what: "not authenticated", fix: "flowiq auth login" });
|
|
87
|
+
} else {
|
|
88
|
+
// whoami doubles as the live server probe — it is how the response headers
|
|
89
|
+
// carrying the version contract reach us at all.
|
|
90
|
+
try {
|
|
91
|
+
// `whoami` is also the live server probe: it is how the response headers
|
|
92
|
+
// carrying the version contract reach us at all.
|
|
93
|
+
const who = await http.get("whoami");
|
|
94
|
+
out.authenticated = true;
|
|
95
|
+
out.user = who?.user_email || null;
|
|
96
|
+
out.super_admin = who?.is_super_admin === true;
|
|
97
|
+
const role = who?.is_super_admin ? "super admin" : red("NOT a super admin");
|
|
98
|
+
lines.push(` ${OK} auth ${out.user || "authenticated"} ${dim(`· ${role}`)}`);
|
|
99
|
+
if (who && who.is_super_admin !== true) {
|
|
100
|
+
problems.push({ what: "this key is not a super-admin key — most commands will 403", fix: "ask Matt to re-issue the staff key" });
|
|
101
|
+
}
|
|
102
|
+
} catch (e) {
|
|
103
|
+
lines.push(` ${BAD} auth ${e.message.split("\n")[0]}`);
|
|
104
|
+
problems.push({
|
|
105
|
+
what: "the API rejected this key",
|
|
106
|
+
fix: e.status === 401 || e.status === 403 ? "flowiq auth login" : "check api_url / network, then retry",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── 3. the server's version contract ──────────────────────────────────────
|
|
112
|
+
out.server_min_version = serverContract.minVersion;
|
|
113
|
+
out.api_build = serverContract.apiBuild;
|
|
114
|
+
|
|
115
|
+
if (!serverContract.minVersion) {
|
|
116
|
+
lines.push(` ${WARN} server did not declare a minimum CLI version ${dim("(deploy predates the version contract)")}`);
|
|
117
|
+
} else if (cmpVersion(serverContract.minVersion, installed) > 0) {
|
|
118
|
+
lines.push(` ${BAD} server requires ${serverContract.minVersion}, you have ${installed}`);
|
|
119
|
+
problems.push({
|
|
120
|
+
what: `this CLI is older than the server's contract (needs ${serverContract.minVersion})`,
|
|
121
|
+
fix: "npm i -g @flowapt/flowiq-cli@latest",
|
|
122
|
+
});
|
|
123
|
+
} else {
|
|
124
|
+
lines.push(` ${OK} server contract ${serverContract.minVersion} or newer ${dim(`· build ${serverContract.apiBuild || "?"}`)}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The REVERSE skew: a CLI newer than the deployment it is talking to. Not an
|
|
128
|
+
// error — it is the normal state for the minutes between a push and the
|
|
129
|
+
// Vercel deploy — but it explains "the command exists and still doesn't work".
|
|
130
|
+
if (latest && serverContract.minVersion && cmpVersion(installed, latest) === 0 && cmpVersion(installed, serverContract.minVersion) > 0) {
|
|
131
|
+
lines.push(` ${dim("i server min is " + serverContract.minVersion + " while you run " + installed + " — fine; newest features need the matching deploy")}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
out.problems = problems;
|
|
135
|
+
if (opts.json) { console.log(JSON.stringify(out, null, 2)); return problems.length ? 1 : 0; }
|
|
136
|
+
|
|
137
|
+
console.log(lines.join("\n"));
|
|
138
|
+
console.log("");
|
|
139
|
+
if (!problems.length) {
|
|
140
|
+
console.log(green(" Everything checks out."));
|
|
141
|
+
} else {
|
|
142
|
+
console.log(red(` ${problems.length} thing${problems.length === 1 ? "" : "s"} to fix:`));
|
|
143
|
+
for (const p of problems) {
|
|
144
|
+
console.log(` • ${p.what}`);
|
|
145
|
+
console.log(` ${p.fix}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
console.log("");
|
|
149
|
+
return problems.length ? 1 : 0;
|
|
150
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// `flowiq insights …` — the Customer Insights Report from the terminal.
|
|
2
|
+
//
|
|
3
|
+
// flowiq insights status [org_id] [--off|--on] [--min-chats N] [--all] who has it, who does not, last report, chat volume
|
|
4
|
+
// flowiq insights enable <org_id> [--daily] [--weekly [day]] [--monthly [day]] [--report standard|advanced]
|
|
5
|
+
// [--store-only] [--recipient a@b.com …] [--dry-run] [--force]
|
|
6
|
+
// flowiq insights disable <org_id>
|
|
7
|
+
// flowiq insights run <org_id> [--period daily|weekly|monthly | --from D --to D] [--report …]
|
|
8
|
+
// [--recipient …] [--store-only] --commit
|
|
9
|
+
//
|
|
10
|
+
// `run` is DRY-RUN by default (it prints the exact engine, window and
|
|
11
|
+
// recipients) and fires only with --commit — it costs OpenAI tokens and can
|
|
12
|
+
// email a client. The server refuses the three ways an enabled org silently
|
|
13
|
+
// produces nothing (no OpenAI key, emailing with no recipients, an empty
|
|
14
|
+
// window); --force overrides all but an inactive org.
|
|
15
|
+
|
|
16
|
+
import { http } from "../http.js";
|
|
17
|
+
import { schedulesFromOpts, describeSchedule, sastRange } from "../insights-config.js";
|
|
18
|
+
|
|
19
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
20
|
+
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
21
|
+
const padL = (s, n) => String(s ?? "").padStart(n);
|
|
22
|
+
|
|
23
|
+
function requireUuid(orgId) {
|
|
24
|
+
if (!UUID_RE.test(orgId || "")) {
|
|
25
|
+
console.error(`Error: "${orgId}" is not a valid organization UUID (find it with \`flowiq org list <name>\`).`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function fmtWhen(iso) {
|
|
31
|
+
if (!iso) return "never";
|
|
32
|
+
const d = new Date(iso);
|
|
33
|
+
if (Number.isNaN(d.getTime())) return String(iso);
|
|
34
|
+
// SAST, minute precision
|
|
35
|
+
const s = new Date(d.getTime() + 2 * 3600 * 1000).toISOString();
|
|
36
|
+
return `${s.slice(0, 10)} ${s.slice(11, 16)}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function daysAgo(iso) {
|
|
40
|
+
if (!iso) return null;
|
|
41
|
+
return Math.floor((Date.now() - new Date(iso).getTime()) / 86400000);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function printProblems(body) {
|
|
45
|
+
for (const e of body.errors || []) console.error(` ✗ ${e}`);
|
|
46
|
+
for (const w of body.warnings || []) console.error(` ⚠ ${w}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fail(prefix, e) {
|
|
50
|
+
console.error(`${prefix}: ${e.message}`);
|
|
51
|
+
const b = e.body || {};
|
|
52
|
+
if (b.errors?.length || b.warnings?.length) printProblems(b);
|
|
53
|
+
if (b.needs_force) console.error(" Fix the cause, or re-run with --force to write anyway.");
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── status ───────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
export async function status(orgId, opts = {}) {
|
|
60
|
+
if (orgId) return statusOne(orgId, opts);
|
|
61
|
+
let resp;
|
|
62
|
+
try { resp = await http.get("insights", { status: "1", all: opts.all ? "1" : undefined }); } catch (e) { fail("Status failed", e); }
|
|
63
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
64
|
+
|
|
65
|
+
let orgs = resp.orgs || [];
|
|
66
|
+
const total = orgs.length;
|
|
67
|
+
const enabledTotal = orgs.filter((o) => o.enabled).length;
|
|
68
|
+
if (opts.on) orgs = orgs.filter((o) => o.enabled);
|
|
69
|
+
if (opts.off) orgs = orgs.filter((o) => !o.enabled);
|
|
70
|
+
const minChats = Number(opts.minChats ?? 0);
|
|
71
|
+
if (minChats > 0) orgs = orgs.filter((o) => o.contacts_30d >= minChats);
|
|
72
|
+
const sort = opts.sort || "chats";
|
|
73
|
+
orgs.sort((a, b) =>
|
|
74
|
+
sort === "name" ? a.name.localeCompare(b.name)
|
|
75
|
+
: sort === "last" ? (new Date(b.last_report?.at || 0) - new Date(a.last_report?.at || 0))
|
|
76
|
+
: (b.contacts_30d - a.contacts_30d) || a.name.localeCompare(b.name));
|
|
77
|
+
|
|
78
|
+
console.log(`Customer Insights Report — ${enabledTotal} of ${total} org(s) enabled${resp.include_inactive ? " (incl. inactive)" : ""}` +
|
|
79
|
+
`${opts.on ? " · showing ON" : opts.off ? " · showing OFF" : ""}${minChats ? ` · ≥${minChats} chatting contacts/30d` : ""}\n`);
|
|
80
|
+
if (!orgs.length) { console.log("(no orgs match the filter)"); return; }
|
|
81
|
+
|
|
82
|
+
const nameW = Math.min(28, Math.max(4, ...orgs.map((o) => o.name.length)));
|
|
83
|
+
console.log(`${pad("NAME", nameW)} ${pad("STATE", 5)} ${padL("CHATS30D", 8)} ${pad("KEY", 3)} ${pad("LAST REPORT", 18)} ${pad("RCPT", 4)} SCHEDULES`);
|
|
84
|
+
for (const o of orgs) {
|
|
85
|
+
const state = o.enabled ? "ON" : o.configured ? "off" : "—";
|
|
86
|
+
const last = o.last_report ? `${fmtWhen(o.last_report.at)}${o.last_report.store_only ? " s" : ""}` : "never";
|
|
87
|
+
const sched = o.enabled ? o.schedules_described.join(", ") + (o.schedules_source === "implicit-daily" ? " (implicit)" : "") : "";
|
|
88
|
+
console.log(
|
|
89
|
+
`${pad(o.name.slice(0, nameW), nameW)} ${pad(state, 5)} ${padL(o.contacts_30d, 8)} ${pad(o.has_openai_key ? "yes" : "NO", 3)} ${pad(last, 18)} ${padL(o.recipients.length, 4)} ${sched}${o.inactive ? " (inactive)" : ""}`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
console.log(`\n${orgs.length} org(s) shown · CHATS30D = distinct contacts who messaged in the last 30 days · KEY = has its own OpenAI key (required) · "s" = store-only`);
|
|
93
|
+
console.log("Enable one: flowiq insights enable <org_id> --weekly monday --recipient client@example.com");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function statusOne(orgId, opts = {}) {
|
|
97
|
+
requireUuid(orgId);
|
|
98
|
+
let resp;
|
|
99
|
+
try { resp = await http.get("insights", { organization_id: orgId }); } catch (e) { fail("Status failed", e); }
|
|
100
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
101
|
+
|
|
102
|
+
console.log(`${resp.name} (${resp.id})${resp.inactive ? " [INACTIVE]" : ""}`);
|
|
103
|
+
console.log(` insights: ${resp.enabled ? "ENABLED" : resp.configured ? "disabled (config kept)" : "never configured"}`);
|
|
104
|
+
if (resp.enabled || resp.configured) {
|
|
105
|
+
console.log(` schedules: ${resp.schedules_described.join(", ") || "(none)"}${resp.schedules_source === "implicit-daily" ? " ⚠ no schedules stored — the scheduler runs DAILY standard by default" : resp.schedules_source === "legacy" ? " (legacy single-config shape)" : ""}`);
|
|
106
|
+
console.log(` recipients: ${resp.recipients.length ? resp.recipients.join(", ") : `(none — emails would go to ${resp.engine_fallback_email})`}`);
|
|
107
|
+
}
|
|
108
|
+
console.log(` openai key: ${resp.has_openai_key ? "own key present" : "NONE — the engines refuse to run"}${resp.has_spare_openai_key ? " (+ spare)" : ""}`);
|
|
109
|
+
console.log(` studio config: ${resp.studio_configured_at ? `saved ${fmtWhen(resp.studio_configured_at)} (advanced engine)` : "none (advanced runs on engine defaults)"}`);
|
|
110
|
+
console.log(` chats (30d): ${resp.contacts_30d} contacts · ${resp.messages_30d} messages · last inbound ${fmtWhen(resp.last_inbound_at)}`);
|
|
111
|
+
console.log(` reports (30d): ${resp.reports_30d}`);
|
|
112
|
+
if (resp.reports?.length) {
|
|
113
|
+
console.log("\n Recent reports (SAST):");
|
|
114
|
+
for (const r of resp.reports) {
|
|
115
|
+
console.log(` ${fmtWhen(r.at)} ${pad(r.kind || "?", 8)} ${padL(r.contacts ?? "?", 5)} contacts ${padL(r.messages ?? "?", 6)} msgs ` +
|
|
116
|
+
`${r.store_only ? "stored only" : `${r.emails_sent ?? 0} email(s)${r.emails_failed ? `, ${r.emails_failed} failed` : ""}`}` +
|
|
117
|
+
`${r.recipients?.length && !r.store_only ? ` → ${r.recipients.join(", ")}` : ""}${r.source && r.source !== "edge" ? ` [${r.source}]` : ""}`);
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
console.log("\n No reports yet.");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── enable / disable ─────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
export async function enable(orgId, opts = {}) {
|
|
127
|
+
requireUuid(orgId);
|
|
128
|
+
let schedules;
|
|
129
|
+
try { schedules = schedulesFromOpts({ daily: opts.daily, weekly: opts.weekly, monthly: opts.monthly, report: opts.report, storeOnly: opts.storeOnly }); }
|
|
130
|
+
catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
|
|
131
|
+
if (!schedules && (opts.report || opts.storeOnly)) {
|
|
132
|
+
console.error("Error: --report / --store-only describe a schedule — add --daily, --weekly or --monthly to say which.");
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
const recipients = {};
|
|
136
|
+
if (opts.recipient?.length) recipients.replace = opts.recipient;
|
|
137
|
+
if (opts.addRecipient?.length) recipients.add = opts.addRecipient;
|
|
138
|
+
if (opts.removeRecipient?.length) recipients.remove = opts.removeRecipient;
|
|
139
|
+
|
|
140
|
+
const body = { organization_id: orgId, action: "enable", dry_run: opts.dryRun === true, force: opts.force === true };
|
|
141
|
+
if (schedules) body.schedules = schedules;
|
|
142
|
+
if (Object.keys(recipients).length) body.recipients = recipients;
|
|
143
|
+
|
|
144
|
+
let resp;
|
|
145
|
+
try { resp = await http.post("insights", body); } catch (e) { fail("Enable failed", e); }
|
|
146
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
147
|
+
|
|
148
|
+
const o = resp.organization;
|
|
149
|
+
const after = resp.after;
|
|
150
|
+
console.log(`${resp.dry_run ? "DRY RUN — " : ""}${o.name}: insights ${resp.before?.enabled ? "already on, config updated" : "ENABLED"}${resp.forced ? " (FORCED past pre-flight)" : ""}`);
|
|
151
|
+
console.log(` schedules: ${(after.schedules || []).map(describeSchedule).join(", ")} [${resp.schedules_source}]`);
|
|
152
|
+
console.log(` recipients: ${after.recipient_emails?.length ? after.recipient_emails.join(", ") : "(none)"}`);
|
|
153
|
+
printProblems(resp);
|
|
154
|
+
if (resp.dry_run) {
|
|
155
|
+
console.log(resp.would_write ? "\n Would write. Re-run without --dry-run." : "\n Would NOT write — fix the ✗ items (or --force).");
|
|
156
|
+
} else {
|
|
157
|
+
console.log("\n Next run: 09:00 SAST on the next matching day (cron 72). Fire one now: flowiq insights run " + orgId + " --commit");
|
|
158
|
+
console.log(" Undo: flowiq insights disable " + orgId);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function disable(orgId, opts = {}) {
|
|
163
|
+
requireUuid(orgId);
|
|
164
|
+
let resp;
|
|
165
|
+
try { resp = await http.post("insights", { organization_id: orgId, action: "disable" }); } catch (e) { fail("Disable failed", e); }
|
|
166
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
167
|
+
console.log(`${resp.organization.name}: insights ${resp.changed ? "DISABLED" : "already disabled"} (schedules + recipients kept; re-enable with \`flowiq insights enable\`)`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── run (Run Now) ────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
export async function run(orgId, opts = {}) {
|
|
173
|
+
requireUuid(orgId);
|
|
174
|
+
const body = { organization_id: orgId, action: "run", commit: opts.commit === true, force: opts.force === true, store_only: opts.storeOnly === true };
|
|
175
|
+
if (opts.from || opts.to) {
|
|
176
|
+
if (!opts.from || !opts.to) { console.error("Error: --from and --to go together (YYYY-MM-DD, SAST days)."); process.exit(1); }
|
|
177
|
+
try { const r = sastRange(opts.from, opts.to); body.start_date = r.start; body.end_date = r.end; }
|
|
178
|
+
catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
|
|
179
|
+
} else if (opts.period) {
|
|
180
|
+
if (!["daily", "weekly", "monthly"].includes(opts.period)) { console.error("Error: --period must be daily, weekly or monthly."); process.exit(1); }
|
|
181
|
+
body.period = opts.period;
|
|
182
|
+
}
|
|
183
|
+
if (opts.report) body.report_type = opts.report;
|
|
184
|
+
if (opts.recipient?.length) body.recipients = opts.recipient;
|
|
185
|
+
|
|
186
|
+
let resp;
|
|
187
|
+
try { resp = await http.post("insights", body); } catch (e) { fail("Run failed", e); }
|
|
188
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
189
|
+
|
|
190
|
+
const o = resp.organization;
|
|
191
|
+
console.log(`${resp.dry_run ? "DRY RUN — " : "STARTED — "}${o.name}: ${resp.period} ${resp.report_type} report via ${resp.engine}`);
|
|
192
|
+
console.log(` window: ${fmtWhen(resp.window.start)} → ${fmtWhen(resp.window.end)} SAST (${resp.messages_in_window ?? "?"} customer messages)`);
|
|
193
|
+
console.log(` delivery: ${resp.store_only ? "store only — no emails" : `email → ${resp.recipients.join(", ")}`}`);
|
|
194
|
+
printProblems(resp);
|
|
195
|
+
if (resp.dry_run) {
|
|
196
|
+
console.log(resp.would_run ? "\n Would run. Add --commit to fire it." : "\n Would NOT run — fix the ✗ items (or --force).");
|
|
197
|
+
} else {
|
|
198
|
+
console.log(`\n job ${resp.job_id || "?"} — python-render is working in the background (a few minutes for a large org).`);
|
|
199
|
+
console.log(` Check: flowiq insights status ${orgId} (the report appears under "Recent reports")`);
|
|
200
|
+
}
|
|
201
|
+
}
|
package/src/commands/links.js
CHANGED
|
@@ -5,16 +5,26 @@
|
|
|
5
5
|
// in the dialog: utm_source=whatsapp, utm_medium=whatsapp_paid fixed, campaign +
|
|
6
6
|
// content yours.
|
|
7
7
|
//
|
|
8
|
-
// flowiq links shorten <org> --url <u> [--url <u2>…] --campaign
|
|
9
|
-
// flowiq links shorten <org> --file ./message.txt --campaign X --content
|
|
8
|
+
// flowiq links shorten <org> --url <u> [--url <u2>…] --campaign "Spring Promotion"
|
|
9
|
+
// flowiq links shorten <org> --file ./message.txt --campaign X --content ViewMore --domain linklnk.io
|
|
10
10
|
// flowiq links list <org> [--campaign X] [--limit 25]
|
|
11
11
|
//
|
|
12
|
+
// NAMING — you do not have to get the tag right yourself. The house convention
|
|
13
|
+
// is `Date_Campaign` (`9Sep_SpringPromotion`: the date the broadcast GOES OUT,
|
|
14
|
+
// then the campaign title in PascalCase), and --campaign is normalised to it:
|
|
15
|
+
// today's date is prefixed when you omit one, `Sept`→`Sep`, a fused
|
|
16
|
+
// `10JunFathersDay` gains its underscore, and spaces/hyphens become PascalCase.
|
|
17
|
+
// The resolved tag is printed before anything is minted (shorten is dry-run by
|
|
18
|
+
// default). `--date <9Sep|2026-09-24|24/9/2026>` for a send going out later;
|
|
19
|
+
// `--raw-campaign` to bypass normalisation entirely.
|
|
20
|
+
//
|
|
12
21
|
// Dry-run FIRST (`--dry-run`) on anything going into a real campaign: it shows
|
|
13
22
|
// the exact destination URL each short link will carry, and whether an identical
|
|
14
23
|
// row already exists (so you can see you are about to reuse an OLD campaign tag).
|
|
15
24
|
|
|
16
25
|
import fs from "node:fs";
|
|
17
26
|
import { http } from "../http.js";
|
|
27
|
+
import { resolveCampaign, resolveContent, isConforming } from "../campaign-naming.js";
|
|
18
28
|
|
|
19
29
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
20
30
|
const URL_PATTERN = /(https?:\/\/[^\s\)]+)/g;
|
|
@@ -59,14 +69,50 @@ export async function shorten(orgId, opts = {}) {
|
|
|
59
69
|
|
|
60
70
|
const dryRun = opts.dryRun !== false && opts.commit !== true;
|
|
61
71
|
|
|
72
|
+
// ── Campaign naming (Date_Campaign) ───────────────────────────────────────
|
|
73
|
+
// Normalise what was typed into the house convention so staff do not have to
|
|
74
|
+
// carry it in their heads. --raw-campaign opts out; the agent's own `ai_<org>`
|
|
75
|
+
// links are minted server-side and never come through here.
|
|
76
|
+
let campaign = opts.campaign;
|
|
77
|
+
let content = opts.content;
|
|
78
|
+
const namingNotes = [];
|
|
79
|
+
if (campaign && !opts.rawCampaign) {
|
|
80
|
+
const r = resolveCampaign(campaign, { date: opts.date });
|
|
81
|
+
if (r.error) {
|
|
82
|
+
console.error(`Error: ${r.error}`);
|
|
83
|
+
console.error(`Convention is Date_Campaign, e.g. --campaign "Spring Promotion" → 9Sep_SpringPromotion.`);
|
|
84
|
+
console.error(`Pass --raw-campaign to use "${campaign}" exactly as typed.`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
if (r.changed) namingNotes.push(`campaign "${campaign}" → ${r.value}`, ...r.notes.map((n) => ` · ${n}`));
|
|
88
|
+
campaign = r.value;
|
|
89
|
+
} else if (campaign && opts.rawCampaign && !isConforming(campaign)) {
|
|
90
|
+
namingNotes.push(`campaign "${campaign}" used raw (--raw-campaign) — does not match Date_Campaign`);
|
|
91
|
+
}
|
|
92
|
+
// utm_content is per-LINK (which button / which product). Default it to the
|
|
93
|
+
// campaign, which is what the paired-flags API expects and what most sends do.
|
|
94
|
+
if (campaign && !content) {
|
|
95
|
+
content = campaign;
|
|
96
|
+
namingNotes.push(`content defaulted to ${content} (pass --content for a per-link tag, e.g. ViewMore)`);
|
|
97
|
+
} else if (content && !opts.rawCampaign) {
|
|
98
|
+
const rc = resolveContent(content);
|
|
99
|
+
if (rc.changed) namingNotes.push(`content "${content}" → ${rc.value}`);
|
|
100
|
+
content = rc.value;
|
|
101
|
+
}
|
|
102
|
+
if (namingNotes.length && !opts.json) {
|
|
103
|
+
console.log("Naming (Date_Campaign):");
|
|
104
|
+
for (const n of namingNotes) console.log(` ${n}`);
|
|
105
|
+
console.log("");
|
|
106
|
+
}
|
|
107
|
+
|
|
62
108
|
let resp;
|
|
63
109
|
try {
|
|
64
110
|
resp = await http.post("links", {
|
|
65
111
|
organization_id: orgId,
|
|
66
112
|
action: "shorten",
|
|
67
113
|
urls: unique,
|
|
68
|
-
campaign
|
|
69
|
-
content
|
|
114
|
+
campaign,
|
|
115
|
+
content,
|
|
70
116
|
domain: opts.domain,
|
|
71
117
|
dry_run: dryRun,
|
|
72
118
|
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// `flowiq org flags …` — an org's feature flags (the Settings → Profile
|
|
2
|
+
// switches) from the terminal.
|
|
3
|
+
//
|
|
4
|
+
// flowiq org flags show <org_id> what this org carries (secrets redacted)
|
|
5
|
+
// flowiq org flags list --key <path> [--all] which orgs carry ONE flag, and its value
|
|
6
|
+
// flowiq org flags set <org_id> <key> <value> [--yes] write a registered key (type-checked server-side)
|
|
7
|
+
// flowiq org flags unset <org_id> <key> --yes remove a key (revert to the platform default)
|
|
8
|
+
// flowiq org flags keys what is settable, by Settings section
|
|
9
|
+
//
|
|
10
|
+
// The server (api/cli/org-flags + _org-flags-registry) is the only validator:
|
|
11
|
+
// only keys the Profile tab manages can be set, credentials are never shown
|
|
12
|
+
// or written, dangerous keys need --yes.
|
|
13
|
+
|
|
14
|
+
import { http } from "../http.js";
|
|
15
|
+
import { parseFlagValue, fmtValue } from "../flag-values.js";
|
|
16
|
+
|
|
17
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
18
|
+
const pad = (s, n) => String(s ?? "").padEnd(n);
|
|
19
|
+
|
|
20
|
+
function requireUuid(orgId) {
|
|
21
|
+
if (!UUID_RE.test(orgId || "")) {
|
|
22
|
+
console.error(`Error: "${orgId}" is not a valid organization UUID (find it with \`flowiq org list <name>\`).`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fail(prefix, e) {
|
|
28
|
+
console.error(`${prefix}: ${e.message}`);
|
|
29
|
+
const b = e.body || {};
|
|
30
|
+
if (b.needs_confirm) {
|
|
31
|
+
if (b.danger) console.error(` ⚠ ${b.danger}`);
|
|
32
|
+
if (b.before !== undefined) console.error(` current: ${fmtValue(b.before)}`);
|
|
33
|
+
if (b.would_set !== undefined) console.error(` would set: ${fmtValue(b.would_set)}`);
|
|
34
|
+
console.error(" Re-run with --yes to confirm.");
|
|
35
|
+
} else if (b.settable === false) {
|
|
36
|
+
console.error(" `flowiq org flags keys` lists what can be set; `flowiq org flags show <org>` still READS it.");
|
|
37
|
+
}
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function keys(opts = {}) {
|
|
42
|
+
let resp;
|
|
43
|
+
try { resp = await http.get("org-flags", { keys: "1" }); } catch (e) { fail("Lookup failed", e); }
|
|
44
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
45
|
+
const rows = (resp.keys || []).filter((k) => !opts.section || k.section.toLowerCase().includes(opts.section.toLowerCase()));
|
|
46
|
+
const bySection = new Map();
|
|
47
|
+
for (const k of rows) {
|
|
48
|
+
if (!bySection.has(k.section)) bySection.set(k.section, []);
|
|
49
|
+
bySection.get(k.section).push(k);
|
|
50
|
+
}
|
|
51
|
+
console.log(`Settable feature flags (${rows.length}) — the Settings → Profile switches, by section.\n`);
|
|
52
|
+
for (const [section, list] of bySection) {
|
|
53
|
+
console.log(`${section}`);
|
|
54
|
+
for (const k of list) {
|
|
55
|
+
const type = k.type === "integer" && k.min !== undefined ? `integer ${k.min}-${k.max}` : k.type;
|
|
56
|
+
console.log(` ${pad(k.key, 46)} ${pad(type, 14)}${k.danger ? " ⚠ needs --yes" : ""}`);
|
|
57
|
+
if (k.note) console.log(` ${pad("", 46)} ${k.note}`);
|
|
58
|
+
}
|
|
59
|
+
console.log("");
|
|
60
|
+
}
|
|
61
|
+
console.log("Wildcards (`x.*`) take one more segment, e.g. integrations_visible.instagram.");
|
|
62
|
+
console.log("Anything else on an org is readable via `flowiq org flags show` but not settable here.");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function show(orgId, opts = {}) {
|
|
66
|
+
requireUuid(orgId);
|
|
67
|
+
let resp;
|
|
68
|
+
try { resp = await http.get("org-flags", { organization_id: orgId }); } catch (e) { fail("Lookup failed", e); }
|
|
69
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
70
|
+
|
|
71
|
+
const o = resp.organization;
|
|
72
|
+
console.log(`${o.name} (${o.id})${o.inactive ? " [INACTIVE]" : ""} — ${resp.key_count} top-level flag key(s)\n`);
|
|
73
|
+
const known = Object.entries(resp.known || {});
|
|
74
|
+
if (known.length) {
|
|
75
|
+
const bySection = new Map();
|
|
76
|
+
for (const [key, v] of known) {
|
|
77
|
+
if (!bySection.has(v.section)) bySection.set(v.section, []);
|
|
78
|
+
bySection.get(v.section).push([key, v.value]);
|
|
79
|
+
}
|
|
80
|
+
for (const [section, list] of bySection) {
|
|
81
|
+
console.log(section);
|
|
82
|
+
for (const [key, value] of list) console.log(` ${pad(key, 46)} ${fmtValue(value, 70)}`);
|
|
83
|
+
console.log("");
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
console.log("(none of the registered flags are set on this org)\n");
|
|
87
|
+
}
|
|
88
|
+
if (resp.unknown_keys?.length) {
|
|
89
|
+
console.log(`Other keys on this org (readable with --json, not settable from the CLI): ${resp.unknown_keys.join(", ")}`);
|
|
90
|
+
}
|
|
91
|
+
if (resp.secret_keys?.length) {
|
|
92
|
+
console.log(`Credentials present (never shown): ${resp.secret_keys.join(", ")}`);
|
|
93
|
+
}
|
|
94
|
+
console.log("\nChange one: flowiq org flags set <org_id> <key> <value> (see `flowiq org flags keys`)");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function list(opts = {}) {
|
|
98
|
+
if (!opts.key) { console.error("Error: --key <path> is required (e.g. --key export_insights.enabled)."); process.exit(1); }
|
|
99
|
+
let resp;
|
|
100
|
+
try { resp = await http.get("org-flags", { list: "1", key: opts.key, all: opts.all ? "1" : undefined }); } catch (e) { fail("Lookup failed", e); }
|
|
101
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
102
|
+
|
|
103
|
+
let rows = resp.rows || [];
|
|
104
|
+
if (opts.on) rows = rows.filter((r) => r.value === true);
|
|
105
|
+
if (opts.off) rows = rows.filter((r) => r.value !== true);
|
|
106
|
+
if (opts.present) rows = rows.filter((r) => r.present);
|
|
107
|
+
if (opts.absent) rows = rows.filter((r) => !r.present);
|
|
108
|
+
|
|
109
|
+
const s = resp.summary;
|
|
110
|
+
const label = resp.registered ? ""
|
|
111
|
+
: resp.registered_parent ? ` (sub-key of \`${resp.registered_parent}\` — set it through the parent${resp.registered_parent === "export_insights" ? ", or \`flowiq insights enable\`" : ""})`
|
|
112
|
+
: " (not a registered key — read-only)";
|
|
113
|
+
console.log(`${resp.key}${label} across ${s.orgs} org(s)${resp.include_inactive ? " incl. inactive" : ""}: ` +
|
|
114
|
+
`${s.present} present · ${s.absent} absent` + (s.true || s.false ? ` · ${s.true} true · ${s.false} false` : "") + "\n");
|
|
115
|
+
if (!rows.length) { console.log("(no orgs match the filter)"); return; }
|
|
116
|
+
const nameW = Math.min(34, Math.max(4, ...rows.map((r) => (r.name || "").length)));
|
|
117
|
+
console.log(`${pad("NAME", nameW)} ${pad("ID", 36)} VALUE`);
|
|
118
|
+
for (const r of rows) {
|
|
119
|
+
console.log(`${pad((r.name || "").slice(0, nameW), nameW)} ${pad(r.id, 36)} ${r.present ? fmtValue(r.value, 60) : "(absent)"}${r.inactive ? " (inactive)" : ""}`);
|
|
120
|
+
}
|
|
121
|
+
console.log(`\n${rows.length} org(s) shown`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function set(orgId, key, value, opts = {}) {
|
|
125
|
+
requireUuid(orgId);
|
|
126
|
+
let parsed;
|
|
127
|
+
try { parsed = parseFlagValue(value); } catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
|
|
128
|
+
let resp;
|
|
129
|
+
try {
|
|
130
|
+
resp = await http.post("org-flags", { organization_id: orgId, action: "set", key, value: parsed, confirm: opts.yes === true });
|
|
131
|
+
} catch (e) { fail("Set failed", e); }
|
|
132
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
133
|
+
console.log(`${resp.organization.name} — ${resp.key}`);
|
|
134
|
+
console.log(` ${fmtValue(resp.before, 70)} → ${fmtValue(resp.after, 70)}${resp.changed ? "" : " (no change)"}`);
|
|
135
|
+
if (resp.danger) console.log(` ⚠ ${resp.danger}`);
|
|
136
|
+
for (const w of resp.warnings || []) console.log(` ⚠ ${w}`);
|
|
137
|
+
console.log(` Undo: flowiq org flags ${resp.before === null ? `unset ${orgId} ${resp.key} --yes` : `set ${orgId} ${resp.key} '${JSON.stringify(resp.before)}'${resp.danger ? " --yes" : ""}`}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function unset(orgId, key, opts = {}) {
|
|
141
|
+
requireUuid(orgId);
|
|
142
|
+
let resp;
|
|
143
|
+
try {
|
|
144
|
+
resp = await http.post("org-flags", { organization_id: orgId, action: "unset", key, confirm: opts.yes === true });
|
|
145
|
+
} catch (e) { fail("Unset failed", e); }
|
|
146
|
+
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
147
|
+
console.log(`${resp.organization.name} — removed ${resp.key}`);
|
|
148
|
+
console.log(` was: ${fmtValue(resp.before, 70)}`);
|
|
149
|
+
for (const w of resp.warnings || []) console.log(` ⚠ ${w}`);
|
|
150
|
+
console.log(` Undo: flowiq org flags set ${orgId} ${resp.key} '${JSON.stringify(resp.before)}'${resp.danger ? " --yes" : ""}`);
|
|
151
|
+
}
|