@flowapt/flowiq-cli 0.8.1 → 0.9.2

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.
@@ -1,7 +1,9 @@
1
1
  // `flowiq hours log <org> --minutes N|--hours H --desc "…"` / `list <org>` /
2
- // `summary`. The client-hours ledger: every org's package includes 10 hours of
3
- // Flowapt work per calendar month, and every piece of client work gets logged
4
- // against it (manually here, or automatically by Claude via the FlowIQ MCP).
2
+ // `summary` / `package show|set|clear`. The client-hours ledger: every piece
3
+ // of client work gets logged against the org's monthly hours PACKAGE. Since
4
+ // v0.9.2 each org has its own package (or shares one with sibling orgs, e.g.
5
+ // Barkyn PT/ES/IT), set with `hours package set` by Matt or Gidon only; an org
6
+ // with no package reads "no package on file", never an implied 10h.
5
7
  // All writes happen server-side (/cli/hours) with the staff token.
6
8
 
7
9
  import { http } from "../http.js";
@@ -14,6 +16,24 @@ function fmtHours(minutes) {
14
16
  return `${Number.isInteger(h) ? h : h.toFixed(1)}h`;
15
17
  }
16
18
 
19
+ /** "5h package" / "10h package shared with Barkyn - Spain, Barkyn - Italy" / "no package on file". */
20
+ function describePackage(pkg, orgName) {
21
+ if (!pkg) return "no package on file (set one with `flowiq hours package set`)";
22
+ const size = `${fmtHours(pkg.allowance_minutes)}/month`;
23
+ if (!pkg.shared) return `${size} package`;
24
+ const others = (pkg.organizations || []).filter((n) => n !== orgName);
25
+ return `${size} package "${pkg.name}", shared with ${others.join(", ")}`;
26
+ }
27
+
28
+ /** "3h of 5h used (2h left)" / "⚠ 1.5h OVER" against a package; "" with none. */
29
+ function standingLine(pkg) {
30
+ if (!pkg) return "";
31
+ const used = pkg.used_minutes;
32
+ const allowance = pkg.allowance_minutes;
33
+ const tail = used > allowance ? ` ⚠ ${fmtHours(used - allowance)} OVER` : ` (${fmtHours(allowance - used)} left)`;
34
+ return `${fmtHours(used)} of ${fmtHours(allowance)} used${pkg.shared ? " across the shared package" : ""}${tail}`;
35
+ }
36
+
17
37
  function requireOrg(orgId) {
18
38
  if (!UUID_RE.test(orgId || "")) {
19
39
  console.error(`Error: "${orgId}" is not a valid organization UUID (find it with \`flowiq org list <search>\`).`);
@@ -88,11 +108,11 @@ export async function log(orgId, opts) {
88
108
  process.exit(1);
89
109
  }
90
110
 
91
- const used = resp.month_total_minutes;
92
- const allowance = resp.allowance_hours * 60;
93
111
  console.log(`Logged ${fmtHours(minutes)} for ${resp.organization.name}`);
94
- console.log(` entry: ${resp.id}`);
95
- console.log(` month: ${resp.month} — ${fmtHours(used)} of ${resp.allowance_hours}h package used${used > allowance ? ` ⚠ ${fmtHours(used - allowance)} OVER` : ` (${fmtHours(allowance - used)} left)`}`);
112
+ console.log(` entry: ${resp.id}`);
113
+ console.log(` month: ${resp.month}: ${fmtHours(resp.month_total_minutes)} logged for this org`);
114
+ console.log(` package: ${describePackage(resp.package, resp.organization.name)}`);
115
+ if (resp.package) console.log(` ${standingLine(resp.package)}`);
96
116
  }
97
117
 
98
118
  export async function list(orgId, opts) {
@@ -109,9 +129,10 @@ export async function list(orgId, opts) {
109
129
  console.log(JSON.stringify(resp, null, 2));
110
130
  return;
111
131
  }
112
- const over = resp.remaining_minutes < 0;
113
132
  console.log(`${resp.organization.name} — ${resp.month}`);
114
- console.log(` package: ${resp.allowance_hours}h/month · used ${fmtHours(resp.total_minutes)} · ${over ? `⚠ ${fmtHours(-resp.remaining_minutes)} OVER` : `${fmtHours(resp.remaining_minutes)} left`}`);
133
+ console.log(` logged: ${fmtHours(resp.total_minutes)} for this org`);
134
+ console.log(` package: ${describePackage(resp.package, resp.organization.name)}`);
135
+ if (resp.package) console.log(` ${standingLine(resp.package)}`);
115
136
  if (!resp.entries.length) {
116
137
  console.log(" (no entries this month)");
117
138
  return;
@@ -138,13 +159,107 @@ export async function summary(opts) {
138
159
  return;
139
160
  }
140
161
  if (!resp.organizations.length) {
141
- console.log(`(no hours logged for any org in ${resp.month})`);
162
+ console.log(`(no hours logged and no packages set for ${resp.month})`);
142
163
  return;
143
164
  }
144
- const allowance = resp.allowance_hours * 60;
145
- console.log(`Client hours — ${resp.month} (package: ${resp.allowance_hours}h/month per org)\n`);
165
+ console.log(`Client hours — ${resp.month}\n`);
146
166
  for (const o of resp.organizations) {
147
- const flag = o.total_minutes > allowance ? ` ⚠ ${fmtHours(o.total_minutes - allowance)} OVER` : "";
148
- console.log(` ${fmtHours(o.total_minutes).padStart(6)} (${String(o.entries).padStart(2)} entr${o.entries === 1 ? "y" : "ies"}) ${o.name || o.id}${flag}`);
167
+ const p = o.package;
168
+ let pkgCol;
169
+ if (!p) pkgCol = "no package on file";
170
+ else if (p.shared) {
171
+ const flag = p.used_minutes > p.allowance_minutes ? ` ⚠ ${fmtHours(p.used_minutes - p.allowance_minutes)} OVER` : "";
172
+ pkgCol = `shared "${p.name}": ${fmtHours(p.used_minutes)} / ${fmtHours(p.allowance_minutes)}${flag}`;
173
+ } else {
174
+ const flag = p.used_minutes > p.allowance_minutes ? ` ⚠ ${fmtHours(p.used_minutes - p.allowance_minutes)} OVER` : "";
175
+ pkgCol = `of ${fmtHours(p.allowance_minutes)}${flag}`;
176
+ }
177
+ console.log(` ${fmtHours(o.total_minutes).padStart(6)} (${String(o.entries).padStart(3)} entr${o.entries === 1 ? "y " : "ies"}) ${(o.name || o.id).padEnd(28)} ${pkgCol}`);
178
+ }
179
+ }
180
+
181
+ // ── packages ───────────────────────────────────────────────────────────────
182
+
183
+ function printPackage(p) {
184
+ const who = p.updated_by ? ` · set by ${p.updated_by} ${String(p.updated_at).slice(0, 10)}` : "";
185
+ console.log(` ${p.name} · ${fmtHours(p.monthly_minutes)}/month${p.organizations.length > 1 ? " · SHARED" : ""}${who}`);
186
+ for (const o of p.organizations) console.log(` ${o.name} ${o.id}`);
187
+ if (p.notes) console.log(` notes: ${p.notes}`);
188
+ }
189
+
190
+ export async function packageShow(orgId, opts) {
191
+ if (orgId) requireOrg(orgId);
192
+ let resp;
193
+ try {
194
+ resp = await http.get("hours", { packages: "1" });
195
+ } catch (e) {
196
+ console.error(`Package lookup failed: ${e.message}`);
197
+ process.exit(1);
198
+ }
199
+ let packages = resp.packages;
200
+ if (orgId) packages = packages.filter((p) => p.organizations.some((o) => o.id === orgId));
201
+ if (opts.json) {
202
+ console.log(JSON.stringify(orgId ? { packages } : resp, null, 2));
203
+ return;
204
+ }
205
+ if (!packages.length) {
206
+ console.log(orgId ? "No package on file for this org." : "No packages set yet.");
207
+ } else {
208
+ console.log(`Hours packages (${packages.length})\n`);
209
+ for (const p of packages) printPackage(p);
210
+ }
211
+ console.log(`\nChanges: ${resp.editors.join(", ")} only${resp.can_edit ? " (you can)" : " (not you)"}.`);
212
+ }
213
+
214
+ export async function packageSet(orgIds, opts) {
215
+ for (const id of orgIds) requireOrg(id);
216
+ const hours = Number(opts.hours);
217
+ if (!Number.isFinite(hours) || hours < 0 || hours > 1000) {
218
+ console.error(`Error: --hours must be a number between 0 and 1000 (got "${opts.hours}").`);
219
+ process.exit(1);
220
+ }
221
+ let resp;
222
+ try {
223
+ resp = await http.post("hours", {
224
+ action: "package_set",
225
+ organization_ids: orgIds,
226
+ hours,
227
+ name: opts.name || undefined,
228
+ notes: opts.notes || undefined,
229
+ dry_run: !!opts.dryRun,
230
+ });
231
+ } catch (e) {
232
+ console.error(`Package change failed: ${e.message}`);
233
+ process.exit(1);
234
+ }
235
+ const plan = resp.plan;
236
+ const who = plan.organizations.map((o) => o.name).join(", ");
237
+ console.log(`${resp.dry_run ? "DRY RUN — nothing written.\n" : ""}${plan.mode === "update" ? "Update" : "New"} package "${plan.name}": ${fmtHours(plan.monthly_minutes)}/month for ${who}`);
238
+ for (const b of plan.before) {
239
+ console.log(` before: "${b.name}" ${fmtHours(b.monthly_minutes)}/month (${b.organizations.map((o) => o.name).join(", ")})`);
240
+ }
241
+ if (!plan.before.length) console.log(" before: no package on file");
242
+ for (const l of plan.left_behind) {
243
+ console.log(` ⚠ ${l.remaining.join(", ")} stay${l.remaining.length === 1 ? "s" : ""} on "${l.package}" without ${plan.organizations.length === 1 ? "this org" : "these orgs"}`);
244
+ }
245
+ if (resp.dry_run) console.log("\nRun again without --dry-run to apply.");
246
+ }
247
+
248
+ export async function packageClear(orgId, opts) {
249
+ requireOrg(orgId);
250
+ let resp;
251
+ try {
252
+ resp = await http.post("hours", { action: "package_clear", organization_id: orgId, dry_run: !!opts.dryRun });
253
+ } catch (e) {
254
+ console.error(`Package change failed: ${e.message}`);
255
+ process.exit(1);
256
+ }
257
+ if (resp.changed === false) {
258
+ console.log(`${resp.organization.name}: no package on file, nothing to clear.`);
259
+ return;
149
260
  }
261
+ const plan = resp.plan;
262
+ console.log(`${resp.dry_run ? "DRY RUN — nothing written.\n" : ""}${plan.organization.name} ${resp.dry_run ? "would leave" : "left"} package "${plan.package.name}" (${fmtHours(plan.package.monthly_minutes)}/month)`);
263
+ if (plan.package_removed) console.log(" the package had no other orgs, so it is removed");
264
+ else console.log(` still on it: ${plan.remaining.join(", ")}`);
150
265
  }
@@ -65,6 +65,8 @@ export async function list(orgId, opts = {}) {
65
65
  if (orgId) query.organization_id = orgId;
66
66
  if (opts.status) query.status = opts.status;
67
67
  if (opts.since) query.since = opts.since;
68
+ if (opts.createdSince) query.created_since = opts.createdSince;
69
+ if (opts.touchedSince) query.touched_since = opts.touchedSince;
68
70
  if (opts.includeInactive) query.include_inactive = "1";
69
71
  if (opts.limit != null) query.limit = String(opts.limit);
70
72
 
@@ -83,7 +85,12 @@ export async function list(orgId, opts = {}) {
83
85
  ? resp.organization_name
84
86
  : `all ${resp.include_inactive ? "" : "active "}orgs`;
85
87
  const totals = STATUS_ORDER.filter((s) => resp.counts?.[s]).map((s) => `${s} ${resp.counts[s]}`).join(" · ");
86
- console.log(`Broadcast plans · ${scope} · status ${resp.status_filter}${resp.since ? ` · from ${resp.since}` : ""}`);
88
+ const windowBits = [
89
+ resp.since ? `send date from ${resp.since}` : null,
90
+ resp.created_since ? `created from ${resp.created_since}` : null,
91
+ resp.touched_since ? `touched from ${resp.touched_since}` : null,
92
+ ].filter(Boolean);
93
+ console.log(`Broadcast plans · ${scope} · status ${resp.status_filter}${windowBits.length ? ` · ${windowBits.join(" · ")}` : ""}`);
87
94
  console.log(` every status in this scope: ${totals || "none"}`);
88
95
 
89
96
  const plans = resp.plans || [];
@@ -0,0 +1,367 @@
1
+ // `flowiq popups …` — create, read, change, copy, switch on and delete popups.
2
+ //
3
+ // The CLI knows NOTHING about what a popup may contain. A popup is a JSON
4
+ // document; the popup service validates it and owns every write rule, and
5
+ // `flowiq popups schema` prints what it accepts TODAY. So a popup feature that
6
+ // ships in the popup service works from here the same day, with no CLI release.
7
+ //
8
+ // flowiq popups schema [--json]
9
+ // flowiq popups list <org_id> [--json]
10
+ // flowiq popups pull <org_id> [popup_id] (no id = every popup)
11
+ // flowiq popups push <file> [--dry-run] [--replace-webhooks] [--confirm-delete-webhooks] [--force]
12
+ // flowiq popups create <org_id> <file> [--dry-run]
13
+ // flowiq popups duplicate <org_id> <popup_id> [--name …] [--from-org <id>] [--no-webhooks]
14
+ // flowiq popups activate|deactivate <org_id> <popup_id>
15
+ // flowiq popups live <org_id> <popup_id> [--off]
16
+ // flowiq popups delete <org_id> <popup_id> [--confirm]
17
+ // flowiq popups upload <org_id> <popup_id> <file> --slot <name> | --font <family> [--weight n] [--italic]
18
+
19
+ import fs from "node:fs/promises";
20
+ import path from "node:path";
21
+ import { http } from "../http.js";
22
+
23
+ const POPUPS_DIR = path.resolve(process.cwd(), ".flowiq", "popups");
24
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
25
+
26
+ // What `push` sends from the file. currently_active_on_store and
27
+ // image_history are deliberately NOT here: which popup a store shows is
28
+ // changed with `popups live`, never as a side effect of pushing an old file.
29
+ const PUSH_COLUMNS = ["name", "slug", "is_active", "content_settings", "style_settings", "rules_settings", "stats"];
30
+
31
+ function slugify(name, fallback) {
32
+ const s = String(name || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
33
+ return s || fallback;
34
+ }
35
+
36
+ function die(msg) {
37
+ console.error(msg);
38
+ process.exit(1);
39
+ }
40
+
41
+ function requireUuid(value, what) {
42
+ if (!UUID_RE.test(String(value || ""))) die(`Error: "${value}" is not a valid ${what} UUID.`);
43
+ }
44
+
45
+ async function fileExists(p) {
46
+ try { await fs.access(p); return true; } catch { return false; }
47
+ }
48
+
49
+ /** A failed call: print the service's own errors + warnings, then exit. */
50
+ function fail(prefix, e) {
51
+ const body = e.body || {};
52
+ console.error(`${prefix}: ${e.message}`);
53
+ const errors = Array.isArray(body.errors) ? body.errors : [];
54
+ if (errors.length > 1) for (const err of errors.slice(1)) console.error(` also: ${err}`);
55
+ printWarnings(body.warnings);
56
+ process.exit(1);
57
+ }
58
+
59
+ function printWarnings(warnings) {
60
+ if (!Array.isArray(warnings) || warnings.length === 0) return;
61
+ console.log(`\n ${warnings.length} warning${warnings.length === 1 ? "" : "s"}:`);
62
+ for (const w of warnings) console.log(` ⚠ ${w}`);
63
+ }
64
+
65
+ function docPath(orgSlug, doc) {
66
+ const popup = doc.popup || {};
67
+ const base = `${slugify(popup.slug || popup.name, "popup")}-${String(popup.id || "").slice(0, 8)}`;
68
+ return path.join(POPUPS_DIR, orgSlug, `${base}.json`);
69
+ }
70
+
71
+ async function writeDoc(resp) {
72
+ const doc = resp.data;
73
+ const orgSlug = slugify(resp.organization_slug || resp.organization_name, doc.organization_id);
74
+ const filePath = docPath(orgSlug, doc);
75
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
76
+ const overwriting = await fileExists(filePath);
77
+ const onDisk = {
78
+ document_version: doc.document_version,
79
+ organization_id: doc.organization_id,
80
+ organization_name: resp.organization_name,
81
+ pulled_at: new Date().toISOString(),
82
+ etag: doc.etag,
83
+ popup: doc.popup,
84
+ webhooks: doc.webhooks,
85
+ };
86
+ await fs.writeFile(filePath, JSON.stringify(onDisk, null, 2) + "\n", "utf8");
87
+ return { filePath, overwriting };
88
+ }
89
+
90
+ function describe(popup) {
91
+ const flags = [popup.is_active ? "active" : "inactive"];
92
+ if (popup.currently_active_on_store) flags.push("LIVE on store");
93
+ if (popup.slug) flags.push(`/${popup.slug}`);
94
+ return flags.join(" · ");
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+
99
+ export async function schema(opts = {}) {
100
+ let resp;
101
+ try { resp = await http.get("popups", { op: "schema" }); } catch (e) { fail("Schema failed", e); }
102
+ const s = resp.data;
103
+ if (opts.json) return console.log(JSON.stringify(s, null, 2));
104
+
105
+ console.log(`Popup document (version ${s.document_version}) — served by the popup service, so this is what it accepts TODAY.\n`);
106
+ console.log("content_settings:");
107
+ for (const [k, v] of Object.entries(s.content_settings)) {
108
+ console.log(` ${k}${v.required ? " (required)" : v.enable_flag ? ` (on when ${k}.${v.enable_flag} is true)` : ""}`);
109
+ console.log(` ${v.summary}`);
110
+ }
111
+ console.log("\nrules_settings:");
112
+ for (const [k, v] of Object.entries(s.rules_settings)) console.log(` ${k}${v.required ? " (required)" : ""}\n ${v.summary}`);
113
+ console.log("\nstyle_settings:");
114
+ for (const [k, v] of Object.entries(s.style_settings)) console.log(` ${k}\n ${v}`);
115
+ console.log("\nwebhooks (what a signup triggers — a popup with none sends nothing):");
116
+ for (const [slug, v] of Object.entries(s.webhooks)) console.log(` ${slug} → ${v.config_column}\n ${v.url}\n ${v.summary}`);
117
+ console.log("\nrules:");
118
+ for (const r of s.rules) console.log(` • ${r}`);
119
+ console.log("\n--json prints the whole schema, enums and limits included.");
120
+ }
121
+
122
+ export async function list(orgId, opts = {}) {
123
+ requireUuid(orgId, "organization");
124
+ let resp;
125
+ try { resp = await http.get("popups", { op: "list", organization_id: orgId }); } catch (e) { fail("List failed", e); }
126
+ const popups = resp.data?.popups || [];
127
+ if (opts.json) return console.log(JSON.stringify(popups, null, 2));
128
+
129
+ console.log(`${resp.organization_name} (${orgId}) — ${popups.length} popup${popups.length === 1 ? "" : "s"}\n`);
130
+ for (const p of popups) {
131
+ console.log(` ${p.id} ${p.name}`);
132
+ console.log(` ${describe(p)} · ${p.total_impressions || 0} views · ${p.total_submissions || 0} signups`);
133
+ console.log(` sends to: ${p.webhook_functions?.length ? p.webhook_functions.join(", ") : "NOTHING (no webhooks)"}`);
134
+ }
135
+ if (popups.length) console.log(`\nflowiq popups pull ${orgId} <popup_id> writes one to ./.flowiq/popups/`);
136
+ }
137
+
138
+ export async function pull(orgId, popupId) {
139
+ requireUuid(orgId, "organization");
140
+ let ids = [];
141
+ if (popupId) {
142
+ requireUuid(popupId, "popup");
143
+ ids = [popupId];
144
+ } else {
145
+ let resp;
146
+ try { resp = await http.get("popups", { op: "list", organization_id: orgId }); } catch (e) { fail("Pull failed", e); }
147
+ ids = (resp.data?.popups || []).map((p) => p.id);
148
+ if (ids.length === 0) return console.log(`${resp.organization_name} has no popups.`);
149
+ }
150
+ for (const id of ids) {
151
+ let resp;
152
+ try { resp = await http.get("popups", { op: "get", organization_id: orgId, popup_id: id }); } catch (e) { fail(`Pull failed (${id})`, e); }
153
+ const { filePath, overwriting } = await writeDoc(resp);
154
+ console.log(`${overwriting ? "Overwrote" : "Wrote"} ${filePath}`);
155
+ console.log(` ${resp.data.popup.name} — ${describe(resp.data.popup)} · ${resp.data.webhooks.length} webhook(s)`);
156
+ }
157
+ }
158
+
159
+ async function isFile(p) {
160
+ try { return (await fs.stat(p)).isFile(); } catch { return false; }
161
+ }
162
+
163
+ async function readDoc(file) {
164
+ if (!file || !String(file).trim()) die("Error: give the popup document to read (a path, or a name under ./.flowiq/popups/).");
165
+ const candidates = path.isAbsolute(file) ? [file] : [path.resolve(file), path.join(POPUPS_DIR, file), path.join(POPUPS_DIR, `${file}.json`)];
166
+ for (const c of candidates) {
167
+ if (await isFile(c)) {
168
+ const raw = await fs.readFile(c, "utf8");
169
+ try { return { filePath: c, doc: JSON.parse(raw) }; } catch (e) { die(`Invalid JSON in ${c}: ${e.message}`); }
170
+ }
171
+ }
172
+ die(`File not found. Tried:\n ${candidates.join("\n ")}`);
173
+ }
174
+
175
+ const pickPushColumns = (popup) => Object.fromEntries(PUSH_COLUMNS.filter((c) => popup[c] !== undefined).map((c) => [c, popup[c]]));
176
+
177
+ function printDiff(diff) {
178
+ const changed = diff?.changed || [];
179
+ const w = diff?.webhooks || {};
180
+ console.log(` changed: ${changed.length ? changed.join(", ") : "nothing"}`);
181
+ console.log(` webhooks: +${w.insert || 0} new · ~${w.update || 0} updated · -${w.delete || 0} removed`);
182
+ }
183
+
184
+ export async function push(file, opts = {}) {
185
+ const { filePath, doc } = await readDoc(file);
186
+ if (!doc.organization_id || !UUID_RE.test(doc.organization_id)) die(`${filePath}: organization_id missing or invalid`);
187
+ if (!doc.popup || typeof doc.popup !== "object") die(`${filePath}: no "popup" object`);
188
+ if (!doc.popup.id) die(`${filePath}: popup.id is missing — this document has never been created. Use:\n flowiq popups create ${doc.organization_id} ${file}`);
189
+
190
+ const body = {
191
+ op: "push",
192
+ organization_id: doc.organization_id,
193
+ popup_id: doc.popup.id,
194
+ popup: pickPushColumns(doc.popup),
195
+ webhooks: Array.isArray(doc.webhooks) ? doc.webhooks : undefined,
196
+ webhooks_mode: opts.replaceWebhooks ? "replace" : undefined,
197
+ confirm_delete_webhooks: !!opts.confirmDeleteWebhooks,
198
+ if_match: doc.etag,
199
+ force: !!opts.force,
200
+ dry_run: !!opts.dryRun,
201
+ };
202
+
203
+ let resp;
204
+ try { resp = await http.post("popups", body); } catch (e) {
205
+ if (e.status === 409) {
206
+ console.error(`Push refused: ${e.message}`);
207
+ console.error(`\n Pull it again (flowiq popups pull ${doc.organization_id} ${doc.popup.id}), re-apply your edit, and push — or --force to overwrite.`);
208
+ process.exit(1);
209
+ }
210
+ fail("Push failed", e);
211
+ }
212
+
213
+ console.log(`${opts.dryRun ? "DRY RUN" : "Pushed"} ${filePath}`);
214
+ console.log(` org: ${resp.organization_name} (${doc.organization_id})`);
215
+ console.log(` popup: ${doc.popup.name} (${doc.popup.id})`);
216
+ printDiff(resp.diff);
217
+ printWarnings(resp.warnings);
218
+ if (opts.dryRun) return console.log("\nDry run only — nothing was written. Re-run without --dry-run to apply.");
219
+
220
+ // The file now carries the NEW etag, so the next push from it is accepted.
221
+ const { filePath: refreshed } = await writeDoc(resp);
222
+ // A rename moves the document to its new file name; the old one would
223
+ // otherwise linger with a stale etag and push a stale name later.
224
+ if (path.resolve(refreshed) !== path.resolve(filePath)) await fs.rm(filePath, { force: true });
225
+ console.log(`\n✓ Saved. ${refreshed} refreshed from the server (new etag).`);
226
+ }
227
+
228
+ export async function create(orgId, file, opts = {}) {
229
+ requireUuid(orgId, "organization");
230
+ const { filePath, doc } = await readDoc(file);
231
+ const popup = doc.popup && typeof doc.popup === "object" ? doc.popup : doc;
232
+ if (!popup.name) die(`${filePath}: popup.name is required`);
233
+
234
+ // A document pulled from another popup carries ids that belong to IT.
235
+ const webhooks = (Array.isArray(doc.webhooks) ? doc.webhooks : []).map(({ id, popup_id, total_attempts, successful_deliveries, failed_deliveries, last_success_at, last_failure_at, created_at, updated_at, ...rest }) => rest);
236
+ const body = {
237
+ op: "create",
238
+ organization_id: orgId,
239
+ popup: { ...pickPushColumns(popup), is_active: false },
240
+ webhooks,
241
+ dry_run: !!opts.dryRun,
242
+ };
243
+
244
+ let resp;
245
+ try { resp = await http.post("popups", body); } catch (e) { fail("Create failed", e); }
246
+
247
+ if (opts.dryRun) {
248
+ console.log(`DRY RUN — would create "${popup.name}" in ${resp.organization_name} with ${webhooks.length} webhook(s), inactive.`);
249
+ printWarnings(resp.warnings);
250
+ return console.log("\nNothing was written. Re-run without --dry-run to create it.");
251
+ }
252
+ const { filePath: written } = await writeDoc(resp);
253
+ const created = resp.data.popup;
254
+ console.log(`✓ Created "${created.name}" in ${resp.organization_name}`);
255
+ console.log(` id: ${created.id}`);
256
+ console.log(` state: INACTIVE — check it, then: flowiq popups activate ${orgId} ${created.id}`);
257
+ console.log(` webhooks: ${resp.data.webhooks.length}`);
258
+ console.log(` file: ${written}`);
259
+ printWarnings(resp.warnings);
260
+ }
261
+
262
+ export async function duplicate(orgId, popupId, opts = {}) {
263
+ requireUuid(orgId, "organization");
264
+ requireUuid(popupId, "popup");
265
+ if (opts.fromOrg) requireUuid(opts.fromOrg, "source organization");
266
+ let resp;
267
+ try {
268
+ resp = await http.post("popups", {
269
+ op: "duplicate",
270
+ organization_id: orgId,
271
+ popup_id: popupId,
272
+ name: opts.name,
273
+ source_organization_id: opts.fromOrg,
274
+ include_webhooks: opts.webhooks === false ? false : undefined,
275
+ });
276
+ } catch (e) { fail("Duplicate failed", e); }
277
+ const { filePath } = await writeDoc(resp);
278
+ const created = resp.data.popup;
279
+ console.log(`✓ Copied into ${resp.organization_name} as "${created.name}"`);
280
+ console.log(` id: ${created.id}`);
281
+ console.log(` state: INACTIVE, no link, zero stats`);
282
+ console.log(` webhooks: ${resp.data.webhooks.length} copied`);
283
+ console.log(` file: ${filePath}`);
284
+ printWarnings(resp.warnings);
285
+ }
286
+
287
+ async function setColumns(orgId, popupId, popup, done) {
288
+ requireUuid(orgId, "organization");
289
+ requireUuid(popupId, "popup");
290
+ let resp;
291
+ try { resp = await http.post("popups", { op: "push", organization_id: orgId, popup_id: popupId, popup }); } catch (e) { fail("Update failed", e); }
292
+ console.log(`✓ ${resp.data.popup.name}: ${done(resp.data.popup)}`);
293
+ for (const d of resp.demoted || []) {
294
+ console.log(` ↳ demoted "${d.name}" (${d.id}) — it was the live popup. Put it back with:\n flowiq popups live ${orgId} ${d.id}`);
295
+ }
296
+ printWarnings(resp.warnings);
297
+ }
298
+
299
+ export const activate = (orgId, popupId) =>
300
+ setColumns(orgId, popupId, { is_active: true }, () => "ACTIVE — it can now be served and accept signups.");
301
+
302
+ export const deactivate = (orgId, popupId) =>
303
+ setColumns(orgId, popupId, { is_active: false }, () => "INACTIVE — it is served nowhere (store embed, pinned id and its link all 404).");
304
+
305
+ export const live = (orgId, popupId, opts = {}) =>
306
+ setColumns(orgId, popupId, { currently_active_on_store: !opts.off }, (p) =>
307
+ p.currently_active_on_store
308
+ ? `is now the popup this organization's store embed shows (any other live popup was demoted).${p.is_active ? "" : " ⚠ It is INACTIVE, so nothing will show until you activate it."}`
309
+ : "is no longer selected as the store's live popup. NOTE: a store embed that pins this popup's id still serves it while it is active — deactivate it to take it down."
310
+ );
311
+
312
+ export async function remove(orgId, popupId, opts = {}) {
313
+ requireUuid(orgId, "organization");
314
+ requireUuid(popupId, "popup");
315
+ try {
316
+ await http.post("popups", { op: "delete", organization_id: orgId, popup_id: popupId, confirm: !!opts.confirm });
317
+ } catch (e) {
318
+ if (e.status === 409) {
319
+ console.error(`Delete refused: ${e.message}`);
320
+ console.error("\n Deleting a popup deletes its signups, webhooks and delivery history with it. Re-run with --confirm if that is what you want.");
321
+ process.exit(1);
322
+ }
323
+ fail("Delete failed", e);
324
+ }
325
+ console.log(`✓ Deleted popup ${popupId}`);
326
+ }
327
+
328
+ const CONTENT_TYPES = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif", svg: "image/svg+xml", avif: "image/avif", otf: "font/otf", ttf: "font/ttf", woff: "font/woff", woff2: "font/woff2" };
329
+
330
+ export async function upload(orgId, popupId, file, opts = {}) {
331
+ requireUuid(orgId, "organization");
332
+ requireUuid(popupId, "popup");
333
+ const isFont = !!opts.font;
334
+ if (!isFont && !opts.slot) die("Error: give --slot <name> for an image (main, main-mobile, textimage, success-main, intro-logo, deck-<key>…) or --font <family> for a font.");
335
+ const abs = path.resolve(file);
336
+ if (!(await fileExists(abs))) die(`File not found: ${abs}`);
337
+ const ext = path.extname(abs).replace(/^\./, "").toLowerCase();
338
+ const target = {
339
+ organization_id: orgId,
340
+ popup_id: popupId,
341
+ kind: isFont ? "font" : "image",
342
+ slot: opts.slot,
343
+ family: opts.font,
344
+ weight: opts.weight ? Number(opts.weight) : undefined,
345
+ style: opts.italic ? "italic" : undefined,
346
+ ext,
347
+ };
348
+
349
+ let ticket;
350
+ try { ticket = await http.post("popups", { op: "upload-url", ...target }); } catch (e) { fail("Upload failed", e); }
351
+
352
+ const bytes = await fs.readFile(abs);
353
+ const put = await fetch(ticket.data.signed_url, {
354
+ method: "PUT",
355
+ headers: { "Content-Type": CONTENT_TYPES[ext] || "application/octet-stream", "x-upsert": "true" },
356
+ body: bytes,
357
+ });
358
+ if (!put.ok) die(`Upload failed: storage answered HTTP ${put.status} ${(await put.text()).slice(0, 200)}`);
359
+
360
+ let ref;
361
+ try { ref = await http.post("popups", { op: "file-url", ...target }); } catch (e) { fail("Upload succeeded but the URL lookup failed", e); }
362
+ console.log(`✓ Uploaded ${path.basename(abs)} (${Math.round(bytes.length / 1024)} KB) → ${ref.data.bucket}/${ref.data.path}`);
363
+ console.log(`\n${ref.data.url}\n`);
364
+ console.log(isFont
365
+ ? `Reference it in style_settings.design.customFonts: [{ "family": "${opts.font}", "faces": [{ "weight": ${target.weight || 400}, "style": "${target.style || "normal"}", "url": "<the URL above>" }] }]`
366
+ : "Paste that URL where the design wants the image (e.g. style_settings.design.image.url), then flowiq popups push.");
367
+ }