@koda-sl/baker-cli 0.280.0 → 0.281.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/dist/cli.js CHANGED
@@ -10482,14 +10482,14 @@ function collectionLine(collection) {
10482
10482
  }
10483
10483
  function accountsInTree(nodes) {
10484
10484
  const accounts = /* @__PURE__ */ new Set();
10485
- const walk = (node) => {
10485
+ const walk2 = (node) => {
10486
10486
  accounts.add(node.customerId);
10487
10487
  for (const child of node.children) {
10488
- walk(child);
10488
+ walk2(child);
10489
10489
  }
10490
10490
  };
10491
10491
  for (const node of nodes) {
10492
- walk(node);
10492
+ walk2(node);
10493
10493
  }
10494
10494
  return accounts;
10495
10495
  }
@@ -12772,6 +12772,25 @@ var CAPTURED_WITHOUT_ROLE = [
12772
12772
  "targetid"
12773
12773
  ].sort();
12774
12774
 
12775
+ // ../api/src/analytics/conversionNames.ts
12776
+ function normalizeConversionName(name) {
12777
+ return name.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim().split(" ").filter((word) => word !== "").map((word) => word.length > 3 && word.endsWith("s") ? word.slice(0, -1) : word).join(" ");
12778
+ }
12779
+ function findNameCollision(name, existing) {
12780
+ const normalized = normalizeConversionName(name);
12781
+ return existing.find((candidate) => candidate !== name && normalizeConversionName(candidate) === normalized) ?? null;
12782
+ }
12783
+ var STANDARD_CONVERSION_NAMES = [
12784
+ { name: "Lead", meaning: "Somebody handed over contact details" },
12785
+ { name: "Booked a call", meaning: "A meeting or demo was scheduled" },
12786
+ { name: "Quote requested", meaning: "Somebody asked what it would cost" },
12787
+ { name: "Purchase", meaning: "Money changed hands" },
12788
+ { name: "Signed up", meaning: "An account or trial was created" },
12789
+ { name: "Subscribed", meaning: "Somebody opted in to hear from them again" },
12790
+ { name: "Downloaded", meaning: "A guide, price list or asset was taken" },
12791
+ { name: "Contacted us", meaning: "A call, WhatsApp or email started from the page" }
12792
+ ];
12793
+
12775
12794
  // ../api/src/analytics/conversions.ts
12776
12795
  var CONVERSION_COUNT_MODES = ["once_per_visit", "every_time"];
12777
12796
  var PRIMARY_CONVERSION_TRIGGER = {
@@ -12793,6 +12812,9 @@ var MAX_CONVERSION_NAME = 80;
12793
12812
 
12794
12813
  // ../api/src/analytics/eventKey.ts
12795
12814
  var SEP = ":";
12815
+ function formEventKey(flowSlug, nodeId, triggerId) {
12816
+ return ["form", flowSlug, nodeId, triggerId].join(SEP);
12817
+ }
12796
12818
  function parseEventKey(key) {
12797
12819
  const cut = key.indexOf(SEP);
12798
12820
  if (cut <= 0) return null;
@@ -20318,14 +20340,14 @@ function collectionLine2(collection) {
20318
20340
  }
20319
20341
  function accountsInTree2(nodes) {
20320
20342
  const accounts = /* @__PURE__ */ new Set();
20321
- const walk = (node) => {
20343
+ const walk2 = (node) => {
20322
20344
  accounts.add(node.accountId);
20323
20345
  for (const child of node.children) {
20324
- walk(child);
20346
+ walk2(child);
20325
20347
  }
20326
20348
  };
20327
20349
  for (const node of nodes) {
20328
- walk(node);
20350
+ walk2(node);
20329
20351
  }
20330
20352
  return accounts;
20331
20353
  }
@@ -25348,14 +25370,14 @@ function collectionLine3(collection) {
25348
25370
  }
25349
25371
  function accountsInTree3(nodes) {
25350
25372
  const accounts = /* @__PURE__ */ new Set();
25351
- const walk = (node) => {
25373
+ const walk2 = (node) => {
25352
25374
  accounts.add(node.accountId);
25353
25375
  for (const child of node.children) {
25354
- walk(child);
25376
+ walk2(child);
25355
25377
  }
25356
25378
  };
25357
25379
  for (const node of nodes) {
25358
- walk(node);
25380
+ walk2(node);
25359
25381
  }
25360
25382
  return accounts;
25361
25383
  }
@@ -27170,6 +27192,9 @@ Full guides: __tooling__/docs/tools/baker/ads-<platform>.md (google|meta|linkedi
27170
27192
  }
27171
27193
  });
27172
27194
 
27195
+ // src/commands/analytics/index.ts
27196
+ import { readFileSync as readFileSync12 } from "fs";
27197
+
27173
27198
  // ../api/src/event-ingest/arrivals.ts
27174
27199
  import { z as z31 } from "zod";
27175
27200
 
@@ -27983,50 +28008,325 @@ var ingestEnvelopeSchema = z32.object({ ok: z32.literal(true), data: ingestRespo
27983
28008
  // src/commands/analytics/index.ts
27984
28009
  import { defineCommand as defineCommand95 } from "citty";
27985
28010
 
28011
+ // src/commands/flows/shared.ts
28012
+ import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync11, writeFileSync as writeFileSync3 } from "fs";
28013
+ import { join as join2 } from "path";
28014
+ var FLOWS_DIR = "src/lib/flow-engine/flows";
28015
+ function flowsDir() {
28016
+ let dir = process.cwd();
28017
+ for (let i = 0; i < 6; i++) {
28018
+ const candidate = join2(dir, FLOWS_DIR);
28019
+ if (existsSync3(candidate)) {
28020
+ return candidate;
28021
+ }
28022
+ const parent = join2(dir, "..");
28023
+ if (parent === dir) {
28024
+ break;
28025
+ }
28026
+ dir = parent;
28027
+ }
28028
+ return join2(process.cwd(), FLOWS_DIR);
28029
+ }
28030
+ function failLocal(message) {
28031
+ writeJson({ ok: false, error: { code: "NOT_FOUND", message } });
28032
+ process.exit(1);
28033
+ }
28034
+ function listFlowSlugs() {
28035
+ const dir = flowsDir();
28036
+ if (!existsSync3(dir)) {
28037
+ return [];
28038
+ }
28039
+ return readdirSync2(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.name.startsWith("_") && entry.name !== ".gitkeep").map((entry) => entry.name).sort();
28040
+ }
28041
+ function flowDataPath(slug) {
28042
+ return join2(flowsDir(), slug, "_data.json");
28043
+ }
28044
+ function writeFlowTree(slug, tree) {
28045
+ writeFileSync3(flowDataPath(slug), `${JSON.stringify(tree, null, 2)}
28046
+ `, "utf-8");
28047
+ }
28048
+ function walkNodes(node, acc = []) {
28049
+ if (!node || typeof node !== "object") return acc;
28050
+ acc.push(node);
28051
+ if (Array.isArray(node.children)) {
28052
+ for (const child of node.children) walkNodes(child, acc);
28053
+ }
28054
+ return acc;
28055
+ }
28056
+ function collectSideEffects(tree) {
28057
+ return walkNodes(tree).flatMap(
28058
+ (node) => Array.isArray(node.sideEffects) ? node.sideEffects.filter((sideEffect) => Boolean(sideEffect) && typeof sideEffect === "object").map((sideEffect) => ({ node, sideEffect })) : []
28059
+ );
28060
+ }
28061
+ function flowExists(slug) {
28062
+ return existsSync3(join2(flowsDir(), slug, "_data.json"));
28063
+ }
28064
+ function readFlowTree(slug) {
28065
+ const path38 = join2(flowsDir(), slug, "_data.json");
28066
+ if (!existsSync3(path38)) {
28067
+ failLocal(`No form "${slug}". Run "baker flows list" to see the forms in this workspace.`);
28068
+ }
28069
+ try {
28070
+ return JSON.parse(readFileSync11(path38, "utf-8"));
28071
+ } catch (error) {
28072
+ failLocal(`Could not read form "${slug}": ${error instanceof Error ? error.message : String(error)}`);
28073
+ }
28074
+ }
28075
+ function isConfigured(value) {
28076
+ return typeof value === "string" && value.length > 0;
28077
+ }
28078
+ function nodeMeta(record) {
28079
+ const nodeId = typeof record.id === "string" ? record.id : "";
28080
+ const nodeName = typeof record.name === "string" ? record.name : void 0;
28081
+ return { nodeId, ...nodeName ? { nodeName } : {} };
28082
+ }
28083
+ function widgetNodeStatus(record, meta) {
28084
+ const nodeData = record.nodeData;
28085
+ const nodeType = nodeData?.type;
28086
+ if (typeof nodeType !== "string" || !FLOW_RESOURCE_NODE_TYPES.includes(nodeType)) {
28087
+ return null;
28088
+ }
28089
+ const external = nodeData?.form?.external;
28090
+ const selected = external != null && typeof external === "object";
28091
+ const name = selected ? external.name : void 0;
28092
+ return {
28093
+ ...meta,
28094
+ target: "node",
28095
+ kind: nodeType,
28096
+ status: selected ? `${nodeType} \u2014 selected${typeof name === "string" ? ` "${name}"` : ""}` : `${nodeType} \u2014 not selected`,
28097
+ needsInput: !selected
28098
+ };
28099
+ }
28100
+ function sideEffectNeedsInput(raw) {
28101
+ const effect = raw;
28102
+ const type = effect?.type;
28103
+ if (typeof type !== "string" || !FLOW_SECRET_SIDE_EFFECT_TYPES.includes(type)) {
28104
+ return false;
28105
+ }
28106
+ const configured = isConfigured(effect?.encryptedConfig);
28107
+ if (!isFlowOauthSideEffect(type)) return !configured;
28108
+ return !isConfigured(effect?.oauthProviderId) || !configured;
28109
+ }
28110
+ function sideEffectStatus(raw, meta) {
28111
+ const effect = raw;
28112
+ const type = effect?.type;
28113
+ if (typeof type !== "string" || !FLOW_SECRET_SIDE_EFFECT_TYPES.includes(type)) {
28114
+ return null;
28115
+ }
28116
+ const configured = isConfigured(effect?.encryptedConfig);
28117
+ const oauth = isFlowOauthSideEffect(type);
28118
+ const connected = isConfigured(effect?.oauthProviderId);
28119
+ const needs = FLOW_SECRET_FIELDS[type];
28120
+ const status = oauth ? `${type} \u2014 connection ${connected ? "[connected]" : "[needs connection]"}, config ${configured ? "[set]" : "[missing]"}` : configured ? `${type} \u2014 [configured]` : `${type} \u2014 [missing]${needs.length > 0 ? ` (needs: ${needs.join(", ")})` : ""}`;
28121
+ return {
28122
+ ...meta,
28123
+ target: "sideEffect",
28124
+ ...typeof effect?.id === "string" ? { sideEffectId: effect.id } : {},
28125
+ kind: type,
28126
+ status,
28127
+ needsInput: sideEffectNeedsInput(effect)
28128
+ };
28129
+ }
28130
+ function requestFlowInputCall(slug, status) {
28131
+ const args = status.target === "node" ? `{ target: "node", flowSlug: "${slug}", nodeId: "${status.nodeId}", nodeType: "${status.kind}" }` : `{ target: "sideEffect", flowSlug: "${slug}", nodeId: "${status.nodeId}", sideEffectId: "${status.sideEffectId}", sideEffectType: "${status.kind}" }`;
28132
+ return `request_flow_input with ${args}`;
28133
+ }
28134
+ function collectConfigStatus(node, acc) {
28135
+ if (!node || typeof node !== "object") {
28136
+ return;
28137
+ }
28138
+ const record = node;
28139
+ const meta = nodeMeta(record);
28140
+ const widget = widgetNodeStatus(record, meta);
28141
+ if (widget) {
28142
+ acc.push(widget);
28143
+ }
28144
+ if (Array.isArray(record.sideEffects)) {
28145
+ for (const raw of record.sideEffects) {
28146
+ const status = sideEffectStatus(raw, meta);
28147
+ if (status) {
28148
+ acc.push(status);
28149
+ }
28150
+ }
28151
+ }
28152
+ if (Array.isArray(record.children)) {
28153
+ for (const child of record.children) {
28154
+ collectConfigStatus(child, acc);
28155
+ }
28156
+ }
28157
+ }
28158
+ function redactTree(node) {
28159
+ if (Array.isArray(node)) {
28160
+ return node.map(redactTree);
28161
+ }
28162
+ if (!node || typeof node !== "object") {
28163
+ return node;
28164
+ }
28165
+ const out = {};
28166
+ for (const [key, value] of Object.entries(node)) {
28167
+ if (key === "encryptedConfig") {
28168
+ out[key] = isConfigured(value) ? "[configured]" : value;
28169
+ } else {
28170
+ out[key] = redactTree(value);
28171
+ }
28172
+ }
28173
+ return out;
28174
+ }
28175
+ function stepNameKey(name) {
28176
+ return name.replace(/([a-z])([A-Z])/g, "$1_$2").replace(/[-\s]+/g, "_").toUpperCase();
28177
+ }
28178
+ function isUsableStepNameKey(key) {
28179
+ return /^[\p{ID_Start}$_][\p{ID_Continue}$‌‍]*$/u.test(key);
28180
+ }
28181
+
27986
28182
  // src/commands/analytics/conversionArgs.ts
27987
28183
  var ConversionArgsError = class extends Error {
27988
28184
  };
28185
+ function allValues(flag, argv) {
28186
+ const values = [];
28187
+ for (let i = 0; i < argv.length; i += 1) {
28188
+ const arg = argv[i];
28189
+ if (arg === flag) {
28190
+ const next = argv[i + 1];
28191
+ if (next !== void 0 && !next.startsWith("--")) values.push(next);
28192
+ continue;
28193
+ }
28194
+ if (arg.startsWith(`${flag}=`)) values.push(arg.slice(flag.length + 1));
28195
+ }
28196
+ return values.map((value) => value.trim()).filter((value) => value !== "");
28197
+ }
27989
28198
  function onlyOnce(value, flag, argv) {
27990
28199
  if (value === void 0) return void 0;
27991
28200
  const given = argv.filter((arg) => arg === flag || arg.startsWith(`${flag}=`)).length;
27992
28201
  if (given > 1) {
27993
28202
  throw new ConversionArgsError(
27994
- `${flag} was given ${given} times and only the last would have been used. Count one outcome per call.`
28203
+ `${flag} was given ${given} times and only the last would have been used. It takes one value.`
27995
28204
  );
27996
28205
  }
27997
28206
  const trimmed = String(value).trim();
27998
28207
  return trimmed === "" ? void 0 : trimmed;
27999
28208
  }
28000
- function parseConversionsArgs(raw, argv) {
28001
- const eventKey = onlyOnce(raw.event, "--event", argv);
28002
- const name = onlyOnce(raw.name, "--name", argv);
28003
- const countMode = onlyOnce(raw["count-mode"], "--count-mode", argv);
28004
- const remove = onlyOnce(raw.remove, "--remove", argv);
28005
- if (eventKey !== void 0 && name === void 0) {
28006
- throw new ConversionArgsError("--event needs --name: a conversion nobody named is a row labelled with a UUID.");
28209
+ function repeatable(flag, cittyValue, argv) {
28210
+ const fromArgv = allValues(flag, argv);
28211
+ if (fromArgv.length > 0) return fromArgv;
28212
+ if (cittyValue === void 0) return [];
28213
+ const trimmed = String(cittyValue).trim();
28214
+ return trimmed === "" ? [] : [trimmed];
28215
+ }
28216
+ function checkFlagShape(args) {
28217
+ if (args.eventKeys.length > 0 && args.name === void 0 && args.countMode === void 0) {
28218
+ throw new ConversionArgsError(
28219
+ "--event needs --name: a conversion nobody named is a row labelled with a UUID. (--event with only --count-mode changes how an event already counted is counted.)"
28220
+ );
28007
28221
  }
28008
- if (name !== void 0 && eventKey === void 0 && remove === void 0) {
28222
+ if (args.name !== void 0 && args.eventKeys.length === 0 && args.renameTo === void 0) {
28009
28223
  throw new ConversionArgsError("--name needs --event, naming which event to count.");
28010
28224
  }
28011
- if (eventKey !== void 0 && parseEventKey(eventKey) === null) {
28225
+ if (args.renameFrom !== void 0 && args.renameTo === void 0) {
28226
+ throw new ConversionArgsError('--rename needs --to: `--rename "Lead form" --to "Lead"`.');
28227
+ }
28228
+ if (args.renameTo !== void 0 && args.renameFrom === void 0) {
28012
28229
  throw new ConversionArgsError(
28013
- `"${eventKey}" is not an event key. Run with --candidates and copy one; they look like form:<form>:<step>:<trigger>, submit:<form>, page:<name> or exit:<host>.`
28230
+ '--to needs --rename, naming which outcome to rename: `--rename "Lead form" --to "Lead"`.'
28014
28231
  );
28015
28232
  }
28233
+ }
28234
+ function checkEventKeys(eventKeys) {
28235
+ for (const eventKey of eventKeys) {
28236
+ if (parseEventKey(eventKey) === null) {
28237
+ throw new ConversionArgsError(
28238
+ `"${eventKey}" is not an event key. Run with --candidates (or --flow <slug> for one Form) and copy one; they look like form:<form>:<step>:<trigger>, submit:<form>, page:<name> or exit:<host>.`
28239
+ );
28240
+ }
28241
+ }
28242
+ }
28243
+ function readDays(raw) {
28244
+ if (raw === void 0) return void 0;
28245
+ const days = Number(raw);
28246
+ if (!Number.isFinite(days) || days <= 0) {
28247
+ throw new ConversionArgsError("--days must be a positive number of days.");
28248
+ }
28249
+ return days;
28250
+ }
28251
+ function parseConversionsArgs(raw, argv) {
28252
+ const countMode = onlyOnce(raw["count-mode"], "--count-mode", argv);
28016
28253
  if (countMode !== void 0 && !CONVERSION_COUNT_MODES.includes(countMode)) {
28017
28254
  throw new ConversionArgsError(`--count-mode must be one of ${CONVERSION_COUNT_MODES.join(", ")}.`);
28018
28255
  }
28019
- const days = raw.days === void 0 ? void 0 : Number(raw.days);
28020
- if (days !== void 0 && (!Number.isFinite(days) || days <= 0)) {
28021
- throw new ConversionArgsError("--days must be a positive number of days.");
28256
+ const args = {
28257
+ eventKeys: repeatable("--event", raw.event, argv),
28258
+ removeKeys: repeatable("--remove", raw.remove, argv),
28259
+ name: onlyOnce(raw.name, "--name", argv),
28260
+ countMode,
28261
+ renameFrom: onlyOnce(raw.rename, "--rename", argv),
28262
+ renameTo: onlyOnce(raw.to, "--to", argv),
28263
+ removeName: onlyOnce(raw["remove-name"], "--remove-name", argv),
28264
+ flowSlug: onlyOnce(raw.flow, "--flow", argv),
28265
+ candidates: raw.candidates === true,
28266
+ days: readDays(raw.days)
28267
+ };
28268
+ checkFlagShape(args);
28269
+ checkEventKeys(args.eventKeys);
28270
+ return args;
28271
+ }
28272
+ function existingNames(existing) {
28273
+ return [...new Set(existing.map((row) => row.name))];
28274
+ }
28275
+ function checkNameCollision(name, existing) {
28276
+ const collision = findNameCollision(name, existingNames(existing));
28277
+ if (collision === null) return;
28278
+ throw new ConversionArgsError(
28279
+ `This company already counts "${collision}", and "${name}" would be a second row for the same outcome \u2014 Baker groups by exact name, so the number would be split in two. Use "${collision}" to make them one number, or pick a name that means something different.`
28280
+ );
28281
+ }
28282
+ function under(name, existing) {
28283
+ const rows = existing.filter((row) => row.name === name);
28284
+ if (rows.length === 0) {
28285
+ throw new ConversionArgsError(
28286
+ `Nothing is counted under "${name}". This company counts: ${existingNames(existing).join(", ") || "nothing yet"}.`
28287
+ );
28022
28288
  }
28023
- return { eventKey, name, countMode, remove, candidates: raw.candidates === true, days };
28289
+ return rows;
28024
28290
  }
28025
- function conversionsWrite(args) {
28026
- const set = args.eventKey !== void 0 && args.name !== void 0 ? [{ eventKey: args.eventKey, name: args.name, ...args.countMode ? { countMode: args.countMode } : {} }] : void 0;
28027
- const remove = args.remove !== void 0 ? [args.remove] : void 0;
28028
- if (!set && !remove) return null;
28029
- return { ...set ? { set } : {}, ...remove ? { remove } : {} };
28291
+ function setFromEvents(args, existing) {
28292
+ if (args.eventKeys.length === 0) return [];
28293
+ if (args.name !== void 0) {
28294
+ checkNameCollision(args.name, existing);
28295
+ const name = args.name;
28296
+ return args.eventKeys.map((eventKey) => ({
28297
+ eventKey,
28298
+ name,
28299
+ ...args.countMode ? { countMode: args.countMode } : {}
28300
+ }));
28301
+ }
28302
+ return args.eventKeys.map((eventKey) => {
28303
+ const current = existing.find((row) => row.eventKey === eventKey);
28304
+ if (!current) {
28305
+ throw new ConversionArgsError(
28306
+ `"${eventKey}" is not counted, so there is no count mode to change. Add it with --event "${eventKey}" --name "<what a person calls it>" --count-mode ${args.countMode}.`
28307
+ );
28308
+ }
28309
+ return { eventKey, name: current.name, countMode: args.countMode };
28310
+ });
28311
+ }
28312
+ function setFromRename(args, existing) {
28313
+ if (args.renameFrom === void 0 || args.renameTo === void 0) return [];
28314
+ const renameTo = args.renameTo;
28315
+ checkNameCollision(renameTo, existing);
28316
+ return under(args.renameFrom, existing).map((row) => ({
28317
+ eventKey: row.eventKey,
28318
+ name: renameTo,
28319
+ countMode: row.countMode
28320
+ }));
28321
+ }
28322
+ function conversionsWrite(args, existing) {
28323
+ const set = [...setFromEvents(args, existing), ...setFromRename(args, existing)];
28324
+ const remove = [
28325
+ ...args.removeKeys,
28326
+ ...args.removeName === void 0 ? [] : under(args.removeName, existing).map((row) => row.eventKey)
28327
+ ];
28328
+ if (set.length === 0 && remove.length === 0) return null;
28329
+ return { ...set.length > 0 ? { set } : {}, ...remove.length > 0 ? { remove } : {} };
28030
28330
  }
28031
28331
 
28032
28332
  // src/commands/analytics/conversionHints.ts
@@ -28064,6 +28364,163 @@ function conversionHints(definitions, candidates, askedForCandidates) {
28064
28364
  }
28065
28365
  return hints2;
28066
28366
  }
28367
+ function namingHints(definitions) {
28368
+ const names = [...new Set(definitions.map((row) => row.name))];
28369
+ if (names.length === 0) {
28370
+ return [
28371
+ `Nothing is named yet, so the first name sets the vocabulary for this company. Start from the standard set \u2014 ${STANDARD_CONVERSION_NAMES.map((entry) => entry.name).join(", ")} \u2014 and stay in the company's own language once one exists.`
28372
+ ];
28373
+ }
28374
+ return [
28375
+ `Names in use here: ${names.join(", ")}. Reuse one EXACTLY when another Form produces the same outcome \u2014 that is what makes them one number \u2014 and use \`--rename "<old>" --to "<existing>"\` to merge two that should always have been one.`
28376
+ ];
28377
+ }
28378
+
28379
+ // src/commands/analytics/conversionList.ts
28380
+ function mergeStoredWithCounts(stored, reported) {
28381
+ const counts = new Map(reported.map((row) => [row.name, row]));
28382
+ const groups = /* @__PURE__ */ new Map();
28383
+ for (const row of stored) {
28384
+ const group = groups.get(row.name) ?? [];
28385
+ group.push(row);
28386
+ groups.set(row.name, group);
28387
+ }
28388
+ return [...groups.entries()].map(([name, rows]) => {
28389
+ const modes = new Set(rows.map((row) => row.countMode));
28390
+ const counted = counts.get(name);
28391
+ return {
28392
+ name,
28393
+ eventKeys: rows.map((row) => row.eventKey).sort(),
28394
+ // A real state and usually an editing accident, which is why it is said
28395
+ // out loud rather than resolved to one of them.
28396
+ countMode: modes.size === 1 ? [...modes][0] : "mixed",
28397
+ // Zero rather than absent: an outcome that is counted and has never
28398
+ // fired is the most actionable row on this report, and dropping it
28399
+ // would make it indistinguishable from one nobody has named.
28400
+ conversions: counted?.conversions ?? 0,
28401
+ convertingVisits: counted?.convertingVisits ?? 0,
28402
+ lastSeen: counted?.lastSeen ?? null
28403
+ };
28404
+ }).sort((a, b) => a.name.localeCompare(b.name));
28405
+ }
28406
+
28407
+ // src/commands/analytics/flowConversionKeys.ts
28408
+ function walk(node, acc = []) {
28409
+ if (!node || typeof node !== "object") return acc;
28410
+ const record = node;
28411
+ acc.push(record);
28412
+ if (Array.isArray(record.children)) for (const child of record.children) walk(child, acc);
28413
+ return acc;
28414
+ }
28415
+ function flowConversionCandidates(tree, slug) {
28416
+ const primary = [];
28417
+ const secondary = [];
28418
+ for (const node of walk(tree)) {
28419
+ const id = typeof node.id === "string" ? node.id : null;
28420
+ if (!id) continue;
28421
+ const triggers = flowNodeTriggers(node.nodeData);
28422
+ for (const triggerId of triggers.available) {
28423
+ const candidate = {
28424
+ eventKey: formEventKey(slug, id, triggerId),
28425
+ nodeId: id,
28426
+ nodeName: typeof node.name === "string" ? node.name : id,
28427
+ nodeType: triggers.type,
28428
+ triggerId,
28429
+ isCompletion: triggers.completion === triggerId
28430
+ };
28431
+ (candidate.isCompletion ? primary : secondary).push(candidate);
28432
+ }
28433
+ }
28434
+ return [
28435
+ ...primary,
28436
+ ...secondary,
28437
+ {
28438
+ eventKey: `submit:${slug}`,
28439
+ nodeId: null,
28440
+ nodeName: "the Form ran out of steps",
28441
+ nodeType: null,
28442
+ triggerId: null,
28443
+ // A Form reaching its last node is as primary as an outcome gets for a
28444
+ // Form with no widget in it — but it never fires on one that ends in a
28445
+ // booking widget or a redirect, so it does not lead the list.
28446
+ isCompletion: false
28447
+ }
28448
+ ];
28449
+ }
28450
+ function formKeyRefusal(eventKey, tree, slug) {
28451
+ const parsed = parseEventKey(eventKey);
28452
+ if (!parsed || parsed.family !== "form" || parsed.flowSlug !== slug) return null;
28453
+ const node = walk(tree).find((candidate) => candidate.id === parsed.nodeId);
28454
+ if (!node) {
28455
+ return {
28456
+ message: `"${slug}" has no step with id "${parsed.nodeId}", so this key would be stored and count zero forever.`,
28457
+ fix: `Steps in this Form: ${walk(tree).map((n) => `${String(n.id)} (${String(n.name ?? "unnamed")})`).join(", ")}. \`baker analytics conversions --flow ${slug}\` prints each one already spelled as an event key.`
28458
+ };
28459
+ }
28460
+ const triggers = flowNodeTriggers(node.nodeData);
28461
+ if (!triggers.known || triggers.available.includes(parsed.triggerId)) return null;
28462
+ return {
28463
+ message: `Step "${String(node.name ?? node.id)}" never raises \`${parsed.triggerId}\`, so counting it would score zero however many people reach that step.`,
28464
+ fix: triggers.available.length > 0 ? `It raises: ${triggers.available.join(", ")}.` : triggers.why ?? "That step raises nothing a conversion could be counted on."
28465
+ };
28466
+ }
28467
+ function joinFlowConversions(candidates, definitions) {
28468
+ const byKey = /* @__PURE__ */ new Map();
28469
+ for (const definition of definitions) {
28470
+ for (const key of definition.eventKeys) byKey.set(key, definition.name);
28471
+ }
28472
+ return candidates.map((candidate) => ({ ...candidate, countedAs: byKey.get(candidate.eventKey) ?? null }));
28473
+ }
28474
+ function flowConversionHints(slug, rows, noConversionReason) {
28475
+ const counted = rows.filter((row) => row.countedAs !== null);
28476
+ if (counted.length > 0) {
28477
+ return [
28478
+ `Baker counts ${counted.length} of this Form's endings: ${counted.map((row) => `${row.countedAs} (${row.nodeName})`).join(
28479
+ ", "
28480
+ )}. Every other ending here scores nothing \u2014 count another with \`--event <key> --name <outcome>\`, reusing the SAME name to make two Forms one number.`
28481
+ ];
28482
+ }
28483
+ if (noConversionReason) {
28484
+ return [
28485
+ `Nothing here is counted, and that is on purpose: this Form declares no outcome \u2014 "${noConversionReason}". Leave it alone unless that has stopped being true.`
28486
+ ];
28487
+ }
28488
+ const deepestFirst = [...rows].reverse();
28489
+ const suggestion = deepestFirst.find((row) => row.isCompletion && row.nodeType !== "text") ?? deepestFirst.find((row) => row.isCompletion) ?? rows[rows.length - 1];
28490
+ return [
28491
+ `Nothing on "${slug}" is counted, so it converts nobody on every report however many people finish it \u2014 Baker never guesses which ending is the point of a Form.`,
28492
+ ...suggestion ? [
28493
+ `Name its outcome: \`baker analytics conversions --event "${suggestion.eventKey}" --name "<what a person calls it>"\` (that key is "${suggestion.nodeName}"). It applies at once and rescores the whole history; nothing is staged and there is nothing to publish.`
28494
+ ] : [],
28495
+ `If this Form has no outcome of its own \u2014 it hands the visitor to a shop, a portal or a funnel that converts them there \u2014 say so instead, with \`noConversion: { "reason": "\u2026" }\` on its first step.`
28496
+ ];
28497
+ }
28498
+ function flowCoverage(forms, definitions) {
28499
+ return forms.map(({ slug, tree }) => {
28500
+ const rows = joinFlowConversions(flowConversionCandidates(tree, slug), definitions);
28501
+ const declared = tree?.noConversion?.reason;
28502
+ const seen = /* @__PURE__ */ new Set();
28503
+ return {
28504
+ slug,
28505
+ counted: rows.filter((row) => row.countedAs !== null).filter((row) => {
28506
+ const key = `${row.countedAs} ${row.nodeName}`;
28507
+ if (seen.has(key)) return false;
28508
+ seen.add(key);
28509
+ return true;
28510
+ }).map((row) => ({ name: String(row.countedAs), step: row.nodeName })),
28511
+ noConversion: typeof declared === "string" && declared.trim() !== "" ? declared.trim() : null
28512
+ };
28513
+ });
28514
+ }
28515
+ function flowCoverageHints(coverage) {
28516
+ const unset = coverage.filter((row) => row.counted.length === 0 && row.noConversion === null);
28517
+ if (unset.length === 0) return [];
28518
+ return [
28519
+ `${unset.length} of ${coverage.length} Form(s) here count nothing, so every report shows them converting nobody: ${unset.map((row) => row.slug).join(
28520
+ ", "
28521
+ )}. Read each one's endings with \`baker analytics conversions --flow <slug>\` and name the outcome, or declare on its first step why it has none.`
28522
+ ];
28523
+ }
28067
28524
 
28068
28525
  // src/commands/analytics/hints.ts
28069
28526
  var SEVERE_STEP_DROP = 0.6;
@@ -28946,26 +29403,93 @@ Examples:
28946
29403
  }
28947
29404
  });
28948
29405
  })();
29406
+ function localFormKeyRefusal(eventKey) {
29407
+ const parsed = parseEventKey(eventKey);
29408
+ if (!parsed || parsed.family !== "form") return null;
29409
+ if (!flowExists(parsed.flowSlug)) return null;
29410
+ return formKeyRefusal(eventKey, readFlowTree(parsed.flowSlug), parsed.flowSlug);
29411
+ }
29412
+ function refuseUnmatchableKey(eventKey) {
29413
+ const refusal = eventKey === void 0 ? null : localFormKeyRefusal(eventKey);
29414
+ if (!refusal) return;
29415
+ writeJsonEnvelope({ ok: false, error: { code: "VALIDATION_ERROR", message: refusal.message, fix: refusal.fix } });
29416
+ process.exit(1);
29417
+ }
29418
+ function readWorkspaceFlows() {
29419
+ return listFlowSlugs().flatMap((slug) => {
29420
+ try {
29421
+ return [{ slug, tree: JSON.parse(readFileSync12(flowDataPath(slug), "utf-8")) }];
29422
+ } catch {
29423
+ return [];
29424
+ }
29425
+ });
29426
+ }
29427
+ function readFlowConversions2(slug, definitions) {
29428
+ const tree = readFlowTree(slug);
29429
+ const declared = tree.noConversion;
29430
+ const reason = declared && typeof declared === "object" && typeof declared.reason === "string" ? String(declared.reason) : null;
29431
+ return {
29432
+ slug,
29433
+ noConversionReason: reason,
29434
+ endings: joinFlowConversions(flowConversionCandidates(tree, slug), definitions)
29435
+ };
29436
+ }
29437
+ function conversionsEnvelope(args, definitions, candidates) {
29438
+ const flow = args.flowSlug === void 0 ? null : readFlowConversions2(args.flowSlug, definitions);
29439
+ const forms = flow === null ? readWorkspaceFlows() : [];
29440
+ const coverage = flowCoverage(forms, definitions);
29441
+ const hints2 = [
29442
+ ...flow ? flowConversionHints(flow.slug, flow.endings, flow.noConversionReason) : [],
29443
+ ...flowCoverageHints(coverage),
29444
+ ...conversionHints(definitions, candidates, args.candidates),
29445
+ ...namingHints(definitions)
29446
+ ];
29447
+ return {
29448
+ ok: true,
29449
+ data: {
29450
+ definitions,
29451
+ ...flow ? { flow } : {},
29452
+ ...coverage.length > 0 ? { flows: coverage } : {},
29453
+ ...args.candidates ? { candidates } : {}
29454
+ },
29455
+ ...hints2.length > 0 ? { hints: hints2 } : {}
29456
+ };
29457
+ }
28949
29458
  var conversionsCommand3 = (() => {
28950
29459
  const args = {
28951
29460
  event: {
28952
29461
  type: "string",
28953
- description: "The event to start counting, from the `candidates` list: form:<form>:<step>:<trigger>, submit:<form>, page:<name>, or exit:<host>. Requires --name. Applies immediately and scores the WHOLE history, not just from now on \u2014 so counting the right event today also corrects last month's reports",
29462
+ description: "The event to start counting, from the `candidates` or `--flow` list: form:<form>:<step>:<trigger>, submit:<form>, page:<name>, or exit:<host>. Requires --name. REPEATABLE \u2014 pass it several times with one --name to count all of them as that single outcome, which is the whole point of the name. Applies immediately and scores the WHOLE history, not just from now on, so counting the right event today also corrects last month's reports",
28954
29463
  required: false
28955
29464
  },
28956
29465
  name: {
28957
29466
  type: "string",
28958
- description: "What to call this outcome on every report \u2014 'Booked a call', 'Quote requested'. Reuse the SAME name on two events and they become one row and one number, which is how a call booked on three different Forms reads as one outcome rather than three",
29467
+ description: "What to call this outcome on every report \u2014 'Booked a call', 'Quote requested'. Baker groups by EXACT name: reuse one this company already counts and the two become one row and one number, which is how a call booked on three Forms reads as one outcome. Read the existing names first (run with no flags) and reuse one before inventing another; a name that only differs by case, accent, punctuation or a plural is refused, because it would split the number in two",
29468
+ required: false
29469
+ },
29470
+ rename: {
29471
+ type: "string",
29472
+ description: "An outcome's current name. With --to, renames every event counted under it. Renaming ONTO a name that already exists MERGES them into one row and one number \u2014 the way to fix a company whose Forms each invented their own word for the same outcome",
29473
+ required: false
29474
+ },
29475
+ to: {
29476
+ type: "string",
29477
+ description: "The new name for --rename",
29478
+ required: false
29479
+ },
29480
+ "remove-name": {
29481
+ type: "string",
29482
+ description: "Stop counting an outcome entirely, by name \u2014 removes every event counted under it. Use --remove for a single event key instead",
28959
29483
  required: false
28960
29484
  },
28961
29485
  "count-mode": {
28962
29486
  type: "string",
28963
- description: "once_per_visit (default) or every_time. Ask whether doing it twice in one visit is one outcome or two: a booking clicked twice is one booking, a guide downloaded twice is two downloads. Getting this wrong inflates the rate every budget is set by",
29487
+ description: "once_per_visit (default) or every_time. Ask whether doing it twice in one visit is one outcome or two: a booking clicked twice is one booking, a guide downloaded twice is two downloads. Getting this wrong inflates the rate every budget is set by. Pass it with --event and no --name to change the mode of something already counted, keeping its name",
28964
29488
  required: false
28965
29489
  },
28966
29490
  remove: {
28967
29491
  type: "string",
28968
- description: "An event key to stop counting. Removes it from the history too, for the same reason adding it fills the history in",
29492
+ description: "An event key to stop counting. REPEATABLE. Removes it from the history too, for the same reason adding it fills the history in",
28969
29493
  required: false
28970
29494
  },
28971
29495
  candidates: {
@@ -28973,6 +29497,11 @@ var conversionsCommand3 = (() => {
28973
29497
  description: "Also list every event these pages actually produced, ranked by volume, with whether it is already counted. This is where an event key comes from \u2014 do not invent one. Off by default because a busy company produces hundreds of rows",
28974
29498
  required: false
28975
29499
  },
29500
+ flow: {
29501
+ type: "string",
29502
+ description: "A Form slug. Lists that Form's own endings already spelled as event keys, each with the name Baker counts it under or nothing \u2014 read off the Form's file, so it works on a Form built minutes ago that has never been visited, which is exactly when --candidates is empty. Use it whenever you create or edit a Form: it is the only way to see that the Form counts nothing before it is published counting nothing",
29503
+ required: false
29504
+ },
28976
29505
  days: {
28977
29506
  type: "string",
28978
29507
  description: "Lookback for the counts and for --candidates. Default 30",
@@ -28981,34 +29510,33 @@ var conversionsCommand3 = (() => {
28981
29510
  };
28982
29511
  registerSchema({
28983
29512
  command: "analytics.conversions",
28984
- description: "Read or change what this company counts as a conversion",
29513
+ description: "Read or change what this company counts as a conversion. Also `--flow <slug>` for one Form's own endings as event keys, whether or not any of them has fired.",
28985
29514
  args
28986
29515
  });
28987
29516
  return defineCommand95({
28988
29517
  meta: {
28989
29518
  name: "conversions",
28990
- description: "What this company counts as a conversion, and what each one produced. Run with no flags first: an empty `definitions` list means every conversion number in every other report is zero \u2014 not because nobody converted, but because nothing is marked as converting, and Baker never guesses. Add `--candidates` to see everything these pages actually do (every Form step, every data-baker-* event, every outbound destination) with counts and whether it is already counted; that list is where an event key comes from, so never invent one. Then `--event <key> --name 'Booked a call'` to count it. It applies IMMEDIATELY and retroactively \u2014 the whole history is rescored, so a company that has been running for a month gets a month of correct numbers the moment you name the right event. Nothing here is staged and publishing is not involved. Two events given the SAME --name are one row and one number. `--remove <key>` stops counting one."
29519
+ description: "What this company counts as a conversion, and what each one produced. Run with no flags first: an empty `definitions` list means every conversion number in every other report is zero \u2014 not because nobody converted, but because nothing is marked as converting, and Baker never guesses. Add `--candidates` to see everything these pages actually do (every Form step, every data-baker-* event, every outbound destination) with counts and whether it is already counted; that list is where an event key comes from, so never invent one. Then `--event <key> --name 'Booked a call'` to count it. It applies IMMEDIATELY and retroactively \u2014 the whole history is rescored, so a company that has been running for a month gets a month of correct numbers the moment you name the right event. Nothing here is staged and publishing is not involved. Two events given the SAME --name are one row and one number. `--remove <key>` stops counting one. `--flow <slug>` answers the other direction \u2014 what one Form counts, with every ending it has already spelled as an event key, read from the Form itself so it works before a single visitor has seen it. Run it whenever you create or edit a Form: a Form nobody named an outcome for is published converting nobody, and no report says so."
28991
29520
  },
28992
29521
  args,
28993
29522
  run: async ({ args: raw }) => {
28994
29523
  try {
28995
29524
  const args2 = parseConversionsArgs(raw, process.argv);
28996
- const write2 = conversionsWrite(args2);
28997
- if (write2) {
28998
- await apiPost("/api/analytics/conversions", write2);
28999
- }
29525
+ for (const eventKey of args2.eventKeys) refuseUnmatchableKey(eventKey);
29526
+ const before = await apiPost("/api/analytics/conversions", {});
29527
+ const write2 = conversionsWrite(args2, before.data.definitions);
29528
+ const stored = write2 ? (await apiPost("/api/analytics/conversions", write2)).data.definitions : before.data.definitions;
29000
29529
  const report = await apiPost("/api/analytics/query", {
29001
29530
  preset: "conversions",
29002
29531
  days: args2.days
29003
29532
  });
29004
- const definitions = report.data.conversions ?? [];
29005
- const candidates = report.data.eventCatalog ?? [];
29006
- const hints2 = conversionHints(definitions, candidates, args2.candidates);
29007
- writeJsonEnvelope({
29008
- ok: true,
29009
- data: { definitions, ...args2.candidates ? { candidates } : {} },
29010
- ...hints2.length > 0 ? { hints: hints2 } : {}
29011
- });
29533
+ writeJsonEnvelope(
29534
+ conversionsEnvelope(
29535
+ args2,
29536
+ mergeStoredWithCounts(stored, report.data.conversions ?? []),
29537
+ report.data.eventCatalog ?? []
29538
+ )
29539
+ );
29012
29540
  } catch (err) {
29013
29541
  if (err instanceof ConversionArgsError) {
29014
29542
  handleError(new ApiError("VALIDATION_ERROR", err.message));
@@ -31310,10 +31838,10 @@ import path15 from "path";
31310
31838
  import { defineCommand as defineCommand107 } from "citty";
31311
31839
 
31312
31840
  // src/commands/canvas/normalize-paths.ts
31313
- import { existsSync as existsSync3, realpathSync } from "fs";
31841
+ import { existsSync as existsSync4, realpathSync } from "fs";
31314
31842
  import { writeFile as writeFile2 } from "fs/promises";
31315
31843
  import path5 from "path";
31316
- function findWorkspaceRoot(startDir, exists = existsSync3, maxDepth = 12) {
31844
+ function findWorkspaceRoot(startDir, exists = existsSync4, maxDepth = 12) {
31317
31845
  let dir = path5.resolve(startDir);
31318
31846
  for (let i = 0; i < maxDepth; i++) {
31319
31847
  if (exists(path5.join(dir, "package.json"))) return dir;
@@ -36958,7 +37486,7 @@ function routeVideoModel(input) {
36958
37486
  import { execFile as execFile2 } from "child_process";
36959
37487
  import { mkdtemp, readdir as readdir7, readFile as readFile14, rm as rm5 } from "fs/promises";
36960
37488
  import { tmpdir } from "os";
36961
- import { join as join2 } from "path";
37489
+ import { join as join3 } from "path";
36962
37490
  import { promisify as promisify2 } from "util";
36963
37491
  var execFileAsync2 = promisify2(execFile2);
36964
37492
  var PYSCENEDETECT_THRESHOLD = 18;
@@ -37011,7 +37539,7 @@ function parsePySceneDetectCsvCuts(csv) {
37011
37539
  return [...new Set(cuts)].sort((a, b) => a - b);
37012
37540
  }
37013
37541
  async function runSceneDetectOnce(filePath, threshold, minSceneLenS, timeoutMs) {
37014
- const outDir = await mkdtemp(join2(tmpdir(), "baker-scenedetect-"));
37542
+ const outDir = await mkdtemp(join3(tmpdir(), "baker-scenedetect-"));
37015
37543
  try {
37016
37544
  await execFileAsync2(
37017
37545
  "scenedetect",
@@ -37032,7 +37560,7 @@ async function runSceneDetectOnce(filePath, threshold, minSceneLenS, timeoutMs)
37032
37560
  );
37033
37561
  const csvName = (await readdir7(outDir)).find((f) => f.toLowerCase().endsWith(".csv"));
37034
37562
  if (!csvName) return [];
37035
- return parsePySceneDetectCsvCuts(await readFile14(join2(outDir, csvName), "utf-8"));
37563
+ return parsePySceneDetectCsvCuts(await readFile14(join3(outDir, csvName), "utf-8"));
37036
37564
  } finally {
37037
37565
  await rm5(outDir, { recursive: true, force: true });
37038
37566
  }
@@ -37056,9 +37584,9 @@ async function detectSceneCutsPySceneDetect(filePath, opts = {}) {
37056
37584
  }
37057
37585
 
37058
37586
  // src/commands/canvas/composition-path.ts
37059
- import { existsSync as existsSync4 } from "fs";
37587
+ import { existsSync as existsSync5 } from "fs";
37060
37588
  import path21 from "path";
37061
- function resolveShippedCanvasDir(name, startDir, exists = existsSync4, maxDepth = 8) {
37589
+ function resolveShippedCanvasDir(name, startDir, exists = existsSync5, maxDepth = 8) {
37062
37590
  const rel = path21.join("canvas", name);
37063
37591
  let dir = startDir;
37064
37592
  for (let i = 0; i < maxDepth; i++) {
@@ -38432,176 +38960,6 @@ import { defineCommand as defineCommand123 } from "citty";
38432
38960
  // src/commands/flows/add.ts
38433
38961
  import { randomUUID } from "crypto";
38434
38962
  import { defineCommand as defineCommand119 } from "citty";
38435
-
38436
- // src/commands/flows/shared.ts
38437
- import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync11, writeFileSync as writeFileSync3 } from "fs";
38438
- import { join as join3 } from "path";
38439
- var FLOWS_DIR = "src/lib/flow-engine/flows";
38440
- function flowsDir() {
38441
- let dir = process.cwd();
38442
- for (let i = 0; i < 6; i++) {
38443
- const candidate = join3(dir, FLOWS_DIR);
38444
- if (existsSync5(candidate)) {
38445
- return candidate;
38446
- }
38447
- const parent = join3(dir, "..");
38448
- if (parent === dir) {
38449
- break;
38450
- }
38451
- dir = parent;
38452
- }
38453
- return join3(process.cwd(), FLOWS_DIR);
38454
- }
38455
- function failLocal(message) {
38456
- writeJson({ ok: false, error: { code: "NOT_FOUND", message } });
38457
- process.exit(1);
38458
- }
38459
- function listFlowSlugs() {
38460
- const dir = flowsDir();
38461
- if (!existsSync5(dir)) {
38462
- return [];
38463
- }
38464
- return readdirSync2(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.name.startsWith("_") && entry.name !== ".gitkeep").map((entry) => entry.name).sort();
38465
- }
38466
- function flowDataPath(slug) {
38467
- return join3(flowsDir(), slug, "_data.json");
38468
- }
38469
- function writeFlowTree(slug, tree) {
38470
- writeFileSync3(flowDataPath(slug), `${JSON.stringify(tree, null, 2)}
38471
- `, "utf-8");
38472
- }
38473
- function walkNodes(node, acc = []) {
38474
- if (!node || typeof node !== "object") return acc;
38475
- acc.push(node);
38476
- if (Array.isArray(node.children)) {
38477
- for (const child of node.children) walkNodes(child, acc);
38478
- }
38479
- return acc;
38480
- }
38481
- function collectSideEffects(tree) {
38482
- return walkNodes(tree).flatMap(
38483
- (node) => Array.isArray(node.sideEffects) ? node.sideEffects.filter((sideEffect) => Boolean(sideEffect) && typeof sideEffect === "object").map((sideEffect) => ({ node, sideEffect })) : []
38484
- );
38485
- }
38486
- function readFlowTree(slug) {
38487
- const path38 = join3(flowsDir(), slug, "_data.json");
38488
- if (!existsSync5(path38)) {
38489
- failLocal(`No form "${slug}". Run "baker flows list" to see the forms in this workspace.`);
38490
- }
38491
- try {
38492
- return JSON.parse(readFileSync11(path38, "utf-8"));
38493
- } catch (error) {
38494
- failLocal(`Could not read form "${slug}": ${error instanceof Error ? error.message : String(error)}`);
38495
- }
38496
- }
38497
- function isConfigured(value) {
38498
- return typeof value === "string" && value.length > 0;
38499
- }
38500
- function nodeMeta(record) {
38501
- const nodeId = typeof record.id === "string" ? record.id : "";
38502
- const nodeName = typeof record.name === "string" ? record.name : void 0;
38503
- return { nodeId, ...nodeName ? { nodeName } : {} };
38504
- }
38505
- function widgetNodeStatus(record, meta) {
38506
- const nodeData = record.nodeData;
38507
- const nodeType = nodeData?.type;
38508
- if (typeof nodeType !== "string" || !FLOW_RESOURCE_NODE_TYPES.includes(nodeType)) {
38509
- return null;
38510
- }
38511
- const external = nodeData?.form?.external;
38512
- const selected = external != null && typeof external === "object";
38513
- const name = selected ? external.name : void 0;
38514
- return {
38515
- ...meta,
38516
- target: "node",
38517
- kind: nodeType,
38518
- status: selected ? `${nodeType} \u2014 selected${typeof name === "string" ? ` "${name}"` : ""}` : `${nodeType} \u2014 not selected`,
38519
- needsInput: !selected
38520
- };
38521
- }
38522
- function sideEffectNeedsInput(raw) {
38523
- const effect = raw;
38524
- const type = effect?.type;
38525
- if (typeof type !== "string" || !FLOW_SECRET_SIDE_EFFECT_TYPES.includes(type)) {
38526
- return false;
38527
- }
38528
- const configured = isConfigured(effect?.encryptedConfig);
38529
- if (!isFlowOauthSideEffect(type)) return !configured;
38530
- return !isConfigured(effect?.oauthProviderId) || !configured;
38531
- }
38532
- function sideEffectStatus(raw, meta) {
38533
- const effect = raw;
38534
- const type = effect?.type;
38535
- if (typeof type !== "string" || !FLOW_SECRET_SIDE_EFFECT_TYPES.includes(type)) {
38536
- return null;
38537
- }
38538
- const configured = isConfigured(effect?.encryptedConfig);
38539
- const oauth = isFlowOauthSideEffect(type);
38540
- const connected = isConfigured(effect?.oauthProviderId);
38541
- const needs = FLOW_SECRET_FIELDS[type];
38542
- const status = oauth ? `${type} \u2014 connection ${connected ? "[connected]" : "[needs connection]"}, config ${configured ? "[set]" : "[missing]"}` : configured ? `${type} \u2014 [configured]` : `${type} \u2014 [missing]${needs.length > 0 ? ` (needs: ${needs.join(", ")})` : ""}`;
38543
- return {
38544
- ...meta,
38545
- target: "sideEffect",
38546
- ...typeof effect?.id === "string" ? { sideEffectId: effect.id } : {},
38547
- kind: type,
38548
- status,
38549
- needsInput: sideEffectNeedsInput(effect)
38550
- };
38551
- }
38552
- function requestFlowInputCall(slug, status) {
38553
- const args = status.target === "node" ? `{ target: "node", flowSlug: "${slug}", nodeId: "${status.nodeId}", nodeType: "${status.kind}" }` : `{ target: "sideEffect", flowSlug: "${slug}", nodeId: "${status.nodeId}", sideEffectId: "${status.sideEffectId}", sideEffectType: "${status.kind}" }`;
38554
- return `request_flow_input with ${args}`;
38555
- }
38556
- function collectConfigStatus(node, acc) {
38557
- if (!node || typeof node !== "object") {
38558
- return;
38559
- }
38560
- const record = node;
38561
- const meta = nodeMeta(record);
38562
- const widget = widgetNodeStatus(record, meta);
38563
- if (widget) {
38564
- acc.push(widget);
38565
- }
38566
- if (Array.isArray(record.sideEffects)) {
38567
- for (const raw of record.sideEffects) {
38568
- const status = sideEffectStatus(raw, meta);
38569
- if (status) {
38570
- acc.push(status);
38571
- }
38572
- }
38573
- }
38574
- if (Array.isArray(record.children)) {
38575
- for (const child of record.children) {
38576
- collectConfigStatus(child, acc);
38577
- }
38578
- }
38579
- }
38580
- function redactTree(node) {
38581
- if (Array.isArray(node)) {
38582
- return node.map(redactTree);
38583
- }
38584
- if (!node || typeof node !== "object") {
38585
- return node;
38586
- }
38587
- const out = {};
38588
- for (const [key, value] of Object.entries(node)) {
38589
- if (key === "encryptedConfig") {
38590
- out[key] = isConfigured(value) ? "[configured]" : value;
38591
- } else {
38592
- out[key] = redactTree(value);
38593
- }
38594
- }
38595
- return out;
38596
- }
38597
- function stepNameKey(name) {
38598
- return name.replace(/([a-z])([A-Z])/g, "$1_$2").replace(/[-\s]+/g, "_").toUpperCase();
38599
- }
38600
- function isUsableStepNameKey(key) {
38601
- return /^[\p{ID_Start}$_][\p{ID_Continue}$‌‍]*$/u.test(key);
38602
- }
38603
-
38604
- // src/commands/flows/add.ts
38605
38963
  function unknownType(kind, type) {
38606
38964
  writeJson({
38607
38965
  ok: false,
@@ -38692,6 +39050,10 @@ var addNodeCommand = defineCommand119({
38692
39050
  data: { slug, nodeId: node.id, type, parentId: parent.id ?? null, path: flowDataPath(slug) },
38693
39051
  hints: [
38694
39052
  `Now fill in this step's \`ai\` field and its copy by editing ${flowDataPath(slug)}.`,
39053
+ // Said on every step added, because the moment a Form's shape changes
39054
+ // is the moment its outcome can move — and a Form nobody named an
39055
+ // outcome for reports zero conversions rather than an error.
39056
+ `Say what this Form counts before you finish: \`baker analytics conversions --flow ${slug}\` lists every ending already spelled as an event key, including this step's. Nothing in _data.json decides it.`,
38695
39057
  ...entry.owners?.["nodeData.form.external"] === "request_flow_input" ? [
38696
39058
  `A ${type} node's resource is picked by the user: call request_flow_input with { target: "node", flowSlug: "${slug}", nodeId: "${node.id}", nodeType: "${type}" }.`
38697
39059
  ] : [],
@@ -38805,6 +39167,13 @@ var addSideEffectCommand = defineCommand119({
38805
39167
  data: { slug, nodeId: node.id, sideEffectId: id, type, triggerId, path: flowDataPath(slug) },
38806
39168
  hints: [
38807
39169
  `Fires on \`${triggerId}\` \u2014 the moment "${String(node.name ?? node.id)}" raises. A side effect only runs when its triggerId is the one its own step raises, so this is set from the step, never from the side-effect type.`,
39170
+ // A pixel side effect is the thing most often mistaken for "setting the
39171
+ // conversion". It tells the ad platform; it tells Baker nothing, and a
39172
+ // Form can fire every pixel it has while every Baker report says it
39173
+ // converted nobody.
39174
+ ...needsUser.some(([, owner]) => owner === "tags") ? [
39175
+ `This reports the conversion to ${type}. It does NOT make Baker count anything: what Baker counts is a named event, set with \`baker analytics conversions --flow ${slug}\`. Do both \u2014 they are two different systems asked the same question.`
39176
+ ] : [],
38808
39177
  ...needsUser.some(([, owner]) => owner === "request_flow_input") ? [
38809
39178
  `Configure it: request_flow_input with { target: "sideEffect", flowSlug: "${slug}", nodeId: "${node.id}", sideEffectId: "${id}", sideEffectType: "${type}" }. Read the destination straight off that call's result.`
38810
39179
  ] : [],
@@ -38818,11 +39187,11 @@ var addSideEffectCommand = defineCommand119({
38818
39187
  });
38819
39188
 
38820
39189
  // src/commands/flows/map.ts
38821
- import { readFileSync as readFileSync13 } from "fs";
39190
+ import { readFileSync as readFileSync14 } from "fs";
38822
39191
  import { defineCommand as defineCommand120 } from "citty";
38823
39192
 
38824
39193
  // src/commands/flows/value-expression.ts
38825
- import { existsSync as existsSync6, readFileSync as readFileSync12 } from "fs";
39194
+ import { existsSync as existsSync6, readFileSync as readFileSync13 } from "fs";
38826
39195
  import { join as join4 } from "path";
38827
39196
  var PREFIXES = ["field", "tracking", "global", "code"];
38828
39197
  function splitParts(raw) {
@@ -38884,7 +39253,7 @@ function trackingFieldIds() {
38884
39253
  const path38 = join4(flowsDir(), "..", "tracking.ts");
38885
39254
  if (!existsSync6(path38)) return null;
38886
39255
  try {
38887
- const source = readFileSync12(path38, "utf-8");
39256
+ const source = readFileSync13(path38, "utf-8");
38888
39257
  const block2 = source.match(/TRACKING_FIELD_IDS\s*=\s*\[([\s\S]*?)\]\s*as const/)?.[1];
38889
39258
  if (!block2) return null;
38890
39259
  const ids = [...block2.matchAll(/"(tracking\.[a-z0-9_]+)"/g)].map((match) => match[1]);
@@ -39428,7 +39797,7 @@ function specsFromFile(parsed) {
39428
39797
  function readSpecFile(path38) {
39429
39798
  let raw;
39430
39799
  try {
39431
- raw = path38 === "-" ? readFileSync13(0, "utf-8") : readFileSync13(path38, "utf-8");
39800
+ raw = path38 === "-" ? readFileSync14(0, "utf-8") : readFileSync14(path38, "utf-8");
39432
39801
  } catch (error) {
39433
39802
  refuse(
39434
39803
  `Could not read ${path38 === "-" ? "the mapping from stdin" : `"${path38}"`}: ${error instanceof Error ? error.message : String(error)}`
@@ -39969,9 +40338,15 @@ var listCommand13 = defineCommand123({
39969
40338
  writeJson({
39970
40339
  ok: true,
39971
40340
  data: { flows },
39972
- hints: pending.length > 0 ? [
39973
- `Still waiting on the user: ${pending.map((flow) => `${flow.slug} (${flow.needsConfig})`).join(", ")}. \`baker flows show <slug>\` returns the request_flow_input call for each.`
39974
- ] : []
40341
+ hints: [
40342
+ ...pending.length > 0 ? [
40343
+ `Still waiting on the user: ${pending.map((flow) => `${flow.slug} (${flow.needsConfig})`).join(", ")}. \`baker flows show <slug>\` returns the request_flow_input call for each.`
40344
+ ] : [],
40345
+ // "Set the conversions on the Forms" lands here first, and nothing in
40346
+ // this family can do it — the conversion side effects it does offer
40347
+ // fire a pixel and are not what any Baker report counts.
40348
+ "Setting what a Form COUNTS is not in this family: a conversion is a named event held in Baker. `baker analytics conversions` lists what this company counts; `--flow <slug>` spells one Form's endings as keys you can count, traffic or no traffic."
40349
+ ]
39975
40350
  });
39976
40351
  }
39977
40352
  });
@@ -39999,7 +40374,15 @@ var showCommand4 = defineCommand123({
39999
40374
  data: response,
40000
40375
  // Inert side effects first: an unconfigured destination is work still to
40001
40376
  // do, but a side effect wired to the wrong moment reads as done.
40002
- hints: [...inertSideEffectHints(tree), ...pendingInputHints(slug, config)]
40377
+ hints: [
40378
+ ...inertSideEffectHints(tree),
40379
+ ...pendingInputHints(slug, config),
40380
+ // The question this command is asked — "is this Form wired up?" — has
40381
+ // an answer that is not in this file at all. What a Form counts is a
40382
+ // named event in Baker, and a Form can be perfectly configured, fire
40383
+ // every pixel it has, and still report converting nobody.
40384
+ `What this Form counts as a conversion is NOT in _data.json and not in any side effect here: it is a named event held in Baker. Read it, and this Form's own endings spelled as event keys, with \`baker analytics conversions --flow ${slug}\`.`
40385
+ ]
40003
40386
  });
40004
40387
  }
40005
40388
  });
@@ -40164,7 +40547,7 @@ Examples:
40164
40547
  import { defineCommand as defineCommand125 } from "citty";
40165
40548
 
40166
40549
  // src/commands/ga4/shared.ts
40167
- import { readFileSync as readFileSync14 } from "fs";
40550
+ import { readFileSync as readFileSync15 } from "fs";
40168
40551
  function failValidation3(message) {
40169
40552
  writeJsonEnvelope({ ok: false, error: { code: "VALIDATION_ERROR", message } });
40170
40553
  process.exit(1);
@@ -40180,7 +40563,7 @@ function requireTarget5(args, entity) {
40180
40563
  function readJsonSource(args, flag) {
40181
40564
  const inline = args[flag];
40182
40565
  const file = args.file;
40183
- const raw = typeof file === "string" && file.length > 0 ? readFileSync14(file, "utf8") : typeof inline === "string" && inline.length > 0 ? inline : void 0;
40566
+ const raw = typeof file === "string" && file.length > 0 ? readFileSync15(file, "utf8") : typeof inline === "string" && inline.length > 0 ? inline : void 0;
40184
40567
  if (raw === void 0) {
40185
40568
  failValidation3(`pass --${flag} with inline JSON or --file with a path to a JSON file`);
40186
40569
  }
@@ -40531,7 +40914,7 @@ Examples:
40531
40914
  });
40532
40915
 
40533
40916
  // src/commands/ga4/query.ts
40534
- import { appendFileSync as appendFileSync2, existsSync as existsSync7, readFileSync as readFileSync15, writeFileSync as writeFileSync4 } from "fs";
40917
+ import { appendFileSync as appendFileSync2, existsSync as existsSync7, readFileSync as readFileSync16, writeFileSync as writeFileSync4 } from "fs";
40535
40918
  import { resolve as resolve2 } from "path";
40536
40919
  import { defineCommand as defineCommand128 } from "citty";
40537
40920
 
@@ -40622,7 +41005,7 @@ function writeRowsToFile2(filePath, rows, append) {
40622
41005
  writeFileSync4(filePath, content, "utf-8");
40623
41006
  }
40624
41007
  } else if (append && existsSync7(filePath)) {
40625
- const existing = JSON.parse(readFileSync15(filePath, "utf-8"));
41008
+ const existing = JSON.parse(readFileSync16(filePath, "utf-8"));
40626
41009
  writeFileSync4(filePath, JSON.stringify([...existing, ...rows], null, 2), "utf-8");
40627
41010
  } else {
40628
41011
  writeFileSync4(filePath, JSON.stringify(rows, null, 2), "utf-8");
@@ -41474,7 +41857,7 @@ Full guide: __tooling__/docs/tools/baker/ga4.md`
41474
41857
  import { defineCommand as defineCommand134 } from "citty";
41475
41858
 
41476
41859
  // src/commands/gsc/query.ts
41477
- import { appendFileSync as appendFileSync3, existsSync as existsSync8, readFileSync as readFileSync16, writeFileSync as writeFileSync5 } from "fs";
41860
+ import { appendFileSync as appendFileSync3, existsSync as existsSync8, readFileSync as readFileSync17, writeFileSync as writeFileSync5 } from "fs";
41478
41861
  import { resolve as resolve3 } from "path";
41479
41862
  import { defineCommand as defineCommand131 } from "citty";
41480
41863
 
@@ -41622,7 +42005,7 @@ function writeRowsToFile3(filePath, rows, append) {
41622
42005
  writeFileSync5(filePath, content, "utf-8");
41623
42006
  }
41624
42007
  } else if (append && existsSync8(filePath)) {
41625
- const existing = JSON.parse(readFileSync16(filePath, "utf-8"));
42008
+ const existing = JSON.parse(readFileSync17(filePath, "utf-8"));
41626
42009
  writeFileSync5(filePath, JSON.stringify([...existing, ...rows], null, 2), "utf-8");
41627
42010
  } else {
41628
42011
  writeFileSync5(filePath, JSON.stringify(rows, null, 2), "utf-8");
@@ -48289,7 +48672,7 @@ var inPageCollectUsedCss = (selector) => {
48289
48672
  }
48290
48673
  return false;
48291
48674
  };
48292
- const walk = (rules, sink) => {
48675
+ const walk2 = (rules, sink) => {
48293
48676
  for (const rule of Array.from(rules)) {
48294
48677
  totalRules++;
48295
48678
  if (rule instanceof CSSStyleRule) {
@@ -48310,7 +48693,7 @@ var inPageCollectUsedCss = (selector) => {
48310
48693
  const grouping = rule instanceof CSSMediaRule || rule instanceof CSSSupportsRule || typeof CSSLayerBlockRule !== "undefined" && rule instanceof CSSLayerBlockRule || typeof CSSContainerRule !== "undefined" && rule instanceof CSSContainerRule;
48311
48694
  if (grouping) {
48312
48695
  const inner = [];
48313
- walk(rule.cssRules, inner);
48696
+ walk2(rule.cssRules, inner);
48314
48697
  if (inner.length === 0) continue;
48315
48698
  const condition = rule.conditionText ?? "";
48316
48699
  const prelude = rule instanceof CSSMediaRule ? `@media ${condition}` : rule.cssText.split("{")[0]?.trim();
@@ -48324,7 +48707,7 @@ ${inner.join("\n")}
48324
48707
  const owner = sheet.ownerNode;
48325
48708
  if (owner?.hasAttribute?.("data-baker-freeze")) continue;
48326
48709
  try {
48327
- walk(sheet.cssRules, kept);
48710
+ walk2(sheet.cssRules, kept);
48328
48711
  } catch {
48329
48712
  if (sheet.href) unreadableHrefs.push(sheet.href);
48330
48713
  }
@@ -48775,7 +49158,7 @@ var inPageCollectMotion = (selector) => {
48775
49158
  else if (infinite) loop.push(effect);
48776
49159
  else entrance.push(effect);
48777
49160
  };
48778
- const walk = (rules, insideScrollTimeline) => {
49161
+ const walk2 = (rules, insideScrollTimeline) => {
48779
49162
  for (const rule of Array.from(rules)) {
48780
49163
  if (rule instanceof CSSKeyframesRule) {
48781
49164
  keyframesByName.set(rule.name, rule.cssText);
@@ -48793,17 +49176,17 @@ var inPageCollectMotion = (selector) => {
48793
49176
  respectsReducedMotion = true;
48794
49177
  continue;
48795
49178
  }
48796
- walk(rule.cssRules, insideScrollTimeline);
49179
+ walk2(rule.cssRules, insideScrollTimeline);
48797
49180
  continue;
48798
49181
  }
48799
49182
  const grouping = rule;
48800
- if (grouping.cssRules) walk(grouping.cssRules, insideScrollTimeline);
49183
+ if (grouping.cssRules) walk2(grouping.cssRules, insideScrollTimeline);
48801
49184
  }
48802
49185
  };
48803
49186
  for (const sheet of Array.from(document.styleSheets)) {
48804
49187
  if (sheet.ownerNode?.hasAttribute?.("data-baker-freeze")) continue;
48805
49188
  try {
48806
- walk(sheet.cssRules, false);
49189
+ walk2(sheet.cssRules, false);
48807
49190
  } catch {
48808
49191
  }
48809
49192
  }
@@ -54256,7 +54639,7 @@ import { defineCommand as defineCommand209 } from "citty";
54256
54639
  import { defineCommand as defineCommand206 } from "citty";
54257
54640
 
54258
54641
  // src/commands/tag-manager/shared.ts
54259
- import { readFileSync as readFileSync17 } from "fs";
54642
+ import { readFileSync as readFileSync18 } from "fs";
54260
54643
  function failValidation5(message) {
54261
54644
  writeJsonEnvelope({ ok: false, error: { code: "VALIDATION_ERROR", message } });
54262
54645
  process.exit(1);
@@ -54272,7 +54655,7 @@ function requireTarget6(args, entity) {
54272
54655
  function loadJsonArg2(args, flag = "json") {
54273
54656
  const inline = args[flag];
54274
54657
  const file = args.file;
54275
- const raw = typeof file === "string" && file.length > 0 ? readFileSync17(file, "utf8") : typeof inline === "string" && inline.length > 0 ? inline : void 0;
54658
+ const raw = typeof file === "string" && file.length > 0 ? readFileSync18(file, "utf8") : typeof inline === "string" && inline.length > 0 ? inline : void 0;
54276
54659
  if (raw === void 0) {
54277
54660
  failValidation5(`pass --${flag} with inline JSON or --file with a path to a JSON file`);
54278
54661
  }
@@ -57463,7 +57846,7 @@ function refuseUnknownFlags(root, argv) {
57463
57846
  }
57464
57847
 
57465
57848
  // src/version.ts
57466
- import { readFileSync as readFileSync18 } from "fs";
57849
+ import { readFileSync as readFileSync19 } from "fs";
57467
57850
  function packageJsonUrl() {
57468
57851
  return new URL("../package.json", import.meta.url);
57469
57852
  }
@@ -57475,7 +57858,7 @@ function parsePackageVersion(raw) {
57475
57858
  throw new Error("Invalid CLI package.json: missing version");
57476
57859
  }
57477
57860
  function getCliVersion() {
57478
- return parsePackageVersion(readFileSync18(packageJsonUrl(), "utf8"));
57861
+ return parsePackageVersion(readFileSync19(packageJsonUrl(), "utf8"));
57479
57862
  }
57480
57863
 
57481
57864
  // src/cli.ts