@wrangled/cli 0.3.149 → 0.3.151
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/api.js +30 -2
- package/dist/guide.js +2 -0
- package/dist/main.js +47 -37
- package/dist/report-columns.js +57 -0
- package/dist/version-check.js +28 -0
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { brand, envPrefix } from "./brand.js";
|
|
5
5
|
import { RUN_ROOT_ENV, writeFileAtomic } from "./files.js";
|
|
6
|
+
import { noteLatestCli } from "./version-check.js";
|
|
6
7
|
export const exitCodes = { ok: 0, error: 1, usage: 2, auth: 3, timedOut: 4, interrupted: 130 };
|
|
7
8
|
export class CliError extends Error {
|
|
8
9
|
exitCode;
|
|
@@ -335,9 +336,9 @@ async function request(connection, path, init) {
|
|
|
335
336
|
});
|
|
336
337
|
}
|
|
337
338
|
catch (error) {
|
|
338
|
-
|
|
339
|
-
throw new CliError(`Cannot reach ${connection.apiUrl}: ${cause}`, exitCodes.error);
|
|
339
|
+
throw cannotReach(connection.apiUrl, error);
|
|
340
340
|
}
|
|
341
|
+
noteLatestCli(response);
|
|
341
342
|
if (response.status === 401) {
|
|
342
343
|
throw new CliError(connection.rejected ?? `The API key was rejected by ${connection.apiUrl}. Run \`${brand.slug} login\` with a valid key.`, exitCodes.auth);
|
|
343
344
|
}
|
|
@@ -347,6 +348,33 @@ async function request(connection, path, init) {
|
|
|
347
348
|
}
|
|
348
349
|
return response;
|
|
349
350
|
}
|
|
351
|
+
/** The build the API at `apiUrl` runs; it needs no key. */
|
|
352
|
+
export async function fetchApiVersion(apiUrl) {
|
|
353
|
+
let response;
|
|
354
|
+
let body;
|
|
355
|
+
try {
|
|
356
|
+
response = await fetch(`${apiUrl}/api/v1/version`, { signal: AbortSignal.timeout(VERSION_TIMEOUT_MS) });
|
|
357
|
+
noteLatestCli(response);
|
|
358
|
+
if (!response.ok)
|
|
359
|
+
throw new CliError(`HTTP ${response.status} from /api/v1/version at ${apiUrl}`, exitCodes.error);
|
|
360
|
+
body = await response.json().catch(() => undefined);
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
if (error instanceof CliError)
|
|
364
|
+
throw error;
|
|
365
|
+
throw cannotReach(apiUrl, error);
|
|
366
|
+
}
|
|
367
|
+
const api = body?.api;
|
|
368
|
+
if (typeof api?.cliLatest !== "string" || (api.commit !== null && typeof api.commit !== "string")) {
|
|
369
|
+
throw new CliError(`/api/v1/version at ${apiUrl} did not answer with a version.`, exitCodes.error);
|
|
370
|
+
}
|
|
371
|
+
return { api: { commit: api.commit, cliLatest: api.cliLatest } };
|
|
372
|
+
}
|
|
373
|
+
const VERSION_TIMEOUT_MS = 5_000;
|
|
374
|
+
function cannotReach(apiUrl, error) {
|
|
375
|
+
const cause = error.cause;
|
|
376
|
+
return new CliError(`Cannot reach ${apiUrl}: ${cause?.message || cause?.code || error.message}`, exitCodes.error);
|
|
377
|
+
}
|
|
350
378
|
function parseErrorBody(text) {
|
|
351
379
|
try {
|
|
352
380
|
return JSON.parse(text).error;
|
package/dist/guide.js
CHANGED
|
@@ -156,6 +156,7 @@ For "how is the funnel doing?", "which campaigns are working?" and "where does t
|
|
|
156
156
|
- \`${cli} funnel [--grain day|week|month]\` prints ad spend, leads, booked calls, calls held, sales and net revenue per day, week (starting Monday) or month, with cost per lead, per booked call and per sale and ROAS, then totals for the whole range.
|
|
157
157
|
- \`${cli} campaigns [--channel <channel>] [--limit <n>]\` prints each campaign's spend, leads, booked calls, sales and revenue, best first (most first-touch revenue, then most spend), then totals over every matching campaign. Each lead and sale is credited to the person's first touch and last touch side by side. \`--limit\` is 1 to 200 (default 50); \`--channel\` is meta, google, affiliate, a utm_source, unknown or recorded_in_crm.
|
|
158
158
|
- \`${cli} money [--grain day|week|month]\` prints money in (gross, refunds, net, orders), ad spend, affiliate payouts, what is left after ad costs and ROAS per period, then totals for the whole range, money in per payment source (stripe, ghl) and the biggest refund days. Refunds come off on the day they were made, not the day of the sale. \`left_after_ad_costs\` is net less ad spend and affiliate payouts; it is not profit. Its \`roas\` is net over ad spend alone, so it differs from \`${cli} funnel\`'s, which divides by all spend including affiliate payouts.
|
|
159
|
+
- When no payment tool, store or CRM sales are connected but Triple Whale is, \`${cli} money\` takes money in from Triple Whale's store totals and \`${cli} campaigns\` adds the orders, revenue and ROAS Triple Whale credits to each Facebook campaign (\`orders_triple_whale\`, \`revenue_triple_whale\`, \`roas_triple_whale\`), counted as \`${cli} winners\` counts them. Each says so under its table. Triple Whale can credit one order to several ads, so campaign orders can add up to more than the store's. With neither, each report says what is missing and leaves out the columns that would be blank.
|
|
159
160
|
- Money is never converted or added across currencies: there is one row per currency, and one total per currency. In \`${cli} funnel\`, leads and calls are counted for the whole period and repeat on every currency row, so never add them across its rows. In \`${cli} campaigns\`, each campaign's leads and calls sit on the row of the currency it spends in (or a blank-currency row when it has no spend), so they do add up across rows and totals. Ratios come from the summed numbers, not averages. An empty cell means the source is not connected, or a ratio would divide by 0.
|
|
160
161
|
- \`${cli} metrics query --metric <name> [--by <dimension>]...\` gives any of the standard numbers by name, split and filtered your way, such as \`--metric spend --metric cost_per_lead --by channel --where channel!=affiliate --sort=-spend\`. It sums the parts before dividing, keeps currencies apart, credits leads and sales by channel or campaign to the first touch (\`--attribution last_touch\` for the last), and prints an empty number, never 0, for a source that is not connected. An unknown name fails with the list of real ones. Use it before writing SQL for a number these reports do not split the way you need.
|
|
161
162
|
- \`${cli} metrics search "<everyday words>"\` finds the name \`metrics query\` takes from your own words, such as \`"show rate"\`, \`"cost per booked call"\` or \`"first time buyers"\`: each match with what it means, which of the client's sources feed it and whether they are connected, and for a split like channel or campaign up to five of the client's own values. \`${cli} metrics list\` prints every number and split. The client's own confirmed labels and numbers show beside the catalog's, marked "(custom)". Search before guessing a name.
|
|
@@ -452,6 +453,7 @@ A scheduled agent's run acts as its skill's agent, the way the Facebook ads agen
|
|
|
452
453
|
${cli} login [--client <name>] validate a key (prompted, piped, or ${envPrefix}_API_KEY) and save it, as the default or under a client's name
|
|
453
454
|
${cli} logout [--client <name>] delete the default saved login, or one client's
|
|
454
455
|
${cli} whoami show the current workspace, library access, and which client's key is in use and why
|
|
456
|
+
${cli} version this CLI's version, the commit the API runs, and the latest CLI it ships (also --version); needs no login
|
|
455
457
|
${cli} knowledge search "<question>" [--limit <n>] [--kind <kind>] [--hook-kind <v> ...] short, cited snippets from the know-how libraries (keys with library access)
|
|
456
458
|
${cli} knowledge tag <entry-id> --hook-kind <v> --confidence <c> tag a library entry with the marketing grammar (or --file tags.json)
|
|
457
459
|
${cli} guide what this workspace's data holds, how fresh it is, and what is missing: read it first
|
package/dist/main.js
CHANGED
|
@@ -5,8 +5,9 @@ import { mkdir, readFile, stat } from "node:fs/promises";
|
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
7
7
|
import { parseArgs } from "node:util";
|
|
8
|
-
import { api, ApiError, apiDownload, apiText, apiUpload, CliError, exitCodes, getSigned, IMPORT_LIMITS, DOCUMENT_LIMITS, META_IDS_MAX, PROFILE_MESSAGING_KINDS, OUTCOME_PARTS, FACET_WORDS, GOOGLE_ADS_IDS_MAX, IMPORT_NAME_PATTERN, AGENT_TABLE_LIMITS, AGENT_TABLE_NAME_PATTERN, putSigned, CLIENT_FILE, parseClientName, readSavedLogins, removeCredentials, resolveApiUrl, resolveConnection, resolveLogin, UnreadableCredentialsError, writeCredentials, OUTBOUND_PUSH_MAX, entryTagFlags, streamSigned, } from "./api.js";
|
|
8
|
+
import { api, ApiError, apiDownload, apiText, apiUpload, CliError, exitCodes, getSigned, IMPORT_LIMITS, DOCUMENT_LIMITS, META_IDS_MAX, PROFILE_MESSAGING_KINDS, OUTCOME_PARTS, FACET_WORDS, GOOGLE_ADS_IDS_MAX, IMPORT_NAME_PATTERN, AGENT_TABLE_LIMITS, AGENT_TABLE_NAME_PATTERN, putSigned, CLIENT_FILE, parseClientName, readSavedLogins, removeCredentials, resolveApiUrl, chosenApiUrl, fetchApiVersion, resolveConnection, resolveLogin, UnreadableCredentialsError, writeCredentials, OUTBOUND_PUSH_MAX, entryTagFlags, streamSigned, } from "./api.js";
|
|
9
9
|
import { brand, envPrefix } from "./brand.js";
|
|
10
|
+
import { campaignLayout, money, moneyLayout } from "./report-columns.js";
|
|
10
11
|
import { renderDataMap, renderQuestions } from "./map.js";
|
|
11
12
|
import { apiSetupPath, parseAuthFlag, parseQueryFlags, setupText, submittedText, testText, tryText, waitingText, } from "./api-setup.js";
|
|
12
13
|
import { answerRow, batches, ENRICH_EMAIL_USAGE, parsePeople, parseSteps } from "./enrich.js";
|
|
@@ -28,6 +29,7 @@ import { AGENT_ENV, HARNESS_COMMAND_ENV, RUNNER_TOKEN_ENV, runLoop, runOnce } fr
|
|
|
28
29
|
import { npmRegistry } from "./runner/self-update.js";
|
|
29
30
|
import { SKILL_NAME_PATTERN } from "./skill-zip.js";
|
|
30
31
|
import { installSkillFolder, operatorConnection, packSkill, parseSkillRef, readSkillFolder, unpackSkill, } from "./skills.js";
|
|
32
|
+
import { ownVersion } from "./version-check.js";
|
|
31
33
|
import { versionAtLeast } from "./version.js";
|
|
32
34
|
const DEFAULT_WAIT_SECONDS = 300;
|
|
33
35
|
const META_AD_PAUSE_USAGE = 'meta ad pause <ad-id> --idempotency-key <key> --reason "<why>" [--name "<expected>"] [--run <run-id>]';
|
|
@@ -113,6 +115,25 @@ const commands = {
|
|
|
113
115
|
console.log(`\n${clientReason(choice)}`);
|
|
114
116
|
},
|
|
115
117
|
},
|
|
118
|
+
version: {
|
|
119
|
+
usage: "version this CLI's version, and the API's: the commit it runs and the latest CLI it ships (also --version)",
|
|
120
|
+
async run(args, options) {
|
|
121
|
+
if (args.length > 0)
|
|
122
|
+
throw new CliError("Usage: version", exitCodes.usage);
|
|
123
|
+
const url = await chosenApiUrl(options);
|
|
124
|
+
const api = await fetchApiVersion(url).then(({ api }) => ({ url, commit: api.commit, cliLatest: api.cliLatest }), (error) => {
|
|
125
|
+
if (!(error instanceof CliError))
|
|
126
|
+
throw error;
|
|
127
|
+
console.error(`error: ${error.message}`);
|
|
128
|
+
return null;
|
|
129
|
+
});
|
|
130
|
+
if (options.format === "json")
|
|
131
|
+
return printJson({ cli: ownVersion(), api });
|
|
132
|
+
console.log(`CLI ${ownVersion()}`);
|
|
133
|
+
if (api)
|
|
134
|
+
console.log(`API ${api.url} commit ${api.commit ?? "unknown"}, latest CLI ${api.cliLatest}`);
|
|
135
|
+
},
|
|
136
|
+
},
|
|
116
137
|
"knowledge search": {
|
|
117
138
|
usage: 'knowledge search ["<question>"] [--limit <n>] [--kind message|transcript|document|note] [--library <slug>] [--angle <v>] [--hook-kind <v>] [--ad-format <v>] [--awareness <v>]',
|
|
118
139
|
async run(args, options) {
|
|
@@ -242,7 +263,7 @@ const commands = {
|
|
|
242
263
|
const { funnel } = await api(await resolveConnection(options), `/api/v1/reports/funnel${query}`);
|
|
243
264
|
if (options.format === "json")
|
|
244
265
|
return printJson({ funnel });
|
|
245
|
-
printReport(`Funnel by ${funnel.grain}`, funnel, ["period", "currency"], FUNNEL_NUMBERS);
|
|
266
|
+
printReport(`Funnel by ${funnel.grain}`, funnel, ["period", "currency"], { numbers: FUNNEL_NUMBERS, before: [], after: [] });
|
|
246
267
|
printLeftOut(funnel.left_out, "sales, revenue_net and roas");
|
|
247
268
|
},
|
|
248
269
|
},
|
|
@@ -480,7 +501,7 @@ const commands = {
|
|
|
480
501
|
const { money } = await api(await resolveConnection(options), `/api/v1/reports/money${query}`);
|
|
481
502
|
if (options.format === "json")
|
|
482
503
|
return printJson({ money });
|
|
483
|
-
printReport(`Money by ${money.grain}`, money, ["period", "currency"],
|
|
504
|
+
printReport(`Money by ${money.grain}`, money, ["period", "currency"], moneyLayout(money));
|
|
484
505
|
if (money.by_source.length > 0)
|
|
485
506
|
console.log(`\nMoney in by payment source:\n${renderTable(["source", "currency", "gross", "refunds", "net", "orders"], money.by_source)}`);
|
|
486
507
|
if (money.store_orders_in_stripe?.length) {
|
|
@@ -552,7 +573,7 @@ const commands = {
|
|
|
552
573
|
if (options.format === "json")
|
|
553
574
|
return printJson({ campaignResults });
|
|
554
575
|
const title = campaignResults.channel === null ? "Campaigns, best first" : `${campaignResults.channel} campaigns, best first`;
|
|
555
|
-
printReport(title, campaignResults, ["channel", "campaign_id", "campaign_name", "currency"],
|
|
576
|
+
printReport(title, campaignResults, ["channel", "campaign_id", "campaign_name", "currency"], campaignLayout(campaignResults));
|
|
556
577
|
printLeftOut(campaignResults.left_out, "the totals (the rows keep it)");
|
|
557
578
|
},
|
|
558
579
|
},
|
|
@@ -4032,8 +4053,7 @@ async function withRunner(options, work) {
|
|
|
4032
4053
|
await work({
|
|
4033
4054
|
apiUrl: resolveApiUrl(options.apiUrl, null),
|
|
4034
4055
|
token,
|
|
4035
|
-
|
|
4036
|
-
cliVersion: JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")).version,
|
|
4056
|
+
cliVersion: ownVersion(),
|
|
4037
4057
|
harnessCommand: process.env[HARNESS_COMMAND_ENV] || undefined,
|
|
4038
4058
|
openRouterKey: process.env[OPENROUTER_KEY_ENV] || undefined,
|
|
4039
4059
|
maxSeconds: options.maxSeconds,
|
|
@@ -4103,27 +4123,6 @@ function historyText(history) {
|
|
|
4103
4123
|
return main + catchingUp.join("");
|
|
4104
4124
|
}
|
|
4105
4125
|
const FUNNEL_NUMBERS = ["spend", "leads", "booked_calls", "calls_held", "sales_count", "revenue_net", "new_buyers", "cost_per_lead", "cost_per_booked_call", "cost_per_sale", "cost_per_new_buyer", "roas"];
|
|
4106
|
-
const MONEY_NUMBERS = ["gross", "refunds", "net", "orders", "ad_spend", "affiliate_payouts", "left_after_ad_costs", "roas"];
|
|
4107
|
-
const CAMPAIGN_NUMBERS = [
|
|
4108
|
-
"spend",
|
|
4109
|
-
"leads_first_touch",
|
|
4110
|
-
"leads_last_touch",
|
|
4111
|
-
"booked_calls_first_touch",
|
|
4112
|
-
"sales_first_touch",
|
|
4113
|
-
"revenue_first_touch",
|
|
4114
|
-
"revenue_last_touch",
|
|
4115
|
-
"cost_per_lead_first_touch",
|
|
4116
|
-
"roas_first_touch",
|
|
4117
|
-
"roas_last_touch",
|
|
4118
|
-
];
|
|
4119
|
-
function money(value, currency) {
|
|
4120
|
-
try {
|
|
4121
|
-
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(value);
|
|
4122
|
-
}
|
|
4123
|
-
catch {
|
|
4124
|
-
return `${value.toFixed(2)} ${currency}`;
|
|
4125
|
-
}
|
|
4126
|
-
}
|
|
4127
4126
|
function newIdeasLine({ target, proposedToday, stalled, costPerResult: cpr }, ideasPerDay) {
|
|
4128
4127
|
const split = `${target} of ${ideasPerDay}${proposedToday > 0 ? `, ${proposedToday} already proposed` : ""}`;
|
|
4129
4128
|
if (!cpr)
|
|
@@ -4578,13 +4577,19 @@ function printLeftOut(leftOut, figures) {
|
|
|
4578
4577
|
const rows = leftOut.flatMap((l) => l.amounts.map((a) => ({ fact: l.fact_id, currency: a.currency, revenue: a.revenue, sales: a.sales, rule: l.summary })));
|
|
4579
4578
|
console.log(`\nLeft out of ${figures} by confirmed leave-out rules:\n${renderTable(["fact", "currency", "revenue", "sales", "rule"], rows)}`);
|
|
4580
4579
|
}
|
|
4581
|
-
function printReport(title, report, labels, numbers) {
|
|
4580
|
+
function printReport(title, report, labels, { numbers, before, after }) {
|
|
4581
|
+
if (before.length > 0)
|
|
4582
|
+
console.log(`${before.join("\n")}\n`);
|
|
4582
4583
|
if (report.rows.length === 0)
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
|
|
4587
|
-
|
|
4584
|
+
console.log(`No numbers from ${report.from} to ${report.to}.`);
|
|
4585
|
+
else {
|
|
4586
|
+
console.log(`${title}, ${report.from} to ${report.to}:`);
|
|
4587
|
+
console.log(renderTable([...labels, ...numbers], report.rows));
|
|
4588
|
+
console.log("\nTotals:");
|
|
4589
|
+
console.log(renderTable(["currency", ...numbers], report.totals));
|
|
4590
|
+
}
|
|
4591
|
+
for (const line of after)
|
|
4592
|
+
console.log(line);
|
|
4588
4593
|
}
|
|
4589
4594
|
const agentPath = (options) => `/api/v1/agents/${encodeURIComponent(options.agent)}`;
|
|
4590
4595
|
const FACTS_PATH = "/api/v1/facts";
|
|
@@ -5227,11 +5232,14 @@ the runner starts instead of Claude Code or Codex.
|
|
|
5227
5232
|
|
|
5228
5233
|
Run \`${brand.slug} docs\` for the full guide.
|
|
5229
5234
|
`;
|
|
5230
|
-
/**
|
|
5231
|
-
|
|
5235
|
+
/**
|
|
5236
|
+
* A bare --questions (map's switch) or --version (the version command, with no command before it) as an empty value,
|
|
5237
|
+
* since funnels draft gives --questions a value, and funnels show and dashboards rollback --version.
|
|
5238
|
+
*/
|
|
5239
|
+
const bareSwitches = (argv) => argv.map((arg, i) => ((arg === "--questions" || arg === "--version") && (i === argv.length - 1 || argv[i + 1].startsWith("-")) ? `${arg}=` : arg));
|
|
5232
5240
|
function parseOptions(argv) {
|
|
5233
5241
|
const { values, positionals } = parseArgs({
|
|
5234
|
-
args:
|
|
5242
|
+
args: bareSwitches(argv),
|
|
5235
5243
|
allowPositionals: true,
|
|
5236
5244
|
options: {
|
|
5237
5245
|
format: { type: "string", default: "table" },
|
|
@@ -6996,7 +7004,7 @@ async function newerCliHint() {
|
|
|
6996
7004
|
// Tests run the CLI with NODE_ENV=test; they ask a registry only when they name one, so npm's real version cannot change their output.
|
|
6997
7005
|
if (process.env.NODE_ENV === "test" && !process.env.npm_config_registry)
|
|
6998
7006
|
return undefined;
|
|
6999
|
-
const version =
|
|
7007
|
+
const version = ownVersion();
|
|
7000
7008
|
const latest = await npmRegistry.latest(3_000).catch(() => undefined);
|
|
7001
7009
|
if (latest === undefined || versionAtLeast(version, latest))
|
|
7002
7010
|
return undefined;
|
|
@@ -7004,6 +7012,8 @@ async function newerCliHint() {
|
|
|
7004
7012
|
}
|
|
7005
7013
|
async function main(argv) {
|
|
7006
7014
|
const { positionals, options, help: wantsHelp } = parseOptions(argv);
|
|
7015
|
+
if (positionals.length === 0 && options.version === "" && !wantsHelp)
|
|
7016
|
+
return commands.version.run([], options);
|
|
7007
7017
|
if (wantsHelp || positionals.length === 0) {
|
|
7008
7018
|
process.stdout.write(help);
|
|
7009
7019
|
return;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { brand } from "./brand.js";
|
|
2
|
+
export const MONEY_NUMBERS = ["gross", "refunds", "net", "orders", "ad_spend", "affiliate_payouts", "left_after_ad_costs", "roas"];
|
|
3
|
+
export const CAMPAIGN_NUMBERS = [
|
|
4
|
+
"spend",
|
|
5
|
+
"leads_first_touch",
|
|
6
|
+
"leads_last_touch",
|
|
7
|
+
"booked_calls_first_touch",
|
|
8
|
+
"sales_first_touch",
|
|
9
|
+
"revenue_first_touch",
|
|
10
|
+
"revenue_last_touch",
|
|
11
|
+
"cost_per_lead_first_touch",
|
|
12
|
+
"roas_first_touch",
|
|
13
|
+
"roas_last_touch",
|
|
14
|
+
];
|
|
15
|
+
const MONEY_IN_NUMBERS = new Set(["gross", "refunds", "net", "orders", "left_after_ad_costs", "roas"]);
|
|
16
|
+
const TRIPLE_WHALE_NUMBERS = ["orders_triple_whale", "revenue_triple_whale", "roas_triple_whale"];
|
|
17
|
+
const NO_CRM_SALES = "no payment tool, store or CRM sales are connected";
|
|
18
|
+
export function money(value, currency) {
|
|
19
|
+
try {
|
|
20
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(value);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return `${value.toFixed(2)} ${currency}`;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** A server older than this CLI sends no sales_from; its money in is daily_sales'. */
|
|
27
|
+
export function moneyLayout({ sales_from: from }) {
|
|
28
|
+
if (from === undefined || from.source === "daily_sales")
|
|
29
|
+
return { numbers: MONEY_NUMBERS, before: [], after: [] };
|
|
30
|
+
if (from.source === null)
|
|
31
|
+
return { numbers: MONEY_NUMBERS.filter((n) => !MONEY_IN_NUMBERS.has(n)), before: [from.why], after: [] };
|
|
32
|
+
return { numbers: MONEY_NUMBERS, before: [], after: [`\nMoney in is Triple Whale's ${from.detail}, because ${NO_CRM_SALES}.`] };
|
|
33
|
+
}
|
|
34
|
+
const blankEverywhere = (column, rows) => rows.every((row) => row[column] === null || row[column] === undefined);
|
|
35
|
+
/**
|
|
36
|
+
* Without daily_sales, the first- and last-touch columns blank in every row and total are left out, and with Triple
|
|
37
|
+
* Whale its own orders, revenue and return on ad spend are added.
|
|
38
|
+
*/
|
|
39
|
+
export function campaignLayout(report) {
|
|
40
|
+
const from = report.sales_from;
|
|
41
|
+
if (from === undefined || from.source === "daily_sales")
|
|
42
|
+
return { numbers: CAMPAIGN_NUMBERS, before: [], after: [] };
|
|
43
|
+
const all = [...report.rows, ...report.totals];
|
|
44
|
+
const touches = CAMPAIGN_NUMBERS.filter((n) => n === "spend" || !blankEverywhere(n, all));
|
|
45
|
+
if (from.source === null)
|
|
46
|
+
return { numbers: touches, before: [from.why], after: [] };
|
|
47
|
+
const others = (report.triple_whale_other_campaigns ?? []).map(({ currency, orders, revenue }) => `Triple Whale credits ${orders} more ${orders === 1 ? "order" : "orders"}${revenue === null || currency === null ? "" : ` (${money(revenue, currency)})`} to Facebook ads that aren't in the ad accounts ${brand.name} is connected to, so they aren't in the rows above.`);
|
|
48
|
+
return {
|
|
49
|
+
numbers: [...touches, ...TRIPLE_WHALE_NUMBERS],
|
|
50
|
+
before: [],
|
|
51
|
+
after: [
|
|
52
|
+
`\nOrders and revenue are what Triple Whale credits to each Facebook campaign (${from.detail}), because ${NO_CRM_SALES}. ` +
|
|
53
|
+
"Triple Whale can credit one order to more than one ad, so these can add up to more than the store's own orders.",
|
|
54
|
+
...others,
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { brand } from "./brand.js";
|
|
3
|
+
import { VERSION_PATTERN, versionAtLeast } from "./version.js";
|
|
4
|
+
let own;
|
|
5
|
+
/** This CLI's version, from the package.json one folder above dist/ (and src/). */
|
|
6
|
+
export function ownVersion() {
|
|
7
|
+
own ??= JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
8
|
+
return own;
|
|
9
|
+
}
|
|
10
|
+
/** The response header that names the latest CLI version the API's build ships. */
|
|
11
|
+
export const CLI_LATEST_HEADER = `x-${brand.slug}-cli-latest`;
|
|
12
|
+
/** The line to warn with when `latest` is a newer version than `version`; nothing when it is not, or is no version at all. */
|
|
13
|
+
export function cliUpdateWarning(version, latest) {
|
|
14
|
+
if (!latest || !VERSION_PATTERN.test(latest) || !VERSION_PATTERN.test(version) || versionAtLeast(version, latest))
|
|
15
|
+
return undefined;
|
|
16
|
+
return `warning: this CLI is ${version} and the API's latest is ${latest}. Update: npm install -g ${brand.npmPackage}@latest`;
|
|
17
|
+
}
|
|
18
|
+
let warned = false;
|
|
19
|
+
/** Warns on stderr, once per process, when an API response says a newer CLI is out. */
|
|
20
|
+
export function noteLatestCli(response) {
|
|
21
|
+
if (warned)
|
|
22
|
+
return;
|
|
23
|
+
const warning = cliUpdateWarning(ownVersion(), response.headers.get(CLI_LATEST_HEADER));
|
|
24
|
+
if (warning === undefined)
|
|
25
|
+
return;
|
|
26
|
+
warned = true;
|
|
27
|
+
process.stderr.write(`${warning}\n`);
|
|
28
|
+
}
|